Day47-OAuth2.0+JWT微服务认证授权:从理论到落地
专栏《Java高级进阶》90天进阶系列。配套代码Spring Boot 3.2.x Spring Cloud 2023.0.x Spring Authorization Server 1.3.x一、那个被 session 拖垮的深夜曾遇到过一家公司他们的微服务集群有 10 多个服务用户登录后把 session 存在 Redis每个请求都带着JSESSIONID到处跑。结果一个下游服务的 cookie 解析逻辑写崩了Redis 连接池被瞬间打满整个商城的下单链路全挂。我就在想“都微服务了为什么我们还要像十年前那样在服务器里存一份用户状态”OAuth2.0 JWT 就是解决这个问题的经典方案。它让认证中心只负责“发令牌”网关只负责“验令牌”业务服务只负责“用令牌里的声明做权限判断”。无状态、可水平扩展、还能统一接入微信 / 企业微信 / 钉钉等第三方登录。二、OAuth2.0 三种模式别拿 Authorization Code 去跑服务间调用很多初学者一上来就被 OAuth2.0 的术语绕晕。其实记住一句话就行OAuth2.0 的核心是“授权”不是“登录”。它解决的是“A 系统能不能代表用户去访问 B 系统的资源”。微服务场景下我们主要用下面三种模式授权模式适用场景是否推荐Authorization Code授权码模式PC/H5 页面、移动 App、SPA 单页应用首推最安全Password密码模式内部系统、遗留系统改造、强信任客户端谨慎使用官方已不鼓励Client Credentials客户端凭证模式服务与服务之间调用无用户参与机器间调用首选一个血泪教训我见过有团队把“客户端凭证模式”用在小程序登录上结果用户 A 拿到了用户 B 的 token。模式选错后面的代码再漂亮也是错的。对于公网暴露的客户端SPA、App一定要用 Authorization Code PKCE防止授权码被截获。PKCE 就是在申请授权码时多传一个code_challenge换 token 时再传对应的code_verifier。三、JWT为什么微服务爱它又恨它JWTJSON Web Token本质是一段带签名的 JSON。它长这样eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDAxIiwic2NvcGUiOiJyZWFkIHdyaXRlIiwiZXhwIjoxNzIyODY0MDAwfQ.signature点号分三段Header.Payload.Signature。其中 Payload 放的是“声明claims”比如用户 ID、角色、权限、过期时间。微服务喜欢 JWT是因为服务端无状态网关验完签名就能放行不用去 Redis 查 session。跨服务传递方便HTTP Header 一挂所有下游服务都能读到同样的用户信息。天然适合水平扩展新增 10 台机器也不需要同步 session。但它也有硬伤无法主动吊销token 一旦发出去在过期前一直有效。如果用户退出登录只能等它过期。体积大每次请求都带一截 Base64Header 会膨胀。不要放敏感信息Payload 只是 Base64 编码谁都能解码看内容。所以生产上的标准做法是access_token 短有效期比如 15 分钟refresh_token 长有效期比如 7 天放 httpOnly cookie退出时把 refresh_token 加入 Redis 黑名单。access_token 里只放用户 ID 和基本角色不放手机号、身份证这些隐私。四、实战用 Spring Authorization Server 搭认证中心下面直接上代码。核心依赖版本我标清楚了复制就能跑。4.1 关键依赖dependency groupIdorg.springframework.security/groupId artifactIdspring-security-oauth2-authorization-server/artifactId version1.3.1/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-resource-server/artifactId /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependencySpring Boot 版本建议 3.2.xSpring Cloud 对应 2023.0.x。4.2 认证服务器配置这是最小可运行的 Authorization Server用 RS256 签名注册了三个客户端分别对应授权码模式、密码模式不推荐但演示用和客户端凭证模式。Configuration EnableWebSecurity public class AuthorizationServerConfig { /** * 注册 OAuth2 客户端。 * 实际项目请把 clientSecret 用 PasswordEncoder 加密不要写 {noop}。 */ Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient webClient RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(mall-web) .clientSecret({noop}mall-web-secret) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri(http://127.0.0.1:8080/login/oauth2/code/mall-web) .scope(OidcScopes.OPENID) .scope(read) .scope(write) .build(); RegisteredClient innerClient RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(mall-app) .clientSecret({noop}mall-app-secret) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .scope(service) .build(); return new InMemoryRegisteredClientRepository(webClient, innerClient); } /** 生成 RSA 密钥对用于 JWT 的 RS256 签名。 生产环境建议从 KMS/配置文件加载不要每次启动重新生成。 */ Bean public JWKSourcelt;SecurityContextgt; jwkSource() { KeyPair keyPair generateRsaKey(); RSAPublicKey publicKey (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey (RSAPrivateKey) keyPair.getPrivate(); RSAKey rsaKey new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); JWKSet jwkSet new JWKSet(rsaKey); return new ImmutableJWKSetlt;gt;(jwkSet); } private static KeyPair generateRsaKey() { try { KeyPairGenerator keyPairGenerator KeyPairGenerator.getInstance(RSA); keyPairGenerator.initialize(2048); return keyPairGenerator.generateKeyPair(); } catch (Exception ex) { throw new IllegalStateException(ex); } } Bean public JwtDecoder jwtDecoder(JWKSourcelt;SecurityContextgt; jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } Bean public AuthorizationServerSettings authorizationServerSettings() { // issuer 必须与网关、资源服务配置的 issuer 一致否则验签会失败 return AuthorizationServerSettings.builder() .issuer(http://localhost:9000) .build(); } Bean Order(Ordered.HIGHEST_PRECEDENCE) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); return http.build(); } }启动后访问http://localhost:9000/.well-known/openid-configuration能看到issuer、token端点、JWKS 端点。网关和资源服务会从 JWKS 拉公钥验签。五、网关统一鉴权把用户信息透传给下游网关是微服务认证的第一道防线。它的职责很清晰解析并校验 JWT 的签名和过期时间把用户身份、权限以内部 Header 的方式传给下游鉴权失败的直接返回 401/403不要透传到业务服务。Component public class JwtAuthGatewayFilter implements GlobalFilter, Ordered { private final ReactiveJwtDecoder jwtDecoder; public JwtAuthGatewayFilter(ReactiveJwtDecoder jwtDecoder) { this.jwtDecoder jwtDecoder; } Override public Monolt;Voidgt; filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); String path request.getURI().getPath(); // 登录、注册、健康检查等公开接口直接放行 if (path.startsWith(/public/) || path.startsWith(/actuator/)) { return chain.filter(exchange); } String token extractToken(request); if (token null) { return unauthorized(exchange, 缺少 Authorization 头); } return jwtDecoder.decode(token) .flatMap(jwt -amp;gt; { String userId jwt.getSubject(); Listamp;lt;Stringamp;gt; scopes jwt.getClaimAsStringList(scope); String authorities scopes null ? : String.join(,, scopes); ServerHttpRequest mutated request.mutate() .header(X-User-Id, userId) .header(X-Authorities, authorities) // 移除外部带来的伪造头部防止冒充 .headers(h -amp;gt; h.remove(X-Internal-Call)) .build(); return chain.filter(exchange.mutate().request(mutated).build()); }) .onErrorResume(e -amp;gt; unauthorized(exchange, Token 无效或已过期: e.getMessage())); } private String extractToken(ServerHttpRequest request) { String bearer request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); if (bearer ! null amp;amp; bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } private Monolt;Voidgt; unauthorized(ServerWebExchange exchange, String msg) { ServerHttpResponse response exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().setContentType(MediaType.APPLICATION_JSON); byte[] body ({error: msg }).getBytes(StandardCharsets.UTF_8); DataBuffer buffer response.bufferFactory().wrap(body); return response.writeWith(Mono.just(buffer)); } Override public int getOrder() { // 优先级要高确保在路由、限流之前执行 return -100; } }Configuration public class GatewayConfig { Bean public ReactiveJwtDecoder reactiveJwtDecoder() { // 从认证中心的 issuer 自动发现 JWKS 端点 return ReactiveJwtDecoders.fromIssuerLocation(http://localhost:9000); } }这里有一个细节网关验完 token 后只应该把“用户是谁、有什么角色”透传下去具体的业务权限判断交给业务服务。不要试图在网关里维护一张巨大的“接口-权限”映射表否则网关会变成第二个鉴权怪物。六、资源服务让 Spring Security 帮你做细粒度控制业务服务作为资源服务器只接收网关转发的请求配置如下Configuration EnableMethodSecurity(prePostEnabled true) public class ResourceServerConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -gt; auth .requestMatchers(/public/**).permitAll() .anyRequest().authenticated() ); http.oauth2ResourceServer(oauth2 -gt; oauth2 .jwt(jwt -gt; jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) ); return http.build(); } /** 把 JWT 里的 scope 转成 Spring Security 的 GrantedAuthority。 例如 scoperead -gt; SCOPE_read */ Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter authoritiesConverter new JwtGrantedAuthoritiesConverter(); authoritiesConverter.setAuthorityPrefix(SCOPE_); authoritiesConverter.setAuthoritiesClaimName(scope); JwtAuthenticationConverter converter new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); converter.setPrincipalClaimName(sub); return converter; } }Controller 里这样写RestController RequestMapping(/orders) public class OrderController { GetMapping(/{id}) PreAuthorize(hasAuthority(SCOPE_read)) public Order getOrder(PathVariable Long id, RequestHeader(X-User-Id) String userId) { // 用户只能查自己的订单 return orderService.findByIdAndUserId(id, Long.valueOf(userId)); } PostMapping PreAuthorize(hasAuthority(SCOPE_write)) public Order createOrder(RequestBody OrderCreateRequest request, RequestHeader(X-User-Id) String userId) { return orderService.create(request, Long.valueOf(userId)); } }到这一步你已经拥有了认证中心发证 → 网关验票 → 资源服务检票的完整链路。七、刷新与退出短 token 黑名单是最佳折中前面说过 JWT 无法主动吊销。生产上我建议这样做access_token 有效期设短5~15 分钟。只放用户 ID、角色体积可控。refresh_token 用 httpOnly Secure SameSiteStrict cookie前端拿不到XSS 偷不走。退出登录时把 refresh_token 加入 Redis 黑名单有效期等于 token 剩余时间。access_token 仍然要等到过期但重新获取的入口已经被切断。Service public class TokenBlacklistService { private final StringRedisTemplate redisTemplate; private static final String PREFIX oauth:refresh:blacklist:; public TokenBlacklistService(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } public void blacklist(String refreshToken, long expireSeconds) { // jti 是 JWT 的唯一标识如果没有 jti 就用 token 本身 hash String jti extractJti(refreshToken); redisTemplate.opsForValue().set(PREFIX jti, 1, expireSeconds, TimeUnit.SECONDS); } public boolean isBlacklisted(String refreshToken) { String jti extractJti(refreshToken); return Boolean.TRUE.equals(redisTemplate.hasKey(PREFIX jti)); } private String extractJti(String token) { // 实际项目用 NimbusJwtDecoder 解析这里简化示意 return DigestUtils.sha256Hex(token); } }提示Apache Commons Codec 的DigestUtils需要额外引入依赖或者用MessageDigest自己实现。八、建议access_token 里不要放会变的权限只做身份标识。如果用户的角色调整后需要立即生效不要把角色写进 JWT。可以让网关透传用户 ID业务服务用 ID 去权限中心实时查。JWT 适合放“不变或短期可容忍延迟”的声明。网关只验签和过期具体权限判断下沉到业务服务。网关越轻越好。我见过有团队把 RBAC 表搬到网关里结果每次加接口都要改网关配置并重启完全丧失了微服务的独立性。公网客户端强制 PKCE生产密钥走 KMS定期轮换。clientSecret明文写配置文件是面试题级别的错误。用 AWS KMS / 阿里云 KMS / HashiCorp Vault 管理并且每季度轮换一次 RSA 密钥对旧公钥保留一段兼容期。九、结尾OAuth2.0 不是银弹JWT 也不是。但它们组合起来确实是目前微服务认证授权最稳妥的底盘方案。记住一句话把认证交给授权中心把验票交给网关把权限交给业务服务别把三件事揉成一团。下一篇 Day 48我们聊聊Seata 分布式事务实战AT 模式 / TCC 模式 / SAGA 该怎么选把微服务里最让人头疼的数据一致性问题也趟过去。