删除 Spring Security 中的 ROLE_ 前缀

1、概览

在配置应用的安全设置时,用户的详细信息可能未包括 Spring Security 预期的 ROLE_ 前缀。这种情况下会导致 “Forbidden” 授权错误,无法访问受保护端点。

本文将带你了解如何配置 Spring Security,以允许使用不带 ROLE_ 前缀的角色。

2、Spring Security 默认行为

首先来看看 Spring Security 角色检查机制的默认行为。

添加一个 InMemoryUserDetailsManager,其中包含一个具有 ADMIN 角色的用户:

@Configuration
public class UserDetailsConfig {
    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        UserDetails admin = User.withUsername("admin")
          .password(encoder().encode("password"))
          .authorities(singletonList(new SimpleGrantedAuthority("ADMIN")))
          .build();

        return new InMemoryUserDetailsManager(admin);
    }

    @Bean
    public PasswordEncoder encoder() {
        return new BCryptPasswordEncoder();
    }
}

如上,创建 UserDetailsConfig 配置类,该类配置了一个 InMemoryUserDetailsManager Bean。在工厂方法内部,使用 PasswordEncoder 来处理用户详细信息的密码。

接着,添加要调用的端点:

@RestController
public class TestSecuredController {

    @GetMapping("/test-resource")
    public ResponseEntity<String> testAdmin() {
        return ResponseEntity.ok("GET request successful");
    }
}

如上,添加了一个简单的 GET 端点,它返回 200 状态代码。

创建 Security 配置:

@Configuration
@EnableWebSecurity
public class DefaultSecurityJavaConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http.authorizeHttpRequests (authorizeRequests -> authorizeRequests
          .requestMatchers("/test-resource").hasRole("ADMIN"))
          .httpBasic(withDefaults())
          .build();
    }
}

如上,创建了一个 SecurityFilterChain Bean,指定只有具有 ADMIN 角色的用户才能访问 test-resource 端点。

现在,将配置添加到测试上下文中,并调用受保护的端点:

@WebMvcTest(controllers = TestSecuredController.class)
@ContextConfiguration(classes = { DefaultSecurityJavaConfig.class, UserDetailsConfig.class,
        TestSecuredController.class })
public class DefaultSecurityFilterChainIntegrationTest {

    @Autowired
    private WebApplicationContext wac;

    private MockMvc mockMvc;

    @BeforeEach
    void setup() {
        mockMvc =  MockMvcBuilders
          .webAppContextSetup(wac)
          .apply(SecurityMockMvcConfigurers.springSecurity())
          .build();
    }

    @Test
    void givenDefaultSecurityFilterChainConfig_whenCallTheResourceWithAdminRole_thenForbiddenResponseCodeExpected() throws Exception {
        MockHttpServletRequestBuilder with = MockMvcRequestBuilders.get("/test-resource")
          header("Authorization", basicAuthHeader("admin", "password"));

        ResultActions performed = mockMvc.perform(with);

        MvcResult mvcResult = performed.andReturn();
        assertEquals(403, mvcResult.getResponse().getStatus());
    }
}

我们在测试上下文中添加了用户详细信息配置、Security 配置和 Controller Bean。然后,使用 admin 用户凭证调用了测试资源,并在 Basic Authorization Header 中发送了这些凭证。但是,我们收到的不是 200 响应,而是 403 Forbidden 响应状态码。

如果我们深入研究 AuthorityAuthorizationManager.hasRole() 方法的实现,就会看到下面的代码:

public static <T> AuthorityAuthorizationManager<T> hasRole(String role) {
    Assert.notNull(role, "role cannot be null");
    Assert.isTrue(!role.startsWith(ROLE_PREFIX), () -> role + " should not start with " + ROLE_PREFIX + " since "
      + ROLE_PREFIX + " is automatically prepended when using hasRole. Consider using hasAuthority instead.");
    return hasAuthority(ROLE_PREFIX + role);
}

可以看到,这里的 ROLE_PREFIX 是硬编码,所有角色都应包含它才能通过验证。在使用 @RolesAllowed 等注解时,也会遇到类似的情况。

3、使用权限而不是角色

最简单的解决此问题的方法是使用权限(Authority)而不是角色(Role),权限不需要预期的前缀。

使用权限可以帮助我们避免与前缀相关的问题。

3.1、基于 SecurityFilterChain 的配置

UserDetailsConfig 类中修改用户详细信息:

@Configuration
public class UserDetailsConfig {
    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
        UserDetails admin = User.withUsername("admin")
          .password(encoder.encode("password"))
          .authorities(Arrays.asList(new SimpleGrantedAuthority("ADMIN"),
            new SimpleGrantedAuthority("ADMINISTRATION")))
          .build();

