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

    • 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
    • 最佳实践

WithMockUser

定义与作用

@WithMockUser、@WithUserDetails 与 @WithAnonymousUser 是 Spring Security 测试模块提供的元注解,用于在单元测试和集成测试中以声明式方式快速填充 SecurityContextHolder。它们解决了安全测试中的核心痛点:如何在测试执行前将特定身份注入安全上下文,从而避免测试代码与真实认证流程(数据库查询、密码比对、Session 创建)耦合。

注解作用底层机制
@WithMockUser直接构造一个内存中的虚拟用户,注入到 SecurityContext由 WithMockUserSecurityContextFactory 创建 UsernamePasswordAuthenticationToken
@WithUserDetails通过指定的 UserDetailsService 按用户名加载真实用户由 WithUserDetailsSecurityContextFactory 调用 loadUserByUsername()
@WithAnonymousUser显式将当前上下文设置为匿名用户由 WithAnonymousUserSecurityContextFactory 创建 AnonymousAuthenticationToken

这些注解均通过 TestExecutionListener 机制在测试方法执行前完成上下文注入,测试方法结束后自动清理,确保测试之间不会相互污染。


核心原理

Spring Security 测试模块注册了一个核心监听器 WithSecurityContextTestExecutionListener。当测试框架(如 JUnit / Jupiter)准备调用某个测试方法时,该监听器会扫描方法上的 @WithMockUser、@WithUserDetails、@WithAnonymousUser 等注解,并调用对应的 SecurityContextFactory 在测试方法执行前将 Authentication 对象写入 SecurityContextHolder.getContext()。

关键细节:

  • @WithMockUser 构造的 Authentication 的 principal 是 String(用户名),而非 UserDetails 对象。因此若被测代码中通过 @AuthenticationPrincipal UserDetails 注入,会收到 ClassCastException。
  • @WithUserDetails 的 principal 是 UserDetailsService 返回的完整 UserDetails 对象,适合需要访问用户扩展属性(如邮箱、部门 ID)的测试场景。
  • @WithAnonymousUser 等价于未登录状态,但 SecurityContextHolder 中不是 null,而是包含 ROLE_ANONYMOUS 的 AnonymousAuthenticationToken,这与生产环境中 AnonymousAuthenticationFilter 的行为完全一致。

示例一:@WithMockUser 测试方法级别安全

场景说明

服务层提供文章管理功能,其中 deleteArticle 方法要求调用者必须拥有 ROLE_ADMIN 或 ROLE_EDITOR 角色。测试目标是验证不同角色下的方法访问行为。

操作前配置

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
}
@Service
public class ArticleService {

    @PreAuthorize("hasAnyRole('ADMIN', 'EDITOR')")
    public void deleteArticle(Long articleId) {
        // 执行删除逻辑
    }

    @PreAuthorize("hasRole('ADMIN')")
    public List<Article> listAllArticles() {
        return List.of(new Article(1L, "Spring Security 测试"));
    }
}

操作后配置(测试类)

@SpringBootTest
public class ArticleServiceSecurityTest {

    @Autowired
    private ArticleService articleService;

    @Test
    @WithMockUser(roles = "ADMIN")
    public void ADMIN角色应能删除文章() {
        // 无异常抛出即为通过
        articleService.deleteArticle(1001L);
    }

    @Test
    @WithMockUser(roles = "EDITOR")
    public void EDITOR角色应能删除文章() {
        articleService.deleteArticle(1001L);
    }

    @Test
    @WithMockUser(roles = "USER")
    public void USER角色尝试删除文章应抛出AccessDeniedException() {
        assertThrows(AccessDeniedException.class, () -> {
            articleService.deleteArticle(1001L);
        });
    }

    @Test
    @WithMockUser(username = "alice", roles = "ADMIN")
    public void ADMIN角色应能查看所有文章() {
        List<Article> articles = articleService.listAllArticles();
        assertFalse(articles.isEmpty());
    }
}

