删除 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