Spring Security - Test
spring security test是spring security的最后一部分了。其实从spring test和springboot test,能发现掌握test能极大加深对框架本身的理解。同理,通过spring security test,能对spring security的理解上升一个档次。
依赖
springboot test似乎没有spring security test相关的starter包,所以要直接加入spring-security-test:
1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
本质
spring security test支持直接通过注入权限的方式,为后续鉴权趟平道路。
注入用户也是为了获取它的权限,所以本质还是为了注入权限。
在Spring Security - Authentication中介绍过,spring security主要的两大块内容是:
- 认证;
- 鉴权;
而认证的本质就是往SecurityContextHolder放一个authentication。spring security test深刻地揭示了这一点。
注入权限
spring security基于spring test,设置了一个listener:WithSecurityContextTestExecutionListener。这个插件会读取注解里的用户权限信息,直接写入SecurityContextHolder。
ensures that our tests are run with the correct user. It does this by populating the
SecurityContextHolderprior to running our tests.
@WithMockUser
使用@WithMockUser可以直接往SecurityContextHolder填充一个UsernamePasswordAuthenticationToken类型的Authentication。
The User will have the username of “user”, the password “password”, and a single GrantedAuthority named “ROLE_USER” is used.
这个user的名字不重要,甚至它都不需要真实存在,我们要的仅仅是它的权限。比如:
ROLE_USER和foobar权限:@WithMockUser(authorities = {"ROLE_USER", "foobar"})- 或者通过role设置
ROLE_USER和ROLE_foobar权限:@WithMockUser(roles = {"USER", "foobar"})
可以标注在方法上,也可以标注在类上。
如果用的比较多,甚至可以搞个alias注解,需要的时候直接标注@AdminAuthorityUser:
1
2
3
4
5
6
7
8
9
/**
* 拥有admin权限的用户
*
* @author liuhaibo on 2022/12/28
*/
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(authorities = {"admin", "foobar"})
public @interface AdminAuthorityUser {
}
@WithUserDetails
如果系统里的authentication的principal需要是某个特定类型,此时@WithMockUser就mock不了了。一般情况下,系统实现特殊的principal类型的时候,使用特定的UserDetailsService创建user,返回user。此时的user一般同时实现UserDetails和自定义的类型。
The custom principal is often returned by a custom
UserDetailsServicethat returns an object that implements bothUserDetailsand the custom type.
UserDetailsService的作用是根据username找到user,此时的user是真实存在(database、ldap、in-memory等)的user。
@WithUserDetails支持指定自定义UserDetailsService bean,查询user,获取UserDetails:
@WithUserDetails(value="customUsername", userDetailsServiceBeanName="myUserDetailsService")
所以这里的user必须是真实存在的数据,不然会查找不到UserDetails。@WithMockUser则不需要user真实存在。
@WithSecurityContext
更进一步,如果需要更细粒度的控制,可以使用@WithSecurityContext直接mock SecurityContext。
示例
1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/")
public class FrontPageController {
@GetMapping("/haha")
@PreAuthorize("hasAnyAuthority('ROLE_USER')")
public String hahaGreeting() {
SecurityContext context = SecurityContextHolder.getContext();
return "haha, " + context.getAuthentication();
}
}
为了让基于方法的@PreAuthorize生效,必须使用@EnableMethodSecurity开启它,否则写了也没用。
基于url的
测试代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@WebMvcTest(controllers = FrontPageController.class)
@Import(MultipleSecurityFilterChainConfig.class)
public class FrontPageControllerTest {
@Autowired
private MockMvc mockMvc;
private static final String url = "/wtf/haha";
@Test
public void noAuthority() throws Exception {
MockHttpServletResponse response = mockMvc.perform(
MockMvcRequestBuilders.get(url).contextPath("/wtf")
).andReturn().getResponse();
Assertions.assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value());
}
@WithMockUser(authorities = {"ROLE_USER", "foobar"})
@Test
public void withAuthority() throws Exception {
MockHttpServletResponse response = mockMvc.perform(
MockMvcRequestBuilders.get(url).contextPath("/wtf")
).andReturn().getResponse();
Assertions.assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
Assertions.assertThat(response.getContentAsString()).contains("ROLE_USER");
}
@WithMockUser(roles = {"USER", "foobar"})
@Test
public void withRole() throws Exception {
MockHttpServletResponse response = mockMvc.perform(
MockMvcRequestBuilders.get(url).contextPath("/wtf")
).andReturn().getResponse();
Assertions.assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
Assertions.assertThat(response.getContentAsString()).contains("ROLE_USER");
}
@WithMockUser(authorities = {"foobar"})
@Test
public void wrongAuthority() throws Exception {
MockHttpServletResponse response = mockMvc.perform(
MockMvcRequestBuilders.get(url).contextPath("/wtf")
).andReturn().getResponse();
Assertions.assertThat(response.getStatus()).isEqualTo(HttpStatus.FORBIDDEN.value());
}
}
有几点需要注意:
MockMvc需要手动设置context path,所以即使代码修改了默认context path,测试的时候最好也不要用context path。它不读取properties里的context path设置,无论是@WebMvcTest还是@SpringBootTest+@AutoConfigureMockMvc;@WebMvcTest不会加载自定义的spring securityMultipleSecurityFilterChainConfig配置,但是它里面写了@EnableMethodSecurity。所以为了让@PreAuthorize生效,需要把它import进来:@Import(MultipleSecurityFilterChainConfig.class);
更多样例可以参考:
- https://www.baeldung.com/spring-security-integration-tests
感想
测试框架往往是框架本身的精简,去其乱七八糟的功能,返璞归真。果然,看了spring security test,更懂spring security实际是干嘛的了。