        return new InMemoryUserDetailsManager(admin);
    }
}

我们为 admin 用户添加了一个名为 ADMINISTRATION 的权限。然后,创建基于权限授权访问的 Security 配置:

@Configuration
@EnableWebSecurity
public class AuthorityBasedSecurityJavaConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http.authorizeHttpRequests (authorizeRequests -> authorizeRequests
            .requestMatchers("/test-resource").hasAuthority("ADMINISTRATION"))
            .httpBasic(withDefaults())
            .build();
    }
}

在此配置中,我们实现了相同的访问限制概念,但使用了 AuthorityAuthorizationManager.hasAuthority() 方法。

将新的 Security 配置设置到上下文中,然后调用受保护的端点:

@WebMvcTest(controllers = TestSecuredController.class)
@ContextConfiguration(classes = { AuthorityBasedSecurityJavaConfig.class, UserDetailsConfig.class,
        TestSecuredController.class })
public class AuthorityBasedSecurityFilterChainIntegrationTest {

    @Autowired
    private WebApplicationContext wac;

    private MockMvc mockMvc;

    @BeforeEach
    void setup() {
        mockMvc =  MockMvcBuilders
          .webAppContextSetup(wac)
          .apply(SecurityMockMvcConfigurers.springSecurity())
          .build();
    }

    @Test
    void givenAuthorityBasedSecurityJavaConfig_whenCallTheResourceWithAdminAuthority_thenOkResponseCodeExpected() throws Exception {
        MockHttpServletRequestBuilder with = MockMvcRequestBuilders.get("/test-resource")
          .header("Authorization", basicAuthHeader("admin", "password"));

        ResultActions performed = mockMvc.perform(with);

        MvcResult mvcResult = performed.andReturn();
        assertEquals(200, mvcResult.getResponse().getStatus());
    }
}

测试通过,可以使用同一个用户访问基于权限保护的测试资源。

3.2、基于注解的配置

要使用基于注解的方法,首先需要启用 Method Security

使用 @EnableMethodSecurity 注解创建一个 Security 配置:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true)
public class MethodSecurityJavaConfig {
}

然后,在 Controller 中再添加一个端点:

@RestController
public class TestSecuredController {

    @PreAuthorize("hasAuthority('ADMINISTRATION')")
    @GetMapping("/test-resource-method-security-with-authorities-resource")
    public ResponseEntity<String> testAdminAuthority() {
        return ResponseEntity.ok("GET request successful");
    }
}

如上,通过带有 hasAuthority 属性的 @PreAuthorize 注解,指定了我们期望的权限。准备就绪后,就可以调用端点了:

@WebMvcTest(controllers = TestSecuredController.class)
@ContextConfiguration(classes = { MethodSecurityJavaConfig.class, UserDetailsConfig.class,
        TestSecuredController.class })
public class AuthorityBasedMethodSecurityIntegrationTest {

    @Autowired
    private WebApplicationContext wac;

    private MockMvc mockMvc;

    @BeforeEach
    void setup() {
        mockMvc =  MockMvcBuilders
          .webAppContextSetup(wac)
          .apply(SecurityMockMvcConfigurers.springSecurity())
          .build();
    }

    @Test
    void givenMethodSecurityJavaConfig_whenCallTheResourceWithAdminAuthority_thenOkResponseCodeExpected() throws Exception {
        MockHttpServletRequestBuilder with = MockMvcRequestBuilders
          .get("/test-resource-method-security-with-authorities-resource")
          .header("Authorization", basicAuthHeader("admin", "password"));

        ResultActions performed = mockMvc.perform(with);

        MvcResult mvcResult = performed.andReturn();
        assertEquals(200, mvcResult.getResponse().getStatus());
    }
}

测试如上,我们在测试上下文中添加了 MethodSecurityJavaConfig 和相同的 UserDetailsConfig。然后,调用了 test-resource-method-security-with-authorities-resource 端点,并成功地访问了它。

4、使用自定义 AuthorizationManager

如果需要使用不带 ROLE_ 前缀的角色,就必须在 SecurityFilterChain 配置中添加一个自定义的 AuthorizationManager,这个自定义管理器没有硬编码的前缀。

创建实现:

public class CustomAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
    private final Set<String> roles = new HashSet<>();

    public CustomAuthorizationManager withRole(String role) {
        roles.add(role);
        return this;
    }

    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication,
                                       RequestAuthorizationContext object) {

        for (GrantedAuthority grantedRole : authentication.get().getAuthorities()) {
            if (roles.contains(grantedRole.getAuthority())) {
                return new AuthorizationDecision(true);
            }
        }

        return new AuthorizationDecision(false);
    }
}

