第 10 章 · OAuth2 资源服务器
本章目标:在微服务架构中实现 OAuth2 资源服务器(Resource Server);由虚构 auth.example.com 签发 JWT,gateway-svc 与 user-svc / order-svc 校验 Token;掌握 Bearer Token 透传、scope/role 映射与服务间调用身份;对比单体 ch15 自签 JWT 与中心化授权服务器;理解 Opaque Token + Introspection(概念)。
学时建议:5~6 小时(含 2 小时 auth-svc 与 Gateway 联调)
前置:spring-cloud-web ch01~ch09;spring-boot-web ch06 Security 与 ch15 JWT 概念。
10.1 微服务鉴权分层
┌──────────────┐ 授权码/密码(教学简化) ┌──────────────┐
│ SPA 客户端 │ ──────────────────────────► │ auth-svc │
│ │ ◄────────────────────────── │ 签发 JWT │
└──────┬───────┘ access_token └──────────────┘
│ Authorization: Bearer <jwt>
▼
┌──────────────┐ 校验 JWT / 透传 Header ┌──────────────┐
│ gateway-svc │ ──────────────────────────► │ order-svc │
│ 可选统一鉴权 │ │ 资源服务器 │
└──────────────┘ └──────────────┘
| 层级 | 职责 |
|---|---|
| 授权服务器 auth-svc | 登录、签发/刷新 Token |
| Gateway | 可选统一 JWT 校验、黑名单、限流 |
| 各业务服务 | 资源服务器,校验 scope、数据权限 |
| 服务间 Feign | 传递用户上下文或 Client Credentials(机器间) |
虚构 auth.example.com;严禁使用真实公司 IdP 密钥或生产 Client Secret。
10.2 授权服务器 auth-svc(教学简化)
使用 Spring Authorization Server(独立模块,与业务 user-svc 分离):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
注册 OAuth2 客户端(SPA,教学用 PKCE 可选):
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient spa = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("svc-spa-client")
.clientSecret("{noop}dev-secret-change-me")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.PASSWORD) // 教学仅 dev
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.scope("openid")
.scope("order:read")
.scope("order:write")
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofHours(2))
.build())
.build();
return new InMemoryRegisteredClientRepository(spa);
}
重要警告:密码模式(Resource Owner Password Credentials)在 OAuth 2.1 中已被移除,且 Spring Authorization Server 官方并未实现该模式——上面的注册示例仅用于理解RegisteredClient结构,直接启动并请求密码模式会得到unsupported_grant_type。若要真正跑通密码模式需自行扩展OAuth2TokenGrantAuthenticationConverter(不推荐)。生产环境请使用:用户场景 → 授权码 + PKCE;服务间 → Client Credentials(见 10.6)。
10.3 JWT 定制 Claims
@Component
public class CustomTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {
@Override
public void customize(JwtEncodingContext context) {
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
Authentication auth = context.getPrincipal();
context.getClaims().claim("roles", extractRoles(auth));
context.getClaims().claim("userId", extractUserId(auth));
}
}
}
JWT payload 示例:
{
"sub": "alice",
"userId": 7,
"roles": ["USER"],
"scope": "order:read order:write",
"iss": "https://auth.example.com",
"exp": 1735689600
}
10.4 业务服务:资源服务器配置
order-svc SecurityFilterChain:
@Bean
@Order(2)
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/v1/orders/**").hasAuthority("SCOPE_order:read")
.requestMatchers(HttpMethod.POST, "/api/v1/orders/**").hasAuthority("SCOPE_order:write")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())));
return http.build();
}
application.yml:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
jwk-set-uri: https://auth.example.com/oauth2/jwks
本地开发可用 http://127.0.0.1:9000 并关闭 HTTPS 校验(仅 dev profile)。
10.5 Gateway 统一鉴权(推荐)
在 gateway-svc 校验 JWT,下游信任内网并透传用户信息:
spring:
cloud:
gateway:
routes:
- id: route-order
uri: lb://order-svc
predicates:
- Path=/api/v1/orders/**
filters:
- TokenRelay= # 把 Authorization 头透传给下游(需引入 spring-boot-starter-oauth2-client)
注意:Gateway 过滤器参数里的 SpEL(如#{@bean.apply(exchange)})无法访问exchange变量,AddRequestHeader只适合写静态值。要把 JWT 中的userId动态注入请求头,必须用自定义GlobalFilter:
@Component
public class JwtAuthGlobalFilter implements GlobalFilter, Ordered {
private final ReactiveJwtDecoder jwtDecoder; // 由资源服务器自动配置注入
public JwtAuthGlobalFilter(ReactiveJwtDecoder jwtDecoder) {
this.jwtDecoder = jwtDecoder;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String auth = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (auth == null || !auth.startsWith("Bearer ")) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}