结果分析

  • @WithMockUser(roles = "ADMIN") 被 WithMockUserSecurityContextFactory 解析后,创建 UsernamePasswordAuthenticationToken,其 authorities 集合中包含 SimpleGrantedAuthority("ROLE_ADMIN")。
  • MethodSecurityInterceptor 在 deleteArticle 方法执行前解析 @PreAuthorize 中的 SpEL 表达式 hasAnyRole('ADMIN', 'EDITOR'),调用 RoleHierarchy 和 RoleVoter 进行投票。ROLE_ADMIN 满足条件,方法正常执行。
  • 第三个测试中 roles = "USER" 只生成 ROLE_USER,RoleVoter 投票 ACCESS_DENIED,AccessDecisionManager 汇总后抛出 AccessDeniedException,断言捕获成功。
  • @WithMockUser 的属性 username 默认值为 user,password 默认值为 password。当测试需要区分不同用户身份时(如 @PreAuthorize("#userId == authentication.principal.id")),应显式设置 username 属性。

示例二:@WithUserDetails 配合自定义 UserDetailsService

场景说明

业务系统中 UserDetails 包含扩展属性(如 departmentId、 email),且服务层通过 @AuthenticationPrincipal 注入自定义的 CustomUserDetails 对象进行业务逻辑判断。@WithMockUser 无法满足此场景,因为 principal 是字符串而非自定义对象,必须通过 @WithUserDetails 让 UserDetailsService 加载真实实体。

操作前配置

public class CustomUserDetails implements UserDetails {
    private String username;
    private String password;
    private Collection<? extends GrantedAuthority> authorities;
    private Long departmentId; // 扩展属性
    private boolean accountNonExpired;
    private boolean accountNonLocked;
    private boolean credentialsNonExpired;
    private boolean enabled;

    // 省略 getter / setter / 实现方法
    public Long getDepartmentId() { return departmentId; }
}
@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 模拟从数据库加载
        CustomUserDetails user = new CustomUserDetails();
        user.setUsername(username);
        user.setPassword("{bcrypt}$2a$10$...");
        user.setDepartmentId("admin".equals(username) ? 1L : 2L);
        user.setAuthorities(List.of(new SimpleGrantedAuthority("ROLE_ADMIN")));
        return user;
    }
}
@Service
public class ReportService {

    @PreAuthorize("hasRole('ADMIN')")
    public Report generateReport(@AuthenticationPrincipal CustomUserDetails user) {
        if (user.getDepartmentId() == null) {
            throw new IllegalStateException("无法获取部门信息");
        }
        return new Report(user.getDepartmentId(), "季度财务报表");
    }
}

操作后配置(测试类)

@SpringBootTest
public class ReportServiceWithUserDetailsTest {

    @Autowired
    private ReportService reportService;

    @Test
    @WithUserDetails(value = "admin", userDetailsServiceBeanName = "customUserDetailsService")
    public void 通过UserDetailsService加载的ADMIN用户应能生成报表() {
        Report report = reportService.generateReport(null); // 参数由 @AuthenticationPrincipal 注入
        assertNotNull(report);
        assertEquals(1L, report.getDepartmentId());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    public void WithMockUser生成的principal是字符串导致类型转换失败() {
        // 实际执行会抛出 ClassCastException:
        // java.lang.String cannot be cast to com.example.CustomUserDetails
        assertThrows(ClassCastException.class, () -> {
            reportService.generateReport(null);
        });
    }
}

结果分析