AuthorizationManager 接口实现如上。在我们的实现中,可以指定多个角色。在 check() 方法中,验证 Authentication 中的权限是否在我们期望的角色集合中。

现在,将自定义 AuthorizationManager 添加到 SecurityFilterChain 上:

@Configuration
@EnableWebSecurity
public class CustomAuthorizationManagerSecurityJavaConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests (authorizeRequests -> {
                hasRole(authorizeRequests.requestMatchers("/test-resource"), "ADMIN");
            })
            .httpBasic(withDefaults());


        return http.build();
    }

    private void hasRole(AuthorizeHttpRequestsConfigurer.AuthorizedUrl authorizedUrl, String role) {
        authorizedUrl.access(new CustomAuthorizationManager().withRole(role));
    }
}

这里,我们使用了 AuthorizeHttpRequestsConfigurer.access() 方法,而不是 AuthorityAuthorizationManager.hasRole() 方法,它允许我们使用自定义的 AuthorizationManager 实现。

最后,配置测试上下文并调用端点:

@WebMvcTest(controllers = TestSecuredController.class)
@ContextConfiguration(classes = { CustomAuthorizationManagerSecurityJavaConfig.class,
        TestSecuredController.class, UserDetailsConfig.class })
public class RemovingRolePrefixIntegrationTest {

    @Autowired
    WebApplicationContext wac;

    private MockMvc mockMvc;

    @BeforeEach
    void setup() {
        mockMvc = MockMvcBuilders
          .webAppContextSetup(wac)
          .apply(SecurityMockMvcConfigurers.springSecurity())
          .build();
    }

    @Test
    public void givenCustomAuthorizationManagerSecurityJavaConfig_whenCallTheResourceWithAdminRole_thenOkResponseCodeExpected() throws Exception {
        MockHttpServletRequestBuilder with = MockMvcRequestBuilders.get("/test-resource")
          .header("Authorization", basicAuthHeader("admin", "password"));

        ResultActions performed = mockMvc.perform(with);

        MvcResult mvcResult = performed.andReturn();
        assertEquals(200, mvcResult.getResponse().getStatus());
    }
}

我们添加了 CustomAuthorizationManagerSecurityJavaConfig 并调用了 test-resource 端点。不出所料,我们收到了 200 响应状态码。

5、自定义 GrantedAuthorityDefaults 覆写角色前缀

在基于注解的方法中,我们可以重写角色的前缀。

修改 MethodSecurityJavaConfig

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true)
public class MethodSecurityJavaConfig {
    @Bean
    GrantedAuthorityDefaults grantedAuthorityDefaults() {
        return new GrantedAuthorityDefaults("");
    }
}

我们添加了 GrantedAuthorityDefaults Bean,并将空字符串作为构造函数参数传递。这个空字符串将用作默认角色前缀。

创建一个新的端点用于测试:

@RestController
public class TestSecuredController {

    @RolesAllowed({"ADMIN"})
    @GetMapping("/test-resource-method-security-resource")
    public ResponseEntity<String> testAdminRole() {
        return ResponseEntity.ok("GET request successful");
    }
}

我们在该端点中添加了 @RolesAllowed({"ADMIN"}),因此只有具有 ADMIN 角色的用户才能访问该端点。

测试,调用它:

@WebMvcTest(controllers = TestSecuredController.class)
@ContextConfiguration(classes = { MethodSecurityJavaConfig.class, UserDetailsConfig.class,
        TestSecuredController.class })
public class RemovingRolePrefixMethodSecurityIntegrationTest {

    @Autowired
    WebApplicationContext wac;

    private MockMvc mockMvc;

    @BeforeEach
    void setup() {
        mockMvc = MockMvcBuilders
          .webAppContextSetup(wac)
          .apply(SecurityMockMvcConfigurers.springSecurity())
          .build();
    }

    @Test
    public void givenMethodSecurityJavaConfig_whenCallTheResourceWithAdminRole_thenOkResponseCodeExpected() throws Exception {
        MockHttpServletRequestBuilder with = MockMvcRequestBuilders.get("/test-resource-method-security-resource")
          .header("Authorization", basicAuthHeader("admin", "password"));

        ResultActions performed = mockMvc.perform(with);

        MvcResult mvcResult = performed.andReturn();
        assertEquals(200, mvcResult.getResponse().getStatus());
    }
}

测试通过,我们成功获取 test-resource-method-security-resource 返回的 200 响应状态码,该用户具有 ADMIN 角色,且不带任何前缀。

6、总结

本文介绍了几种方法来解决 Spring Security 中角色 ROLE_ 前缀的问题,这些方法可以帮助我们避免在用户详细信息中为角色添加前缀。


Ref:https://www.baeldung.com/spring-security-remove-role_prefix