Spring Security + OAuth2 认证授权体系
提出问题
现代微服务架构中,认证和授权是不可绕过的基础能力。传统单体应用的 Session 登录在分布式环境下暴露出会话穿透、状态共享等问题,而 OAuth2 协议作为业界标准授权框架,被广泛应用于 SSO、第三方登录、API 鉴权等场景。Spring Security 通过与 OAuth2 + JWT 的深度集成,提供了一套从过滤器链到方法级权限的完整安全方案。面试中常问的问题包括:SecurityFilterChain 的机制、OAuth2 四种授权模式的选择、JWT 与 Session 的权衡、资源服务器与授权服务器的职责划分。理解这些,才能在实际项目中设计出既安全又灵活的身份认证体系。
分析问题
SecurityFilterChain 与过滤器链机制
Spring Security 的核心是过滤器链(Filter Chain)。每个请求会经过一串有序的 Servlet Filter,SecurityFilterChain 负责决定哪些过滤器作用于哪些请求路径。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
}关键点:可以配置多个 SecurityFilterChain,按 @Order 排序,匹配到第一个就停止。典型的做法是将公开接口、移动端接口、管理后台接口拆成不同的安全规则。过滤器链中的核心过滤器包括 AuthenticationFilter→AuthenticationManager→SecurityContextHolder,认证成功后将主体信息存入当前线程的 SecurityContext。
多链配置实战: 一个 8 年 Java 后端大概率遇到过这样的场景——公开接口不需要认证,APP 端用 JWT,管理后台用 Session。如果只配一条链,SessionCreationPolicy.STATELESS 会一刀切掉 Session,导致管理后台登录失效。正确的做法是配两条链,@Order(1) 的链处理 /api/** 走无状态 JWT,@Order(2) 的链处理 /admin/** 走 Session。Spring Security 源码里 SecurityFilterChain 的匹配逻辑在 FilterChainProxy.doFilterInternal() 中,遍历 List<SecurityFilterChain> 逐个调用 requestMatcher.matches(request),匹配到第一条就跳出。踩坑: 如果两条链的 requestMatchers 有重叠,顺序靠前的会吃掉后面的请求。
过滤器链各环节的职责和数据流向(文字时序):
请求 → SecurityContextHolderFilter → 认证过滤器(UsernamePasswordAuthenticationFilter / JwtAuthenticationFilter)
→ SecurityContextHolder 存储当前认证信息 → 授权过滤器(FilterSecurityInterceptor)
→ 权限决策器(AccessDecisionManager) → 输出 403/500/200每个过滤器在 doFilterInternal() 中调用 chain.doFilter() 让请求继续往下走。SecurityContextHolder 默认策略是 MODE_THREADLOCAL,意味着在异步场景下(@Async、CompletableFuture)身份信息会丢失。踩坑: 在异步任务里取 SecurityContextHolder.getContext().getAuthentication() 返回 null,排查半小时才发现是 ThreadLocal 的天然限制。解决方案:用 SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL) 或显式传递 SecurityContext。
OAuth2 四种授权模式
OAuth2 定义了四种授权模式,面向不同场景:
| 模式 | 适用场景 | 安全等级 | 典型 Token 有效期 |
|---|---|---|---|
| 授权码 + PKCE(Authorization Code + PKCE) | 有后端的 Web 应用、SPA | 高 | access_token 15min + refresh_token 7d |
| 客户端凭证(Client Credentials) | 服务间通信(M2M) | 中 | 自行配置,通常 1h |
| 密码模式(Password) | 遗留系统,已弃用 | 低(明文密码) | 不推荐使用 |
| 隐式模式(Implicit) | 纯前端 SPA,已弃用(改用 PKCE) | 低(token 在 URL 中) | 不推荐使用 |
授权码 + PKCE 完整时序(文字描述):
1. 用户在前端点击"登录",前端生成 code_verifier(随机字符串)和 code_challenge(SHA256 哈希)
2. 前端重定向到授权服务器:/authorize?response_type=code&client_id=xxx&redirect_uri=xxx&code_challenge=xxx
3. 用户登录并授权,授权服务器返回 code 到回调地址
4. 后端用 code + code_verifier + client_secret 换 token:POST /token
5. 授权服务器验证 code_verifier 与 code_challenge 匹配,返回 {access_token, refresh_token, id_token, expires_in}
6. 后端将 access_token 返回给前端,前端存储并每次请求携带踩坑记录: 曾经在对接 Keycloak 时,发现 token 刷新偶尔失败。排查后发现是 refresh_token 的 rotation 策略——Keycloak 默认每次 refresh 都会吊销旧的 refresh_token 并返回新的。如果客户端并发刷新,只有第一个成功,后续的请求拿到的是已吊销的旧 refresh_token。解决方案:在 refresh 逻辑加分布式锁,或者在客户端侧做串行化。
M2M 场景的 token 管理: 服务间调用用 Client Credentials 模式时,token 过期直接返回 401 最坑。因为调用链中 A → B → C,中间 B 的 token 过期了,B 自己收到 401 不会自动重试。解决方案:封装一个带重试机制和缓存 token 的 HTTP 客户端,在 401 响应时自动刷新 token 并重试一次,同时用 synchronized 或 ReentrantLock 防止多个线程同时刷新。
private String getToken() {
if (cachedToken != null && cachedExpireAt > Instant.now().plusSeconds(30)) {
return cachedToken;
}
synchronized (lock) {
// 双重检查,防止第一次涌入的并发请求都去刷新 token
if (cachedToken != null && cachedExpireAt > Instant.now().plusSeconds(30)) {
return cachedToken;
}
// 调用授权服务器获取新 token
TokenResponse resp = restTemplate.postForObject(
"https://auth.example.com/oauth/token",
new ClientCredentialsGrant("client_id", "client_secret"),
TokenResponse.class
);
cachedToken = resp.getAccessToken();
cachedExpireAt = Instant.now().plusSeconds(resp.getExpiresIn());
return cachedToken;
}
}JWT 无状态鉴权与网关集成
JWT 是无状态 Token 的典型实现,包含 Header、Payload、Signature 三部分。服务端不需要存储 Session,只需验证签名即可。
Session vs JWT 对比(面试必问):
| 维度 | Session | JWT |
|---|---|---|
| 存储位置 | 服务端内存/Redis | 客户端(请求头) |
| 扩展性 | 需共享 Session 存储(Redis) | 天然水平扩展 |
| 撤销能力 | 删 Session 即可 | 需引入黑名单,增加查询开销 |
| Token 体积 | Cookie 较小(通常 ~32 bytes) | 通常 1-3KB,每次请求携带 |
| 安全性 | 防 XSS 需设置 HttpOnly | Payload 只 Base64 编码,不加密 |
| 跨服务传递 | 需要透传 Session ID | 自包含,直接解析 |
JWT 黑名单的性能问题: 如果业务要求能主动踢人,需要在服务端维护一个 JWT 黑名单。最直接的做法是把被撤销的 JWT 的 jti(JWT ID)存到 Redis,每次请求都需要查一次 Redis。1000 QPS 下,每次多一次 Redis GET 查询,latency 增加约 0.5ms,可以接受。但如果到了 10 万 QPS,这个 0.5ms 累积起来就是 50ms 的 P99 延迟增长。优化方案: 用短过期(如 5 分钟)+ refresh token 的方式,黑名单只保留过去 5 分钟的 jti,Redis 内存占用控制在 KB 级别,同时用 EXPIRE 自动清理。
Payload 不加密的坑: JWT 的 Payload 只是 Base64 编码,不是加密。生产环境见过同事把用户的身份证号、手机号放进了 JWT 的 claims 里,结果前端一解码就能看到。原则: JWT 里只放 sub(用户标识)、roles(角色)、scope(权限范围),不涉及 PII 数据。如果非要放,用 JWE(JSON Web Encryption)加密 payload,但会增加解析开销和复杂度。
网关层统一鉴权的真实架构:
客户端 → Spring Cloud Gateway(JWT 鉴权 + 请求头注入) → 下游微服务 A / B / C网关统一鉴权是推荐做法,但有一个容易被忽略的坑:网关鉴权失败后返回 401,下游服务日志里看不到任何请求记录,运维排查问题时根本无法判断是客户端没发 token 还是网关验签失败。解决方案: 网关层在返回 401 之前,记录一条结构化日志,包含 traceId、clientIp、path、reason(如 "token expired"、"signature invalid"、"missing auth header")。
@Component
public class JwtAuthGatewayFilter implements GlobalFilter {
private final ReactiveJwtDecoder jwtDecoder;
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String authHeader = exchange.getRequest().getHeaders()
.getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
log.warn("Auth failed: missing token, path={}, ip={}",
exchange.getRequest().getPath(), exchange.getRequest().getRemoteAddress());
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
String token = authHeader.substring(7);
return jwtDecoder.decode(token).flatMap(jwt -> {
// 将用户信息注入请求头,下游服务直接读取
exchange.getRequest().mutate()
.header("X-User-Id", jwt.getSubject())
.header("X-User-Roles", jwt.getClaimAsString("roles"));
return chain.filter(exchange);
}).onErrorResume(e -> {
String reason = e instanceof JwtExpiredException ? "token expired"
: e instanceof JwtValidationException ? "signature invalid"
: "unknown";
log.warn("Auth failed: {}, path={}, ip={}", reason,
exchange.getRequest().getPath(), exchange.getRequest().getRemoteAddress());
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
});
}
}@PreAuthorize 复杂条件表达式的踩坑: 业务上经常有「只有订单的创建者或管理员才能查看」这样的需求。
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or #id == authentication.name")
public Order getOrder(@PathVariable String id) {
// 只允许访问自己的订单
}坑点: 这个表达式里 #id 是方法参数,authentication.name 是 JwtAuthenticationToken.getName() 的返回值。如果 name 不是订单 ID 而是用户名,那就永远匹配不上。正确的做法: 在 JWT 里用 sub 作为用户唯一标识,在 @PreAuthorize 里用 #id == T(String).valueOf(authentication.principal) 或更稳妥的方式——在 Service 层手动校验,而不是依赖 SpEL 表达式。因为 SpEL 表达式在 AOP 代理中执行,如果方法被 @Transactional 也代理了,代理顺序会导致 @PreAuthorize 先执行,此时 #id 参数能取到,但 authentication 可能为空。遇到过的具体事故: 一个 @Async 方法加了 @PreAuthorize,结果异步线程里 SecurityContextHolder 为空,SpEL 解析 authentication 直接 NPE,返回 500 而不是 403。
资源服务器与授权服务器的职责划分
这是面试中经常被问清楚的边界问题:
- 授权服务器(Authorization Server):颁发 token,管理客户端注册、用户认证、授权码发放。通常独立部署,使用 Keycloak、Auth0、阿里云 IDaaS 等。
- 资源服务器(Resource Server):验证 token,提供受保护的 API。也就是我们的业务微服务。
不要把授权服务器和资源服务器混在一起。在早期架构中,有人把 token 签发和 token 验证放在同一个服务里,结果授权服务器挂掉后,已签发的 token 也无法验证,所有业务全部不可用。正确做法: 授权服务器独立部署,资源服务器通过 JWKS 端点(/.well-known/jwks.json)获取公钥来验证 token,即使授权服务器短时间内不可用,已签发的 token 仍然有效,直到到期。
总结
Spring Security + OAuth2 + JWT 的组合是当前微服务认证授权的主流方案。核心要点:
- SecurityFilterChain 是请求安全处理的入口,通过多链划分实现不同路径的安全策略差异化;注意异步场景下 ThreadLocal 丢失身份的问题
- 授权码模式+PKCE 是 Web 应用的首选,M2M 用 Client Credentials 模式,注意 token 刷新加锁防并发
- JWT 无状态适合微服务水平扩展,但需要配合短过期+refresh token 补偿撤销能力;Payload 不加密,别放敏感信息
- 网关层统一鉴权 + 下游信任传递,降低每服务的安全配置复杂度;鉴权失败记得打日志方便排查
- 授权服务器和资源服务器职责分离,避免单点故障级联
参考
参考:Spring Security 官方文档(6.x+);RFC 6749 OAuth 2.0 Authorization Framework;RFC 7519 JSON Web Token;Spring Security in Action(Laurentiu Spilca)