Skip to content

Spring Security + OAuth2 认证授权体系

提出问题

现代微服务架构中,认证和授权是不可绕过的基础能力。传统单体应用的 Session 登录在分布式环境下暴露出会话穿透、状态共享等问题,而 OAuth2 协议作为业界标准授权框架,被广泛应用于 SSO、第三方登录、API 鉴权等场景。Spring Security 通过与 OAuth2 + JWT 的深度集成,提供了一套从过滤器链到方法级权限的完整安全方案。面试中常问的问题包括:SecurityFilterChain 的机制、OAuth2 四种授权模式的选择、JWT 与 Session 的权衡、资源服务器与授权服务器的职责划分。理解这些,才能在实际项目中设计出既安全又灵活的身份认证体系。

分析问题

SecurityFilterChain 与过滤器链机制

Spring Security 的核心是过滤器链(Filter Chain)。每个请求会经过一串有序的 Servlet Filter,SecurityFilterChain 负责决定哪些过滤器作用于哪些请求路径。

java
@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 排序,匹配到第一个就停止。典型的做法是将公开接口、移动端接口、管理后台接口拆成不同的安全规则。过滤器链中的核心过滤器包括 AuthenticationFilterAuthenticationManagerSecurityContextHolder,认证成功后将主体信息存入当前线程的 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,意味着在异步场景下(@AsyncCompletableFuture)身份信息会丢失。踩坑: 在异步任务里取 SecurityContextHolder.getContext().getAuthentication() 返回 null,排查半小时才发现是 ThreadLocal 的天然限制。解决方案:用 SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL) 或显式传递 SecurityContext

OAuth2 四种授权模式

OAuth2 定义了四种授权模式,面向不同场景:

模式适用场景安全等级典型 Token 有效期
授权码 + PKCE(Authorization Code + PKCE)有后端的 Web 应用、SPAaccess_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_tokenrotation 策略——Keycloak 默认每次 refresh 都会吊销旧的 refresh_token 并返回新的。如果客户端并发刷新,只有第一个成功,后续的请求拿到的是已吊销的旧 refresh_token。解决方案:在 refresh 逻辑加分布式锁,或者在客户端侧做串行化。

M2M 场景的 token 管理: 服务间调用用 Client Credentials 模式时,token 过期直接返回 401 最坑。因为调用链中 A → B → C,中间 B 的 token 过期了,B 自己收到 401 不会自动重试。解决方案:封装一个带重试机制和缓存 token 的 HTTP 客户端,在 401 响应时自动刷新 token 并重试一次,同时用 synchronizedReentrantLock 防止多个线程同时刷新。

java
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 对比(面试必问):

维度SessionJWT
存储位置服务端内存/Redis客户端(请求头)
扩展性需共享 Session 存储(Redis)天然水平扩展
撤销能力删 Session 即可需引入黑名单,增加查询开销
Token 体积Cookie 较小(通常 ~32 bytes)通常 1-3KB,每次请求携带
安全性防 XSS 需设置 HttpOnlyPayload 只 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 之前,记录一条结构化日志,包含 traceIdclientIppathreason(如 "token expired"、"signature invalid"、"missing auth header")。

java
@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 复杂条件表达式的踩坑: 业务上经常有「只有订单的创建者或管理员才能查看」这样的需求。

java
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or #id == authentication.name")
public Order getOrder(@PathVariable String id) {
    // 只允许访问自己的订单
}

坑点: 这个表达式里 #id 是方法参数,authentication.nameJwtAuthenticationToken.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)

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。