  • @WithUserDetails 的 userDetailsServiceBeanName 属性显式指定了容器中 CustomUserDetailsService 的 Bean 名称。WithUserDetailsSecurityContextFactory 调用 loadUserByUsername("admin"),获取到包含 departmentId = 1L 的 CustomUserDetails 实例,并将其包装为 UsernamePasswordAuthenticationToken 存入 SecurityContext。
  • generateReport 方法参数上的 @AuthenticationPrincipal 从 SecurityContext 中提取 principal,此时类型为 CustomUserDetails,业务逻辑正常执行,报表生成成功。
  • 对比测试展示了 @WithMockUser 的局限:其 principal 是字符串 "user",@AuthenticationPrincipal 尝试将字符串转为 CustomUserDetails 时触发 ClassCastException。这证明了当 Controller 或 Service 层依赖 UserDetails 的具体类型时,必须使用 @WithUserDetails。

易错场景:@WithMockUser 不生效的三大根因

现象

测试方法上标注了 @WithMockUser,但方法执行时 SecurityContextHolder.getContext().getAuthentication() 仍然返回 null,导致 @PreAuthorize 或 URL 授权检查行为异常。

根因一:未引入 spring-security-test 依赖

<!-- 必须显式引入测试模块 -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

如果没有 spring-security-test,@WithMockUser 注解不会被 WithSecurityContextTestExecutionListener 识别,注解将被完全忽略,安全上下文保持为空。

根因二:使用了非 Spring 测试上下文运行器

// 错误:使用 JUnit4 的默认 Runner,未触发 Spring 监听器
@RunWith(JUnit4.class)
public class BadRunnerTest { }

// 正确:使用 SpringRunner 或 SpringExtension
@RunWith(SpringRunner.class) // JUnit4
@ExtendWith(SpringExtension.class) // JUnit5
public class CorrectRunnerTest { }

WithSecurityContextTestExecutionListener 是 Spring TestExecutionListener 链的一环,只有使用 Spring 的测试扩展(SpringRunner / SpringExtension)时才会被注册和回调。

根因三:在 @WebMvcTest 切片测试中直接注解 Service 层测试类

@WebMvcTest(controllers = MyController.class)
public class ControllerTest {
    // @WithMockUser 在这里有效,因为 Spring Security 测试监听器已注册
}

@WebMvcTest(controllers = MyController.class)
public class ServiceLayerTest {
    @Autowired
    private SomeService someService; // 错误:Service Bean 不会被加载

    @Test
    @WithMockUser
    public void testService() { } // 可能因上下文不完整导致注解不生效或 Bean 缺失
}

@WebMvcTest 只加载 Spring MVC 相关的 Bean,Service 层 Bean 不会被实例化。如果强行在该类中测试 Service,要么 Bean 注入失败,要么测试上下文不完整。应改用 @SpringBootTest 或 @ContextConfiguration 加载完整上下文。

正确做法 checklist

  1. 确认 pom.xml 包含 spring-security-test 依赖;
  2. 确认测试类使用 @SpringBootTest 或 @ContextConfiguration + SpringExtension;
  3. 确认 @WithMockUser 直接标注在测试方法或测试类上(标注在辅助方法上无效);
  4. 若需要自定义 UserDetails 类型,改用 @WithUserDetails 并确保 UserDetailsService Bean 已加载。

面试考点

Q:@WithMockUser 与 @WithUserDetails 在 principal 类型上的差异,以及何时必须选择后者?

@WithMockUser 通过 WithMockUserSecurityContextFactory 创建 UsernamePasswordAuthenticationToken,其 principal 是 String 类型(用户名),authorities 由注解的 roles / authorities 属性生成。适用于简单角色校验和 URL 级别测试。@WithUserDetails 通过 WithUserDetailsSecurityContextFactory 调用 UserDetailsService.loadUserByUsername(),将返回的 UserDetails 对象作为 principal。当 Controller 或 Service 层通过 @AuthenticationPrincipal CustomUserDetails 注入自定义用户实体、或方法安全表达式中引用 principal.departmentId 等自定义属性时,必须使用 @WithUserDetails,否则运行时会发生类型转换异常。

Q:@WithAnonymousUser 的适用场景是什么?它是否等价于不标注任何注解?

不等价。不标注任何注解时,SecurityContextHolder 中可能残留上一个测试的 Authentication(若测试监听器清理不及时或并行测试导致线程池复用),造成测试不稳定。@WithAnonymousUser 显式注入 AnonymousAuthenticationToken(principal 为 anonymousUser,权限为 ROLE_ANONYMOUS),与生产环境中 AnonymousAuthenticationFilter 的行为完全一致。适用于测试匿名用户可访问的公开接口、或验证 @PreAuthorize("permitAll()") 方法在无任何认证信息时的正确性。


版本说明:本教程基于 Spring Security 5.x(对应 Spring Boot 2.x)。@EnableGlobalMethodSecurity(prePostEnabled = true) 为 5.x 开启方法安全的经典方式,测试注解的 API 在 5.x 与 6.x 中保持兼容,但 6.x 推荐改用 @EnableMethodSecurity。

上一页
安全测试
下一页
最佳实践