WebFlux 与 Spring Security 整合——响应式鉴权的完整实现方案
WebFlux 与 Spring Security 整合——响应式鉴权的完整实现方案
适读人群:WebFlux 项目中需要接入认证鉴权的工程师 | 阅读时长:约15分钟 | 核心价值:响应式 Security 配置、JWT 整合、SecurityContext 传递,一篇搞定
去年帮一个朋友的团队排查过一个奇怪的问题:他们的 WebFlux 服务接入了 Spring Security,但偶发性地会出现 SecurityContext 丢失——明明登录了,某些接口会随机返回401。
他们排查了好几天,以为是 JWT 解析的 bug,把 JWT 部分翻来覆去检查了一遍,没发现问题。最后是我看了一眼代码,发现问题出在他们把 MVC 版的 SecurityContextHolder 用法搬到了 WebFlux 里,这在 WebFlux 里根本行不通。
这个错误我见过好几次了,所以这篇文章从这里讲起。
一、WebFlux Security 和 MVC Security 的核心差异
Spring MVC 用 SecurityContextHolder(基于 ThreadLocal)存储认证信息:
// MVC 里这样取当前用户,线程级别的上下文
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
UserDetails user = (UserDetails) auth.getPrincipal();WebFlux 里线程是共享的(event loop),不能用 ThreadLocal,所以换成了响应式的 ReactiveSecurityContextHolder,底层是 Reactor Context:
// WebFlux 里取当前用户
ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(auth -> (UserDetails) auth.getPrincipal())
.subscribe(user -> doSomething(user));
// 或者在 @Controller 方法参数里直接注入(更常用)
@GetMapping("/profile")
public Mono<ProfileVO> getProfile(@AuthenticationPrincipal UserDetails user) {
return Mono.just(ProfileVO.from(user));
}关键区别:SecurityContextHolder.getContext() 是同步方法,直接返回;ReactiveSecurityContextHolder.getContext() 返回 Mono<SecurityContext>,需要在响应式链里处理。
二、依赖配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<!-- JWT 解析,用 jjwt -->
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>Spring Boot 会自动检测到 WebFlux,Security 会使用响应式版本,不需要额外配置。
三、Security 配置
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthFilter;
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
// 禁用不需要的功能(响应式项目通常是 API,不需要表单登录和 CSRF)
.csrf(csrf -> csrf.disable())
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
// 配置访问规则
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/auth/**", "/public/**", "/actuator/health").permitAll()
.pathMatchers("/admin/**").hasRole("ADMIN")
.pathMatchers(HttpMethod.GET, "/api/**").hasAnyRole("USER", "ADMIN")
.anyExchange().authenticated()
)
// 添加 JWT 过滤器,放在 SecurityWebFiltersOrder.AUTHENTICATION 之前
.addFilterBefore(jwtAuthFilter, SecurityWebFiltersOrder.AUTHENTICATION)
// 未认证的处理(不跳转登录页,返回 401 JSON)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler(new HttpStatusServerAccessDeniedHandler(HttpStatus.FORBIDDEN))
)
.build();
}
// 如果需要内存用户(测试用)
@Bean
public ReactiveUserDetailsService userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("test")
.password("123456")
.roles("USER")
.build();
return new MapReactiveUserDetailsService(user);
}
}四、JWT 工具类
@Component
public class JwtUtils {
private final SecretKey secretKey;
private final long expirationMs;
public JwtUtils(@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration-ms:3600000}") long expirationMs) {
// 确保密钥长度足够(HS256 需要 256 bit)
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expirationMs = expirationMs;
}
public String generateToken(String username, List<String> roles) {
return Jwts.builder()
.subject(username)
.claim("roles", roles)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(secretKey)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
public boolean isTokenValid(String token) {
try {
Claims claims = parseToken(token);
return !claims.getExpiration().before(new Date());
} catch (JwtException e) {
return false;
}
}
}五、JWT 认证过滤器
这是整个方案的核心:
@Component
public class JwtAuthenticationFilter implements WebFilter {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private ReactiveUserDetailsService userDetailsService;
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String token = extractToken(exchange.getRequest());
if (token == null) {
// 没有 token,不做处理,让后续的 Security 逻辑决定是否允许访问
return chain.filter(exchange);
}
if (!jwtUtils.isTokenValid(token)) {
// token 无效,返回 401
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
Claims claims = jwtUtils.parseToken(token);
String username = claims.getSubject();
// 注意:这里不能用 SecurityContextHolder(MVC 的)
// 要用 SecurityContext 注入到 Reactor Context
return userDetailsService.findByUsername(username)
.map(userDetails -> {
List<GrantedAuthority> authorities = userDetails.getAuthorities()
.stream()
.collect(Collectors.toList());
return new UsernamePasswordAuthenticationToken(
userDetails, null, authorities);
})
.flatMap(auth -> {
SecurityContext context = new SecurityContextImpl(auth);
// 关键:把 SecurityContext 写入 Reactor Context
return chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withSecurityContext(
Mono.just(context)));
})
.onErrorResume(e -> {
log.warn("JWT认证失败: {}", e.getMessage());
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
});
}
private String extractToken(ServerHttpRequest request) {
String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return null;
}
}contextWrite(ReactiveSecurityContextHolder.withSecurityContext(...)) 是关键:它把认证信息写入 Reactor Context,后续的整个响应式链都能通过 ReactiveSecurityContextHolder.getContext() 取到。
六、登录接口
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private ReactiveUserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtUtils jwtUtils;
@PostMapping("/login")
public Mono<ResponseEntity<LoginResponse>> login(@RequestBody LoginRequest request) {
return userDetailsService.findByUsername(request.getUsername())
.filter(userDetails ->
passwordEncoder.matches(request.getPassword(), userDetails.getPassword()))
.map(userDetails -> {
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
String token = jwtUtils.generateToken(userDetails.getUsername(), roles);
return ResponseEntity.ok(new LoginResponse(token, roles));
})
.switchIfEmpty(Mono.just(ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.<LoginResponse>build()));
}
}七、在 Service 层获取当前用户
@Service
public class UserProfileService {
// 方式1:通过 ReactiveSecurityContextHolder(显式)
public Mono<ProfileVO> getCurrentUserProfile() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(auth -> (UserDetails) auth.getPrincipal())
.flatMap(userDetails -> userRepository.findByUsername(userDetails.getUsername()))
.map(ProfileVO::from);
}
// 方式2:Controller 层传进来(更清晰,推荐)
public Mono<ProfileVO> getProfile(String username) {
return userRepository.findByUsername(username)
.map(ProfileVO::from);
}
}
// Controller 层
@GetMapping("/profile")
public Mono<ProfileVO> getProfile(@AuthenticationPrincipal UserDetails userDetails) {
return userProfileService.getProfile(userDetails.getUsername());
}我个人更喜欢方式2:在 Controller 层通过 @AuthenticationPrincipal 拿到用户,然后作为参数传给 Service,这样 Service 层的依赖更清晰,测试也更容易。
八、方法级权限控制
@EnableReactiveMethodSecurity // 开启方法级安全
@Configuration
public class MethodSecurityConfig {
}@Service
public class AdminService {
@PreAuthorize("hasRole('ADMIN')") // 只有 ADMIN 才能调用
public Mono<List<User>> getAllUsers() {
return userRepository.findAll().collectList();
}
@PreAuthorize("#userId == authentication.principal.username or hasRole('ADMIN')")
public Mono<UserVO> getUserById(String userId) {
return userRepository.findByUsername(userId).map(UserVO::from);
}
}整个方案下来,代码量不少,但结构很清晰。对我来说,从 MVC Security 迁到 WebFlux Security 最大的心智负担,就是接受"SecurityContext 不再是线程级别的,而是响应式流级别的"这个转变。理解了这一点,其他都是水到渠成。
下一篇写 WebFlux 的测试——WebTestClient 和 StepVerifier,这两个工具用好了,测试响应式代码一点都不难。
