微服务安全: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 之上加了两个关键东西:
- ID Token:一个 JWT 格式的 Token,包含用户身份信息(sub、name、email、preferred_username 等),由认证中心签名。
- UserInfo 端点:通过 Access Token 换取详细的用户信息。
// 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 字段。
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。
// 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 泄露,攻击者也只能用一次。
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 校验配置
// GatewayApplication.java
@SpringBootApplication
@EnableReactiveMethodSecurity
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}# 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,自动拉取公钥// 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 后,通过鉴权过滤器将用户身份信息注入到请求头中:
// 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)
// 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 是最常见的开源方案。
# 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-devKeycloak 配置要点:
- 创建 Realm(租户隔离)
- 创建 Client(配置为 OIDC 类型,开启 PKCE)
- 配置 Client 的 Access Token 有效期(默认 5 分钟,够用)
- 配置 Client 的 Refresh Token 有效期(默认 30 分钟,要调长到 7 天)
- 获取 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 错误。
// 错误示范
{
"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,完整权限信息业务服务自己查缓存。
// 正确示范
{
"sub": "user123",
"roles": ["admin", "order_operator"]
}为什么 roles 就够了? 权限映射是"角色→权限"的二维关系,在业务服务本地缓存里维护。一个 1000 用户的系统,角色数量不超过 10 个,缓存更新成本极低。
陷阱 2:Token 在微服务间透传
正确做法:
- 用户请求:网关校验 JWT → 透传 userId 给业务服务
- 服务间调用:用 Client Credentials 模式获取服务间 Token,或使用 mTLS
// 服务间调用 - 使用 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 周期,让所有网关实例都能平滑过渡。
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,密钥轮换时保留旧密钥平滑过渡 |