乐途乐途
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
  • 学习路径
  • 第1章 Spring Security 基础

    • 本章定位
    • Spring Security 是什么
    • DelegatingFilterProxy
    • 安全过滤器链
    • SecurityFilterChain
    • 过滤器执行顺序
  • 第2章 认证

    • 本章定位
    • Authentication
    • 认证流程
    • AuthenticationManager
    • ProviderManager
    • DaoAuthenticationProvider
    • UserDetails
    • UserDetailsService
    • PasswordEncoder
    • BCryptPasswordEncoder
    • DelegatingPasswordEncoder
    • 表单登录
    • SecurityContext
    • SecurityContextHolder
    • UsernamePasswordAuthenticationToken
  • 第3章 授权

    • 本章定位
    • 授权模型
    • GrantedAuthority
    • AccessDecisionManager
    • AccessDecisionVoter
    • URL 级别授权
    • 方法级别安全
    • @PreAuthorize
    • @PostAuthorize
    • @PreFilter
    • @PostFilter
    • @Secured
    • RoleHierarchy
  • 第4章 过滤器链

    • 本章定位
    • FilterChainProxy
    • SecurityContextHolderFilter
    • LogoutFilter
    • BasicAuthenticationFilter
    • CsrfFilter
    • CorsFilter
    • HeaderWriterFilter
    • AnonymousAuthenticationFilter
    • RequestCacheAwareFilter
    • ExceptionTranslationFilter
    • FilterSecurityInterceptor
  • 第5章 会话管理

    • 本章定位
    • 会话管理
    • SessionFixation
    • 会话并发控制
    • SessionCreationPolicy
    • RememberMe
  • 第6章 JWT

    • 本章定位
    • JWT
    • JwtDecoder
    • JWT 认证
    • JwtAuthenticationConverter
  • 第7章 OAuth2

    • 本章定位
    • OAuth2 基础
    • OAuth2 Client
    • OAuth2 Resource Server
    • 第三方登录配置
  • 第8章 攻击防护

    • 本章定位
    • CSRF 跨站请求伪造防护
    • CORS 跨域防护
    • Clickjacking 点击劫持防护
    • 安全响应头
    • Session Fixation 会话固定防护
  • 第9章 测试

    • 本章定位
    • 安全测试
    • @WithMockUser
    • 最佳实践

安全测试

定义与作用

Spring Security 测试支持是一套专门为安全过滤器链设计的集成测试框架。它允许开发者在不启动完整嵌入式 Servlet 容器的情况下,验证 SecurityFilterChain 中各过滤器的协作行为——包括认证拦截、授权决策、CSRF 校验、Session 管理及安全响应头的写入。核心注解 @AutoConfigureMockMvc 负责将 Spring Security 的完整过滤器链注入到 MockMvc 中,使得测试请求能够像真实请求一样依次经过 DelegatingFilterProxy → FilterChainProxy → 各安全过滤器,最终到达目标控制器。

该测试框架的价值在于:安全规则属于架构约束,必须用自动化测试锁定。一旦 HttpSecurity 配置发生变更,对应的测试必须立即暴露回归缺陷,而非等到生产环境被渗透测试发现。


核心原理

MockMvc 本身是一个基于 DispatcherServlet 的轻量级测试门面,默认只包含 Spring MVC 的过滤器。当测试类上标注 @AutoConfigureMockMvc 时,Spring Boot 测试自动配置会检索容器中名为 springSecurityFilterChain 的 FilterChainProxy Bean,并将其作为首个 FilterRegistrationBean 注册到 MockMvc 的过滤器链中。此后,所有通过 mockMvc.perform() 发起的模拟请求都会按以下顺序执行:

  1. MockMvc 构建模拟的 HttpServletRequest 与 HttpServletResponse;
  2. DelegatingFilterProxy 将请求委托给 FilterChainProxy;
  3. FilterChainProxy 遍历所有 SecurityFilterChain,匹配第一个适用的链;
  4. 链内过滤器按固定顺序执行:SecurityContextPersistenceFilter → CsrfFilter → UsernamePasswordAuthenticationFilter → ExceptionTranslationFilter → FilterSecurityInterceptor 等;
  5. 若认证与授权均通过,请求进入 DispatcherServlet 并到达 Controller;
  6. 响应反向经过各过滤器的后置处理,最终返回 ResultActions 供断言。

