安全测试专题
概述
Spring Security 测试是保证安全配置正确的关键。通过 spring-security-test 模块,可以模拟各种认证状态和权限场景。
一、依赖
xml
<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>二、注解方式模拟用户
2.1 @WithMockUser
java
@SpringBootTest
@AutoConfigureMockMvc
public class OrderControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(roles = "ADMIN")
void testAdminCanAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/admin/orders"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "user1", roles = "USER")
void testUserCanAccessOwnOrders() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void testUserCannotAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/admin/orders"))
.andExpect(status().isForbidden());
}
@Test
void testAnonymousCannotAccessProtectedEndpoint() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
}2.2 @WithAnonymousUser
java
@Test
@WithAnonymousUser
void testPublicEndpoint() throws Exception {
mockMvc.perform(get("/api/public/health"))
.andExpect(status().isOk());
}2.3 @WithUserDetails
java
@Test
@WithUserDetails("admin") // 从数据库加载真正的 UserDetails
void testWithRealUser() throws Exception {
mockMvc.perform(get("/admin/users"))
.andExpect(status().isOk());
}2.4 自定义注解
java
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "admin", roles = {"ADMIN", "USER"})
public @interface WithAdminUser {
}
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "viewer", roles = "USER")
public @interface WithViewerUser {
}
// 使用
@Test
@WithAdminUser
void testAdminPrivilege() throws Exception {
mockMvc.perform(delete("/api/orders/1"))
.andExpect(status().isOk());
}
@Test
@WithViewerUser
void testViewerCannotDelete() throws Exception {
mockMvc.perform(delete("/api/orders/1"))
.andExpect(status().isForbidden());
}三、RequestPostProcessors
3.1 编程方式模拟认证
java
@Test
void testWithRequestPostProcessor() throws Exception {
mockMvc.perform(get("/api/orders")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk());
}
@Test
void testWithUserDetailsProcessor() throws Exception {
mockMvc.perform(get("/api/orders")
.with(userDetailsService(userDetailsService)
.username("admin"))
)
.andExpect(status().isOk());
}
@Test
void testWithAuthenticationProcessor() throws Exception {
Authentication auth = new UsernamePasswordAuthenticationToken(
"admin", "password",
AuthorityUtils.createAuthorityList("ROLE_ADMIN")
);
mockMvc.perform(get("/admin/orders")
.with(authentication(auth))
)
.andExpect(status().isOk());
}3.2 CSRF 测试
java
@Test
void testPostWithoutCsrfToken() throws Exception {
// POST 请求默认需要 CSRF Token
mockMvc.perform(post("/api/orders")
.content("{\"productId\": 1, \"quantity\": 2}")
.contentType(MediaType.APPLICATION_JSON)
.with(csrf()) // 提供 CSRF Token
)
.andExpect(status().isOk());
}
@Test
void testPostWithoutCsrfTokenFails() throws Exception {
mockMvc.perform(post("/api/orders")
.content("{\"productId\": 1, \"quantity\": 2}")
.contentType(MediaType.APPLICATION_JSON)
// 不提供 CSRF Token
)
.andExpect(status().isForbidden());
}
@Test
void testPostWithInvalidCsrfToken() throws Exception {
mockMvc.perform(post("/api/orders")
.content("{\"productId\": 1, \"quantity\": 2}")
.contentType(MediaType.APPLICATION_JSON)
.with(csrf().useInvalidToken()) // 使用无效 Token
)
.andExpect(status().isForbidden());
}
@Test
void testCsrfDisabled() throws Exception {
// 假设某接口禁用了 CSRF
mockMvc.perform(post("/api/webhook")
.content("{}")
.contentType(MediaType.APPLICATION_JSON)
.with(csrf().asHeader()) // 作为请求头发送
)
.andExpect(status().isOk());
}3.3 SecurityContext 持久化
java
@Test
void testSecurityContextAcrossMultipleRequests() throws Exception {
// 设置一次 SecurityContext,多个请求共享
mockMvc.perform(get("/api/orders")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk());
// 同一个请求中 SecurityContext 自动传递
// 注意:每个测试方法是独立的
}四、OAuth2 测试
4.1 JWT 令牌
java
@Test
void testJwtToken() throws Exception {
mockMvc.perform(get("/api/orders")
.with(jwt().jwt(jwt -> jwt
.subject("user123")
.claim("scope", "read write")
))
)
.andExpect(status().isOk());
}
@Test
void testJwtWithAuthorities() throws Exception {
mockMvc.perform(get("/api/admin/orders")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN")))
)
.andExpect(status().isOk());
}
@Test
void testJwtWithMissingScope() throws Exception {
mockMvc.perform(get("/api/admin/orders")
.with(jwt().jwt(jwt -> jwt
.subject("user123")
.claim("scope", "read") // 只有 read 权限
))
)
.andExpect(status().isForbidden());
}4.2 Opaque Token
java
@Test
void testOpaqueToken() throws Exception {
mockMvc.perform(get("/api/orders")
.with(opaqueToken().attributes(attrs -> attrs
.put("sub", "user123")
.put("name", "John Doe")
))
)
.andExpect(status().isOk());
}4.3 OAuth2 登录
java
@Test
void testOAuth2Login() throws Exception {
mockMvc.perform(get("/api/userinfo")
.with(oauth2Login().attributes(attrs -> attrs
.put("sub", "github_12345")
.put("login", "john")
.put("email", "john@example.com")
))
)
.andExpect(status().isOk());
}五、方法安全测试
java
@SpringBootTest
public class OrderServiceMethodSecurityTest {
@Autowired
private OrderService orderService;
@Test
@WithMockUser(roles = "ADMIN")
void testAdminCanDeleteOrder() {
// 方法上有 @PreAuthorize("hasRole('ADMIN')")
orderService.deleteOrder(1L);
}
@Test
@WithMockUser(roles = "USER")
void testUserCannotDeleteOrder() {
assertThrows(AccessDeniedException.class, () -> {
orderService.deleteOrder(1L);
});
}
@Test
@WithMockUser(username = "owner")
void testOwnerCanAccessDocument() {
Document doc = documentService.findById(1L);
assertEquals("owner", doc.getOwner());
}
@Test
@WithMockUser(username = "other")
void testOtherCannotAccessDocument() {
assertThrows(AccessDeniedException.class, () -> {
// @PostAuthorize 验证失败
documentService.findById(1L);
});
}
}六、安全场景测试矩阵
java
@SpringBootTest
@AutoConfigureMockMvc
public class SecurityScenarioTest {
@Autowired
private MockMvc mockMvc;
// 场景 1:权限不足
@Test
@WithMockUser(roles = "USER")
void testForbidden() throws Exception {
mockMvc.perform(delete("/api/orders/1"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error").value("Access Denied"));
}
// 场景 2:认证过期
@Test
void testTokenExpired() throws Exception {
// 模拟过期的 JWT
String expiredToken = generateExpiredToken();
mockMvc.perform(get("/api/orders")
.header("Authorization", "Bearer " + expiredToken)
)
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("Token expired"));
}
// 场景 3:CSRF 异常
@Test
void testCsrfException() throws Exception {
mockMvc.perform(post("/api/orders")
.content("{}")
.contentType(MediaType.APPLICATION_JSON)
// 没有提供 CSRF Token
)
.andExpect(status().isForbidden());
}
// 场景 4:CORS 错误
@Test
void testCorsBlocked() throws Exception {
mockMvc.perform(get("/api/orders")
.header("Origin", "https://evil.com")
)
.andExpect(status().isForbidden());
}
// 场景 5:越权访问(水平越权)
@Test
@WithMockUser(username = "user1")
void testHorizontalPrivilegeEscalation() throws Exception {
// user1 尝试访问 user2 的订单
mockMvc.perform(get("/api/orders/100")) // 100 属于 user2
.andExpect(status().isForbidden());
}
// 场景 6:越权访问(垂直越权)
@Test
@WithMockUser(roles = "USER")
void testVerticalPrivilegeEscalation() throws Exception {
// USER 尝试访问 ADMIN 接口
mockMvc.perform(get("/admin/users"))
.andExpect(status().isForbidden());
}
// 场景 7:未认证访问
@Test
void testUnauthenticatedAccess() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
// 场景 8:公共接口访问
@Test
void testPublicEndpoint() throws Exception {
mockMvc.perform(get("/api/public/health"))
.andExpect(status().isOk());
}
// 场景 9:SQL 注入尝试
@Test
@WithMockUser(roles = "ADMIN")
void testSqlInjectionAttempt() throws Exception {
mockMvc.perform(get("/api/users")
.param("username", "' OR '1'='1")
)
// 安全配置应该能防御并返回正常结果
.andExpect(status().isOk());
}
// 场景 10:会话固定攻击
@Test
void testSessionFixation() throws Exception {
String originalSessionId = getCurrentSessionId();
// 登录后 Session ID 应该变化
mockMvc.perform(post("/login")
.param("username", "admin")
.param("password", "password")
)
.andExpect(status().isOk())
.andDo(result -> {
String newSessionId = getSessionId(result);
assertNotEquals(originalSessionId, newSessionId);
});
}
}七、安全配置测试
java
@SpringBootTest
@AutoConfigureMockMvc
public class SecurityConfigTest {
@Autowired
private MockMvc mockMvc;
// 测试 URL 安全配置
@Test
void testSecurityMatchers() throws Exception {
// 公共资源
mockMvc.perform(get("/public/help"))
.andExpect(status().isOk());
// 静态资源
mockMvc.perform(get("/static/css/main.css"))
.andExpect(status().isOk());
// 需认证
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
// 测试 CORS 配置
@Test
void testCorsConfiguration() throws Exception {
// 允许的域
mockMvc.perform(options("/api/orders")
.header("Origin", "https://example.com")
.header("Access-Control-Request-Method", "POST")
)
.andExpect(status().isOk())
.andExpect(header().string("Access-Control-Allow-Origin", "https://example.com"));
// 不允许的域
mockMvc.perform(options("/api/orders")
.header("Origin", "https://evil.com")
.header("Access-Control-Request-Method", "POST")
)
.andExpect(status().isForbidden());
}
// 测试安全头
@Test
void testSecurityHeaders() throws Exception {
mockMvc.perform(get("/api/orders")
.with(user("admin").roles("ADMIN"))
)
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
.andExpect(header().string("X-Frame-Options", "DENY"))
.andExpect(header().string("X-XSS-Protection", "0"));
}
}八、总结
| 知识点 | 说明 |
|---|---|
@WithMockUser | 快速模拟用户,指定 roles/username |
@WithUserDetails | 从数据库加载真实 UserDetails |
@WithAnonymousUser | 模拟匿名用户 |
RequestPostProcessor | 编程式设置认证、CSRF |
| OAuth2 测试 | jwt()、opaqueToken()、oauth2Login() |
| 安全场景 | 权限不足、认证过期、CSRF、CORS、越权 |
| @DataJpaTest + Security | 结合 @WithMockUser 测试方法安全 |
参考链接: