Skip to content

微服务安全:OAuth 2.1 + OIDC + JWT 鉴权,Spring Security 网关级集成

问题

微服务架构下,如何设计统一的认证鉴权体系?OAuth 2.0 和 OAuth 2.1 的核心区别是什么?OIDC(OpenID Connect)在 OAuth 2.0 基础上增加了什么?JWT 的 Token 如何做刷新和撤销?Spring Security 如何与网关集成实现统一鉴权?

分析:微服务安全的三层架构

微服务安全的核心挑战很明确:服务多了,认证鉴权不能重复造轮子,也不能各搞一套。标准的方案是三层架构:

客户端 → 网关层(统一鉴权) → 业务服务(细粒度授权)

          认证中心(Auth Server)

网关层只做两件事:校验 Token 有效性、解析用户身份并透传给下游。业务服务拿到身份信息后做自己的权限判断。认证中心独立部署,只负责签发 Token。

这个架构的逻辑很清晰——把"你是谁"和"你能做什么"分开,网关管前者,业务管后者。

三层架构设计要点

  • 为什么不在每个业务服务里校验 JWT? CPU 浪费。如果 20 个微服务每个都要拉 JWK Set 验证签名,负载是 20 倍。网关层做一次就够了。
  • 网关层退化为单点瓶颈怎么办? 网关层本身可以水平扩展,JWT 校验是无状态的,加实例就行。
  • 认证中心挂了怎么办? 认证中心只负责签发 Token,不参与运行时校验。网关层已经缓存了 JWK Set(公钥),认证中心离线期间现有 Token 照样可用。新用户登录才受影响,降级为"只读模式"。

OAuth 2.0 → OAuth 2.1:删了四个历史包袱

OAuth 2.1 不是新协议,而是对 OAuth 2.0 的瘦身。删掉了四个在实践中被证明是"安全漏洞"的模式:

废弃内容为什么死替代方案
隐式授权模式(Implicit Grant)Token 直接暴露在 URL 片段中,浏览器历史记录里存着明文的 access_token,攻击者拿走历史记录就拿到了授权码模式 + PKCE
密码模式(Resource Owner Password Credentials Grant)第三方应用直接拿密码,你交密码给别人的那一刻用户名密码就泄露了授权码模式 / 设备授权
密码模式的 Refresh Token 流转密码模式本身已废弃,随之废弃
不强制 PKCE 的授权码模式授权码可能被拦截(在没有 TLS 的移动端/桌面端回环地址上),攻击者用授权码直接换 Token强制 PKCE

核心变化就一句话:OAuth 2.1 强制要求所有授权码流程都带 PKCE,且删掉了隐式和密码模式。

授权码 + PKCE 完整流程(面试必问)

客户端(SPA/App)                    认证中心(Auth Server)
    │                                      │
    │  1. 生成 code_verifier(随机字符串)    │
    │     计算 code_challenge = SHA256(code_verifier)
    │                                      │
    │  2. 请求授权码(带 code_challenge)    │
    │  ──────────────────────────────────►  │
    │                                      │
    │  3. 用户登录授权(302 跳转)            │
    │  ◄──────────────────────────────────  │
    │                                      │
    │  4. 浏览器拿到授权码                    │
    │                                      │
    │  5. 用授权码 + code_verifier 换 Token  │
    │  ──────────────────────────────────►  │
    │     认证中心校验:                       │
    │     SHA256(code_verifier) == code_challenge ?
    │                                      │
    │  6. 返回 Access Token + ID Token      │
    │  ◄──────────────────────────────────  │

为什么要 PKCE? 2016 年之前,移动端 App 用授权码模式时,授权码是通过系统浏览器回调的,恶意 App 可以拦截这个回调拿到授权码。PKCE 加了一道验证:即使授权码被拦截,攻击者没有原始的 code_verifier 也换不到 Token。

OIDC:解决"你是谁"的问题

OAuth 2.0 只解决授权(Authorization)——"允许第三方应用访问我的资源"。但它不解决认证(Authentication)——"你怎么证明是你本人"。

OIDC(OpenID Connect)在 OAuth 2.0 之上加了两个关键东西:

  1. ID Token:一个 JWT 格式的 Token,包含用户身份信息(sub、name、email、preferred_username 等),由认证中心签名。
  2. UserInfo 端点:通过 Access Token 换取详细的用户信息。
json
// ID Token 示例(JWT 解码后)
{
  "iss": "https://auth.example.com",
  "sub": "1234567890",
  "aud": "my-client-id",
  "exp": 1693833600,
  "iat": 1693830000,
  "name": "张三",
  "email": "zhangsan@example.com",
  "preferred_username": "zhangsan",
  "nonce": "n-0S6_WzA2Mj"  // OIDC 防重放攻击
}

OIDC 和 OAuth 2.0 的流程差异只有一步:在授权码换 Token 时,响应里多了 id_token 字段。

http
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
code=authorization_code_value
code_verifier=original_code_verifier
redirect_uri=https://myapp.com/callback
client_id=my-client

// 响应
{
  "access_token": "eyJhbG...",
  "token_type": "Bearer",
  "expires_in": 300,
  "id_token": "eyJraWQ...",   // OIDC 新增的
  "refresh_token": "def502..."
}

OIDC = OAuth 2.0 + 身份认证。

JWT 签名算法选择:对称 vs 非对称

维度HS256(对称)RS256(非对称)
密钥一个共享密钥,谁持有谁就能签发私钥签名,公钥验证
性能签名验证都快,对称算法天生快 1-2 个数量级签名慢(私钥操作),验证快(公钥操作)
密钥分发麻烦,所有服务都要拿到同一个密钥网关层只配公钥 JWK Set,认证中心私钥不外泄
安全风险任何一个服务被攻破,密钥泄露,所有 Token 伪造即使业务服务被攻破,拿不到签名私钥

生产环境选 RS256。原因:网关层验证 JWT 不需要拿私钥,认证中心私钥只在认证中心一台机器上,攻击面小很多。只有单机单体应用才考虑 HS256。

JWT 的刷新与撤销:现实问题不美

JWT 最大的问题是无状态=不可撤销。Token 签发后,服务端没有记录,无法主动让 Token 失效。

标准解法:短效 Access Token + 长效 Refresh Token。

java
// Access Token 5 分钟,Refresh Token 7 天
@Bean
public JwtEncoder jwtEncoder() {
    JWKSource<SecurityContext> jwkSource = ...;
    return new NimbusJwtEncoder(jwkSource);
}

public String createAccessToken(String userId, String[] roles) {
    Instant now = Instant.now();
    JwtClaimsSet claims = JwtClaimsSet.builder()
        .issuer("https://auth.example.com")
        .subject(userId)
        .issuedAt(now)
        .expiresAt(now.plus(5, ChronoUnit.MINUTES))
        .claim("roles", roles)
        .build();
    return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}

必须撤销怎么办?(用户改密码、账号被封等场景)

  • 方案 A:黑名单机制——网关层维护一个 Redis Set,存储已撤销的 Token JTI(JWT ID),每次请求前检查 Redis。一个 5000 QPS 的网关,Redis 检查耗时约 0.5ms,可以接受。
  • 方案 B:短效 Token + 不做黑名单——5 分钟过期,等它自然失效。大多数场景够用。如果用户改密码,5 分钟后旧 Token 自动失效,不会给攻击者留太多时间窗口。

推荐方案 B + 方案 A 的降级:默认用短效 Token,只有管理员手动封禁用户时才走黑名单。黑名单可以做成 Bloom Filter 来节省内存——误判最多让用户多刷新一次,不会放行无效 Token。

Refresh Token Rotation(OAuth 2.1 推荐)

每次刷新 Access Token 时,同时签发一个新的 Refresh Token,并使旧的 Refresh Token 失效。这样即使 Refresh Token 泄露,攻击者也只能用一次。

http
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
refresh_token=old_refresh_token
client_id=my-client
client_secret=my-secret

// 响应
{
  "access_token": "new_access_token",
  "refresh_token": "new_refresh_token",  // 旧的 Refresh Token 已失效
  "token_type": "Bearer",
  "expires_in": 300
}

Refresh Token Rotation 的坑:如果客户端刷新后网络断连,没收到新的 Refresh Token,客户端就永久丢失了刷新能力。这时候客户端只能重新登录。解法:客户端在拿到新 Token 之前不要丢弃旧的,或者设置一个"宽限期"——旧的 Refresh Token 在 30 秒内仍然可用。

代码示例:Spring Cloud Gateway + Spring Security 网关级集成

1. 网关层 JWT 校验配置

java
// GatewayApplication.java
@SpringBootApplication
@EnableReactiveMethodSecurity
public class GatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}
yaml
# application.yml
spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
          filters:
            - StripPrefix=1
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com
          # 自动从 issuer-uri 获取 JWK Set 公钥
          # 等效请求:GET https://auth.example.com/.well-known/openid-configuration
          # 然后从中读取 jwks_uri,自动拉取公钥
java
// SecurityConfig.java - 网关安全配置
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(
            ServerHttpSecurity http,
            ReactiveAuthenticationManagerResolver<ServerWebExchange> authResolver) {
        return http
            .authorizeExchange(exchanges -> exchanges
                // 公开端点
                .pathMatchers(HttpMethod.GET, "/api/public/**").permitAll()
                // 需要登录
                .pathMatchers("/api/orders/**").authenticated()
                // 需要 ADMIN 角色
                .pathMatchers("/api/admin/**").hasRole("ADMIN")
                .anyExchange().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            )
            .csrf(ServerHttpSecurity.CsrfSpec::disable)
            .build();
    }

    /**
     * 将 JWT claims 解析为认证信息,并注入到请求头中透传给下游
     */
    private Converter<Jwt, Mono<AbstractAuthenticationToken>> jwtAuthenticationConverter() {
        return new ReactiveJwtAuthenticationConverterAdapter(
            new JwtAuthenticationConverter() {
                {
                    setJwtGrantedAuthoritiesConverter(jwt -> {
                        List<String> roles = jwt.getClaimAsStringList("roles");
                        if (roles == null) return List.of();
                        return roles.stream()
                            .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                            .collect(Collectors.toList());
                    });
                }
            }
        );
    }
}

2. Spring Security 的 JWT 校验内部发生了什么

请求到达 Gateway


ServerHttpSecurity 过滤器链

     ├─ SecurityWebFilterChain 匹配请求路径

     ├─ BearerTokenAuthenticationFilter
     │      │
     │      ├─ 从 Authorization 头提取 Bearer Token
     │      │
     │      ├─ ReactiveJwtDecoder
     │      │      │
     │      │      ├─ 从 JWK Set 缓存中找匹配的 kid
     │      │      ├─ 用公钥验证签名(RS256 的 RSA 验签)
     │      │      ├─ 校验 exp、iss、aud
     │      │      └─ 返回 Jwt 对象
     │      │
     │      └─ JwtAuthenticationConverter
     │             │
     │             └─ 将 Jwt claims 转为 Authentication 对象

     └─ AuthorizationWebFilter

            └─ 根据路径配置和角色进行授权判断

关键点ReactiveJwtDecoder 默认会缓存 JWK Set,默认缓存时间 5 分钟。认证中心轮换密钥后,网关最多 5 分钟才会感知到新密钥。如果认证中心在旧密钥轮换期间签发了新 Token,网关会验证失败。解法:配置 spring.security.oauth2.resourceserver.jwt.jwk-set-cache-ttl 缩短缓存时间,或者注册一个 ReactiveJwtDecoder 实例并手动设置 JWSKeySelector 的缓存策略。

3. 网关透传用户信息到下游服务

网关校验 JWT 后,通过鉴权过滤器将用户身份信息注入到请求头中:

java
// JwtAuthHeaderFilter.java
@Component
public class JwtAuthHeaderFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return exchange.getPrincipal()
            .cast(JwtAuthenticationToken.class)
            .map(auth -> {
                Jwt jwt = auth.getToken();
                ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
                    // 注意:内部请求头不能用 X- 前缀,防止客户端伪造
                    // 实际生产中通常用自定义前缀如 X-Internal- 并配合内部网络策略
                    .header("X-Auth-User-Id", jwt.getSubject())
                    .header("X-Auth-User-Roles", 
                        String.join(",", auth.getAuthorities().stream()
                            .map(GrantedAuthority::getAuthority)
                            .collect(Collectors.toList())))
                    .build();
                return exchange.mutate().request(mutatedRequest).build();
            })
            .defaultIfEmpty(exchange)
            .flatMap(chain::filter);
    }

    @Override
    public int getOrder() {
        return -1; // 在路由转发之前执行
    }
}

安全考虑:下游服务信任这些请求头的前提是——网关到业务服务必须在内网,且入口网关必须剥离外部请求的 X-Auth-User-Id 头,防止客户端伪造。如果业务服务直接暴露在公网,这个方案就废了。

4. 业务服务接收用户信息(无需再校验 JWT)

java
// OrderController.java - 业务服务
@RestController
@RequestMapping("/orders")
public class OrderController {

    @GetMapping("/{orderId}")
    public Order getOrder(
            @PathVariable String orderId,
            @RequestHeader("X-Auth-User-Id") String userId,
            @RequestHeader("X-Auth-User-Roles") String roles) {
        // 网关已经校验过 Token,这里直接拿 userId
        // 细粒度鉴权在这里做
        if (!orderService.belongsToUser(orderId, userId)) {
            throw new AccessDeniedException("无权访问此订单");
        }
        return orderService.getOrder(orderId, userId);
    }
}

5. Keycloak 作为认证中心的配置示例

生产环境很少自己实现认证中心,Keycloak 是最常见的开源方案。

yaml
# docker-compose.yml
version: '3.8'
services:
  keycloak:
    image: quay.io/keycloak/keycloak:24.0
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
    ports:
      - "8080:8080"
    command: start-dev

Keycloak 配置要点:

  1. 创建 Realm(租户隔离)
  2. 创建 Client(配置为 OIDC 类型,开启 PKCE)
  3. 配置 Client 的 Access Token 有效期(默认 5 分钟,够用)
  4. 配置 Client 的 Refresh Token 有效期(默认 30 分钟,要调长到 7 天)
  5. 获取 Realm 的 JWK Set URL:http://keycloak:8080/realms/{realm}/protocol/openid-connect/certs

常见陷阱

陷阱 1:JWT Payload 太大

真实案例:某公司把用户权限树全塞进 JWT,一个 Token 超过 8KB。网关层每次请求传输 8KB,1000 QPS 时额外带宽消耗约 64 Mbps。更严重的是,HTTP Header 限制为 8KB(Nginx 默认),部分请求直接 400 错误。

json
// 错误示范
{
  "sub": "user123",
  "permissions": ["order:read", "order:write", "product:read", "product:delete", ...],  // 100+ 个
  "departments": ["RD", "Platform", "Core", ...],  // 10+ 个
  "org_tree": { ... }  // 巨大的组织树
}

后果:每次请求网关都要传输这个巨大的 JWT,带宽浪费,且 HTTP Header 大小有限制。

正确做法:JWT 只放 userId 和 roles,完整权限信息业务服务自己查缓存。

json
// 正确示范
{
  "sub": "user123",
  "roles": ["admin", "order_operator"]
}

为什么 roles 就够了? 权限映射是"角色→权限"的二维关系,在业务服务本地缓存里维护。一个 1000 用户的系统,角色数量不超过 10 个,缓存更新成本极低。

陷阱 2:Token 在微服务间透传

正确做法

  • 用户请求:网关校验 JWT → 透传 userId 给业务服务
  • 服务间调用:用 Client Credentials 模式获取服务间 Token,或使用 mTLS
java
// 服务间调用 - 使用 Client Credentials
@FeignClient(name = "inventory-service", configuration = InternalFeignConfig.class)
public interface InventoryClient {
    @PostMapping("/api/inventory/deduct")
    DeductResult deduct(@RequestBody DeductRequest request);
}

// 配置 Feign 时自动注入服务间 Token
public class InternalFeignConfig {
    @Bean
    public RequestInterceptor internalTokenInterceptor() {
        return request -> {
            // 获取服务间 Token(Client Credentials 模式)
            // 注意:这个 Token 要缓存,不要每次请求都去认证中心获取
            String internalToken = getInternalToken();
            request.header("Authorization", "Bearer " + internalToken);
        };
    }
}

Client Credentials Token 的缓存策略:Token 过期前 1 分钟主动刷新,而不是等到过期再刷新。用 ScheduledExecutorService 定时刷新,避免并发请求同时去认证中心抢 Token。

陷阱 3:Access Token 有效期太长

真实案例:某公司 JWT 的 Access Token 有效期设为 7 天,且没配 Refresh Token 机制。用户改密码后旧 Token 仍然有效,攻击者利用泄露的旧 Token 持续访问了 3 天。

教训:Access Token 必须短效(5-15 分钟),即使增加 Refresh Token Rotation 的复杂度也值得。

陷阱 4:JWK Set 缓存过期导致生产故障

真实案例:某公司认证中心轮换签名密钥,但网关的 JWK Set 缓存了 1 小时。密钥轮换后 50 分钟内签发的 Token 全部被网关验证失败,用户集体掉线。

解决:配置 JWK Set 缓存 TTL 为 5 分钟,且认证中心轮换密钥时保留旧密钥 2 个 TTL 周期,让所有网关实例都能平滑过渡。

yaml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-cache-ttl: 5m  # 默认 5 分钟,不要改太长

陷阱 5:网关层和业务服务之间的信任边界不清

错误做法:业务服务直接信任网关传来的 X-Auth-User-Id 头,但不验证来源 IP。

后果:如果任何一个内网服务被攻破,攻击者可以伪造请求头冒充任意用户。

正确做法:在内网 Kubernetes 中,用 NetworkPolicy 限制只有网关 Pod 能访问业务服务。或者用 mTLS 做服务间认证,每个请求都验证客户端证书。

总结

问题答案
微服务安全架构三层:认证中心 → 网关(统一鉴权)→ 业务服务(细粒度授权)
OAuth 2.0 → 2.1 变化废弃隐式/密码模式,强制 PKCE
OIDC 解决了什么OAuth 2.0 只解决授权,OIDC 增加身份认证(ID Token + UserInfo)
JWT 签名算法选型RS256 非对称,认证中心私钥签名,网关公钥验证
JWT 撤销方案短效 Token(5-15 分钟)+ Refresh Token Rotation,必要时加黑名单(Bloom Filter)
网关鉴权集成Spring Cloud Gateway + Spring Security OAuth2 Resource Server,自动获取 JWK Set
Token 透传网关透传 userId 到业务服务,服务间用 Client Credentials 或 mTLS
鉴权粒度网关做认证,业务做授权
JWK Set 缓存5 分钟 TTL,密钥轮换时保留旧密钥平滑过渡

延伸阅读

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