由于过滤器链完整参与,测试中可以断言 HTTP 状态码、重定向地址、响应头、SecurityContext 中的 Authentication 对象,甚至验证 CsrfFilter 是否因 Token 缺失而拒绝 POST 请求。


测试流程图


示例一:未认证请求被拦截与登录后放行

场景说明

应用存在 /admin/dashboard 页面,要求用户必须已认证且拥有 ADMIN 角色。测试目标是:验证未认证用户被重定向到登录页,并验证登录后带正确角色的用户可以访问。

操作前配置

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(auth -> auth
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/admin/dashboard")
                .permitAll()
            )
            .csrf().disable(); // 为简化测试示例先禁用,生产环境不应如此
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("admin")
            .password("{noop}admin123")
            .roles("ADMIN");
    }
}

操作后配置(测试类)

@SpringBootTest
@AutoConfigureMockMvc
public class AdminDashboardSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void 未认证用户访问管理后台应被重定向到登录页() throws Exception {
        mockMvc.perform(get("/admin/dashboard"))
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrlPattern("**/login"));
    }

    @Test
    public void 已认证且拥有ADMIN角色的用户应能访问管理后台() throws Exception {
        mockMvc.perform(get("/admin/dashboard")
                .with(user("admin").roles("ADMIN")))
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("Dashboard")));
    }
}

结果分析

  • 第一个测试执行时,MockMvc 发送的 GET 请求到达 FilterSecurityInterceptor,由于 SecurityContextHolder 中不存在 Authentication,ExceptionTranslationFilter 捕获 AccessDeniedException 并调用 AuthenticationEntryPoint,默认行为为重定向到 /login,断言 is3xxRedirection() 通过。
  • 第二个测试通过 MockMvcRequestPostProcessors.user() 构建了一个已认证的 UsernamePasswordAuthenticationToken 并注入 SecurityContext。FilterSecurityInterceptor 检查 Authentication 中的 ROLE_ADMIN 后放行,Controller 正常返回 200。

示例二:POST 请求 CSRF 校验缺失导致 403

场景说明

管理后台提供用户删除接口 POST /admin/users/delete,要求提交表单时必须携带有效的 CSRF Token。测试目标是验证:未携带 CSRF Token 的请求被拒绝,携带正确 Token 的请求成功处理。

操作前配置

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(auth -> auth
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .permitAll()
            )
            .csrf(); // 显式启用 CSRF 防护(默认即为开启)
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("admin")
            .password("{noop}admin123")
            .roles("ADMIN");
    }
}

操作后配置(测试类)

@SpringBootTest
@AutoConfigureMockMvc
public class AdminDeleteUserSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void 未携带CSRF_TOKEN的POST请求应返回403() throws Exception {
        mockMvc.perform(post("/admin/users/delete")
                .param("userId", "1001")
                .with(user("admin").roles("ADMIN")))
            .andExpect(status().isForbidden());
    }

    @Test
    public void 携带CSRF_TOKEN的POST请求应成功处理() throws Exception {
        mockMvc.perform(post("/admin/users/delete")
                .param("userId", "1001")
                .with(user("admin").roles("ADMIN"))
                .with(csrf())) // 通过 csrf() 处理器自动注入有效 Token
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("删除成功")));
    }
}

结果分析

  • CsrfFilter 默认拦截所有 "state-changing" 请求(POST / PUT / DELETE / PATCH)。第一个测试未调用 .with(csrf()),请求中既无 _csrf 参数也无 X-CSRF-TOKEN 请求头,CsrfFilter 比对失败,直接返回 403 Forbidden。
  • 第二个测试使用 MockMvcRequestPostProcessors.csrf() 处理器,该处理器从 HttpSessionCsrfTokenRepository 中获取或生成 Token,并将其同时写入 Session 和请求参数。CsrfFilter 比对通过,请求继续向下到达 FilterSecurityInterceptor,授权通过后进入 Controller 返回 200。
  • 关键结论:在 Spring Security 5.x 中,csrf() 默认开启,任何 POST 测试若未显式处理 CSRF,都会收到 403。这是 MockMvc 安全测试中最常见的失败原因。

易错场景:@AutoConfigureMockMvc 遗漏导致安全过滤器未生效

现象

开发者编写了如下测试,但断言失败——未认证用户居然返回了 200 而不是 302:

@SpringBootTest
public class WrongSecurityTest { // 缺少 @AutoConfigureMockMvc

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void 未认证访问受保护资源应被重定向() throws Exception {
        mockMvc.perform(get("/admin/dashboard"))
            .andExpect(status().is3xxRedirection()); // 实际返回 200,断言失败
    }
}

根因分析

@AutoConfigureMockMvc 的作用不仅仅是创建 MockMvc 实例,更重要的是将 Spring Security 的 FilterChainProxy 注册到 MockMvc 的过滤器链中。缺少该注解时,MockMvc 内部只有 Spring MVC 的默认过滤器(如 CharacterEncodingFilter),springSecurityFilterChain 根本不会参与请求处理。请求未经 FilterSecurityInterceptor 拦截,直接到达 Controller,因此返回 200。

正确做法

测试类必须显式标注 @AutoConfigureMockMvc。如果开发者需要进一步排除某些自动配置(例如排除 OAuth2 客户端配置),应使用 excludeAutoConfiguration 属性,但绝不能省略该注解本身。

@SpringBootTest
@AutoConfigureMockMvc
public class CorrectSecurityTest {
    // 安全过滤器链正常注入,测试断言通过
}

面试考点

Q:MockMvc 安全测试与真实启动 Servlet 容器的测试相比,有何局限?

MockMvc 测试基于 DispatcherServlet 的轻量级模拟,不经过真正的嵌入式 Tomcat/Jetty。因此它无法验证:① 容器层自定义 Filter(如 web.xml 中手动注册的 Filter)的交互;② HTTPS 强制跳转(HttpSecurity.requiresChannel() 在 MockMvc 中无法真正建立 TLS 连接);③ 分布式 Session(如 Spring Session Redis)的跨实例共享。对于纯 Spring Security 过滤器链逻辑的验证,MockMvc 是最高效的方式;对于容器层或网络层的验证,仍需使用 @SpringBootTest(webEnvironment = RANDOM_PORT) 配合 TestRestTemplate 或 WebTestClient。

Q:为什么测试中的 user() 处理器与 @WithMockUser 注解都能模拟用户,两者区别是什么?

MockMvcRequestPostProcessors.user() 是请求级别的处理器,仅作用于当前 mockMvc.perform() 请求的 SecurityContext,粒度细到单个 HTTP 调用;@WithMockUser 是测试方法级别的注解,由 WithSecurityContextTestExecutionListener 在测试方法执行前将 Authentication 设置到 SecurityContextHolder,在整个测试方法期间都有效(包括被测方法内部可能触发的异步任务或子线程,但子线程继承需额外配置)。在 @WebMvcTest 切片测试中,推荐 user() 处理器;在需要方法级别统一身份的集成测试中,推荐 @WithMockUser。


版本说明:本教程基于 Spring Security 5.x(对应 Spring Boot 2.x)。WebSecurityConfigurerAdapter 为 5.x 的经典配置基类,测试注解与 MockMvc 安全处理器的 API 在 5.x 与 6.x 中保持兼容。

上一页
本章定位
下一页
@WithMockUser