SpringCloud---安全(Security / OAuth2 / JWT)设计思想与深度解析

基于:Spring Cloud 2025.1.x(Oakwood)/ Spring Boot 4.0.x / Spring Security 7.0.x / Spring Authorization Server 1.5.x / Nimbus JOSE + JWT

本文面向微服务安全体系:先讲清 Spring Security(框架)、OAuth2(协议)、JWT(令牌格式)三者的分工与边界,再逐条拆解过滤器链、SecurityContext、PKCE、JWT 签名等核心机制,完整剖析「登录认证 → 令牌签发 → 网关透传 → 资源校验」主线链路,并给出可直接运行的认证中心 / 资源服务 / 网关三端配置示例(含 DDL 与 curl 验证)。


目录

  1. 核心理论:微服务安全的分层模型
    • [1.1 三个组件的定位与边界](#1.1 三个组件的定位与边界)
    • [1.2 核心概念速览](#1.2 核心概念速览)
    • [1.3 技术选型速查](#1.3 技术选型速查)
  2. 设计思想与核心机制 ★★★
    • [2.1 过滤器链:一切请求的入口](#2.1 过滤器链:一切请求的入口) ★★★
    • [2.2 SecurityContext:认证结果的载体与传播](#2.2 SecurityContext:认证结果的载体与传播) ★★★
    • [2.3 OAuth2 授权模式与 PKCE](#2.3 OAuth2 授权模式与 PKCE)
    • [2.4 JWT:无状态令牌的机制与取舍](#2.4 JWT:无状态令牌的机制与取舍)
    • [2.5 Spring Authorization Server 架构](#2.5 Spring Authorization Server 架构)
    • [2.6 资源服务器:Bearer Token 校验链路](#2.6 资源服务器:Bearer Token 校验链路)
  3. 主线流程剖析
    • [3.1 表单登录认证流程](#3.1 表单登录认证流程)
    • [3.2 授权码模式完整时序](#3.2 授权码模式完整时序) ★★★
    • [3.3 微服务资源访问流程](#3.3 微服务资源访问流程)
    • [3.4 网关 Token Relay](#3.4 网关 Token Relay)
  4. 三端最小可运行示例 ★★★
    • [4.1 认证中心:依赖与核心配置](#4.1 认证中心:依赖与核心配置)
    • [4.2 资源服务配置](#4.2 资源服务配置)
    • [4.3 网关 TokenRelay 配置](#4.3 网关 TokenRelay 配置)
    • [4.4 curl 调用链路验证](#4.4 curl 调用链路验证)
  5. 核心类关系图
  6. 扩展点与常见问题
    • [6.1 常见扩展点](#6.1 常见扩展点)
    • [6.2 常见问题 FAQ](#6.2 常见问题 FAQ)
    • [6.3 参考资料](#6.3 参考资料)

1. 核心理论:微服务安全的分层模型

1.1 三个组件的定位与边界

关键认知:Spring Security 是「框架」、OAuth2 是「协议」、JWT 是「数据格式」------三者根本不在同一个抽象层级上。大量教程混着讲导致「学了但搭不起来」;先把层级分清,整条链路就自然展开了。

组件 层面 解决的核心问题 类比
Spring Security 框架(工程实现) 认证与授权的落地:过滤器链、密码存储、上下文传播、方法级安全 房子的承重结构
OAuth2 协议(RFC 规范) 委托授权的标准化流程:谁把什么权限、委托给谁、如何收回 房屋租赁合同模板
JWT 数据格式(RFC 7519) 令牌的自包含表达:无状态携带身份信息 + 防篡改签名 合同上的防伪水印

微服务下的典型分工(本文贯穿始终的架构):

角色 组件 职责
认证中心 Spring Authorization Server 用户登录、签发/吊销令牌、暴露 JWKS 公钥
资源服务 Spring Security + oauth2ResourceServer 校验 JWT 签名、解析权限、本地授权
网关 Spring Cloud Gateway + TokenRelay 统一入口、路由、令牌透传(必要时粗粒度校验)
客户端 前端 SPA / 移动端 / 内部服务 申请令牌、携带令牌

1.2 核心概念速览

概念 含义 归属
Authentication 认证对象,含 principal(主体)、credentials(凭证)、authorities(权限) Spring Security
GrantedAuthority 权限项,如 ROLE_USERSCOPE_orders:read Spring Security
SecurityContext 认证结果容器,默认绑定当前线程(ThreadLocal) Spring Security
Access Token / Refresh Token 访问令牌(短时)/ 刷新令牌(长时,用于换新访问令牌) OAuth2
Scope 协议层权限粒度,如 orders:read,映射为 authority SCOPE_orders:read OAuth2
Authorization Code 授权码,换取令牌的一次性凭证 OAuth2
PKCE 授权码防截获增强(code_challenge / code_verifier) OAuth2
Bearer Token 「谁持有令牌谁就是主体」的传递约定 HTTP 认证方案
Claim JWT 中的声明(sub / exp / iss / aud ...) JWT

1.3 技术选型速查

场景 推荐方案
单体应用内部登录 Spring Security Form Login / HTTP Basic
微服务统一认证(用户 + 前端) Spring Authorization Server + 授权码模式 + PKCE
微服务间内部调用 OAuth2 Client Credentials(客户端凭证)
令牌格式 JWT(自包含,多服务免查库校验)
需要令牌可撤销 Opaque Token + Token Introspection(每次回认证中心查)
网关登录态 OAuth2 Client(oauth2Login)+ TokenRelay 透传

版本口径:Spring Cloud 2025.0.x(Northfields,对应 Boot 3.5.x)开源支持已于 2026-06-30 结束,新项目应直接基于 Spring Cloud 2025.1.x(Oakwood,对应 Boot 4.0.x、Security 7.0.x);仍在 Boot 3.5 维护线上的老项目对应 Spring Security 6.5.x + Authorization Server 1.5.x,本文示例代码两条线通用(坐标差异见 4.1)。


2. 设计思想与核心机制

2.1 过滤器链:一切请求的入口

Servlet 体系中,Spring Security 的全部能力由一条过滤器链 承载:DelegatingFilterProxy 把请求交给 FilterChainProxy,后者按 RequestMatcher 将请求分发给匹配的 SecurityFilterChain(责任链模式)。微服务里最常见的两类链:表单登录链(认证中心)与资源服务链(Bearer Token 校验)。

关键过滤器及其顺序(默认链摘选):

过滤器 职责
SecurityContextHolderFilter 从 SecurityContextRepository 恢复上下文
UsernamePasswordAuthenticationFilter 表单登录认证
BearerTokenAuthenticationFilter 解析 Authorization: Bearer
AnonymousAuthenticationFilter 未认证时挂匿名身份,避免下游空指针
ExceptionTranslationFilter 捕获认证/授权异常,转交 EntryPoint / DeniedHandler
AuthorizationFilter 按 AuthorizationManager 做授权裁决

关键认知 :整个安全体系是「过滤器链 + 异常中转 」的流水线------每个过滤器只做一件事,ExceptionTranslationFilter 兜底把认证/授权异常翻译成 401/403。理解这一点后,任何自定义安全逻辑本质都是「往链上插一个过滤器」。

2.2 SecurityContext:认证结果的载体与传播

认证成功的 Authentication 存入 SecurityContext,由 SecurityContextHolder 持有,默认策略 MODE_THREADLOCAL ------ 按线程隔离

微服务场景的传播问题:

传播边界 方案
同一请求内(Servlet) ThreadLocal 天然可见
跨线程(@Async、线程池) DelegatingSecurityContextExecutor 包装线程池
WebFlux 响应式 Reactor Context + SecurityWebFilterChain(Servlet 链不可用)
跨服务 ❌ 不传播上下文,只传 JWT 令牌本身(自包含)

关键认知 :微服务之间永远不共享 SecurityContext,共享的是令牌。JWT 的价值正在于此------任何服务拿到令牌即可独立完成「验签 + 提取身份」,无需回认证中心查询,这也是微服务选 JWT 而非 Session 的根本原因。

2.3 OAuth2 授权模式与 PKCE

模式 令牌授予方 适用场景 现状
授权码 + PKCE 用户本人 SPA / 移动端 / 普通 Web ✅ 首选
客户端凭证(Client Credentials) 客户端自己 服务间内部调用 ✅ 常用
刷新令牌(Refresh Token) 与上两者配合 访问令牌过期后续期 ✅ 常用
隐式(Implicit) 用户 ------ ❌ OAuth 2.1 已移除
密码(Password) 用户 ------ ❌ OAuth 2.1 已移除

PKCE 机制 (防授权码被拦截冒用):客户端生成随机 code_verifier,取 code_challenge = BASE64URL(SHA256(code_verifier)) 随授权请求发送;换令牌时必须提交原 code_verifier,认证中心验算匹配才发令牌。拦截者只有 code 没有 verifier,无法换取令牌。公共客户端(SPA/移动端,无法保密密钥)必须 启用 PKCE(ClientSettings.requireProofKey(true))。

2.4 JWT:无状态令牌的机制与取舍

JWT = Base64Url(header) . Base64Url(payload) . signature,三段点分隔。解码后的典型结构:

json 复制代码
// header
{"alg": "RS256", "kid": "key-1", "typ": "JWT"}
// payload(claims)
{"sub": "alice", "iss": "http://localhost:9000", "aud": "orders-service",
 "iat": 1756720000, "exp": 1756721800, "jti": "9a1c...", "scope": ["orders:read", "openid"]}
部分 内容
header 签名算法 alg、密钥标识 kid
payload 声明:sub 主体、iss 签发方、aud 接收方、exp 过期、scope 权限、jti 唯一 ID
signature 按 header 声明的算法对前两段签名

签名算法选择

算法 密钥 微服务适用性
HS256 对称密钥 ❌ 签名与验签同钥,密钥必须下发给所有服务,泄漏即全线沦陷
RS256 / ES256 非对称密钥对 ✅ 认证中心持私钥签发,各服务只持公钥验签
优点 缺点
自包含、无状态,资源服务零存储、零远程调用 ⚠️ 令牌发出即无法「撤销」,只能等过期
公钥验签,天然支持多服务横向扩展 ⚠️ payload 仅编码不加密,禁止放敏感信息
天然携带过期时间,可做无状态续期 ⚠️ 体积比 opaque 令牌大(HTTP 头约 1KB 级)

关键认知:JWT 的「无状态」是以「无法撤销」为代价的。工程上必须配套:短有效期访问令牌(如 30 分钟)+ 刷新令牌续期 + 必要时 jti 黑名单兜底(见 FAQ Q2)。

2.5 Spring Authorization Server 架构

Spring 官方 OAuth 2.1 + OIDC 1.0 授权服务器实现(替代已废弃的旧 spring-security-oauth2 项目)。

核心组件:

组件 职责
RegisteredClientRepository 客户端注册信息存储(内存 / JDBC)
AuthorizationService 授权记录(授权码、令牌)存储
AuthorizationConsentService 用户授权同意记录
OAuth2TokenGenerator 令牌生成(JWT 由 JwtEncoder 私钥签名)
JwtEncoder / JwtDecoder JWT 编解码(Nimbus 实现)
JWKSource 签名密钥源,公钥经 JWKS 端点对外发布

内置端点:

端点 用途
/oauth2/authorize 发起授权(跳登录 + 授权确认页)
/oauth2/token 换取 / 刷新令牌
/oauth2/jwks 发布公钥 JWKS(资源服务验签用)
/oauth2/introspect / /oauth2/revoke 令牌内省 / 吊销
/.well-known/openid-configuration OIDC 发现端点(客户端自动配置用)

2.6 资源服务器:Bearer Token 校验链路

oauth2ResourceServer(jwt) 自动装配一条校验链:

  1. BearerTokenAuthenticationFilter 提取 Authorization: Bearer 令牌;
  2. JwtAuthenticationProvider 委托 JwtDecoderNimbusJwtDecoder)验签解码;
  3. JwtAuthenticationConverter 把 JWT 转成 JwtAuthenticationToken(scope → SCOPE_xxx authority);
  4. 校验通过 → 写入 SecurityContext → AuthorizationFilter 授权裁决。

多实例共享公钥:配置 jwk-set-uri 指向认证中心 JWKS 端点即可,密钥轮换后新 kid 自动生效。


3. 主线流程剖析

3.1 表单登录认证流程

#mermaid-svg-H3n9caoQ6jWyRQQV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-H3n9caoQ6jWyRQQV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-H3n9caoQ6jWyRQQV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-H3n9caoQ6jWyRQQV .error-icon{fill:#552222;}#mermaid-svg-H3n9caoQ6jWyRQQV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-H3n9caoQ6jWyRQQV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-H3n9caoQ6jWyRQQV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-H3n9caoQ6jWyRQQV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-H3n9caoQ6jWyRQQV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-H3n9caoQ6jWyRQQV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-H3n9caoQ6jWyRQQV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-H3n9caoQ6jWyRQQV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-H3n9caoQ6jWyRQQV .marker.cross{stroke:#333333;}#mermaid-svg-H3n9caoQ6jWyRQQV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-H3n9caoQ6jWyRQQV p{margin:0;}#mermaid-svg-H3n9caoQ6jWyRQQV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-H3n9caoQ6jWyRQQV .cluster-label text{fill:#333;}#mermaid-svg-H3n9caoQ6jWyRQQV .cluster-label span{color:#333;}#mermaid-svg-H3n9caoQ6jWyRQQV .cluster-label span p{background-color:transparent;}#mermaid-svg-H3n9caoQ6jWyRQQV .label text,#mermaid-svg-H3n9caoQ6jWyRQQV span{fill:#333;color:#333;}#mermaid-svg-H3n9caoQ6jWyRQQV .node rect,#mermaid-svg-H3n9caoQ6jWyRQQV .node circle,#mermaid-svg-H3n9caoQ6jWyRQQV .node ellipse,#mermaid-svg-H3n9caoQ6jWyRQQV .node polygon,#mermaid-svg-H3n9caoQ6jWyRQQV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-H3n9caoQ6jWyRQQV .rough-node .label text,#mermaid-svg-H3n9caoQ6jWyRQQV .node .label text,#mermaid-svg-H3n9caoQ6jWyRQQV .image-shape .label,#mermaid-svg-H3n9caoQ6jWyRQQV .icon-shape .label{text-anchor:middle;}#mermaid-svg-H3n9caoQ6jWyRQQV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-H3n9caoQ6jWyRQQV .rough-node .label,#mermaid-svg-H3n9caoQ6jWyRQQV .node .label,#mermaid-svg-H3n9caoQ6jWyRQQV .image-shape .label,#mermaid-svg-H3n9caoQ6jWyRQQV .icon-shape .label{text-align:center;}#mermaid-svg-H3n9caoQ6jWyRQQV .node.clickable{cursor:pointer;}#mermaid-svg-H3n9caoQ6jWyRQQV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-H3n9caoQ6jWyRQQV .arrowheadPath{fill:#333333;}#mermaid-svg-H3n9caoQ6jWyRQQV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-H3n9caoQ6jWyRQQV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-H3n9caoQ6jWyRQQV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-H3n9caoQ6jWyRQQV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-H3n9caoQ6jWyRQQV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-H3n9caoQ6jWyRQQV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-H3n9caoQ6jWyRQQV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-H3n9caoQ6jWyRQQV .cluster text{fill:#333;}#mermaid-svg-H3n9caoQ6jWyRQQV .cluster span{color:#333;}#mermaid-svg-H3n9caoQ6jWyRQQV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-H3n9caoQ6jWyRQQV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-H3n9caoQ6jWyRQQV rect.text{fill:none;stroke-width:0;}#mermaid-svg-H3n9caoQ6jWyRQQV .icon-shape,#mermaid-svg-H3n9caoQ6jWyRQQV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-H3n9caoQ6jWyRQQV .icon-shape p,#mermaid-svg-H3n9caoQ6jWyRQQV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-H3n9caoQ6jWyRQQV .icon-shape .label rect,#mermaid-svg-H3n9caoQ6jWyRQQV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-H3n9caoQ6jWyRQQV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-H3n9caoQ6jWyRQQV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-H3n9caoQ6jWyRQQV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 失败
成功
POST /login 携带用户名密码
FilterChainProxy 匹配 SecurityFilterChain
UsernamePasswordAuthenticationFilter

提取用户名密码封装 Authentication
ProviderManager 遍历 Provider
DaoAuthenticationProvider
UserDetailsService 按用户名查询
BCryptPasswordEncoder

密码比对
BadCredentialsException
ExceptionTranslationFilter
AuthenticationEntryPoint

返回 401 / 重定向登录页
构造已认证 Authentication

写入 SecurityContextHolder
AuthenticationSuccessHandler 回调
请求继续 → Controller

一句话概括:认证的本质是把「凭证(credentials)」换成「受信主体(authenticated principal)」并放入当前线程上下文------后续所有环节(授权、@PreAuthorize、审计)都围绕这个容器展开。

3.2 授权码模式完整时序

资源服务 认证中心(Authorization Server) 前端 SPA 用户 资源服务 认证中心(Authorization Server) 前端 SPA 用户 #mermaid-svg-hEVjx7PJ7y2USSPj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hEVjx7PJ7y2USSPj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hEVjx7PJ7y2USSPj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hEVjx7PJ7y2USSPj .error-icon{fill:#552222;}#mermaid-svg-hEVjx7PJ7y2USSPj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hEVjx7PJ7y2USSPj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hEVjx7PJ7y2USSPj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hEVjx7PJ7y2USSPj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hEVjx7PJ7y2USSPj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hEVjx7PJ7y2USSPj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hEVjx7PJ7y2USSPj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hEVjx7PJ7y2USSPj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hEVjx7PJ7y2USSPj .marker.cross{stroke:#333333;}#mermaid-svg-hEVjx7PJ7y2USSPj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hEVjx7PJ7y2USSPj p{margin:0;}#mermaid-svg-hEVjx7PJ7y2USSPj .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-hEVjx7PJ7y2USSPj text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-hEVjx7PJ7y2USSPj .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-hEVjx7PJ7y2USSPj .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-hEVjx7PJ7y2USSPj .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-hEVjx7PJ7y2USSPj .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-hEVjx7PJ7y2USSPj #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-hEVjx7PJ7y2USSPj .sequenceNumber{fill:white;}#mermaid-svg-hEVjx7PJ7y2USSPj #sequencenumber{fill:#333;}#mermaid-svg-hEVjx7PJ7y2USSPj #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-hEVjx7PJ7y2USSPj .messageText{fill:#333;stroke:none;}#mermaid-svg-hEVjx7PJ7y2USSPj .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-hEVjx7PJ7y2USSPj .labelText,#mermaid-svg-hEVjx7PJ7y2USSPj .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-hEVjx7PJ7y2USSPj .loopText,#mermaid-svg-hEVjx7PJ7y2USSPj .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-hEVjx7PJ7y2USSPj .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-hEVjx7PJ7y2USSPj .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-hEVjx7PJ7y2USSPj .noteText,#mermaid-svg-hEVjx7PJ7y2USSPj .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-hEVjx7PJ7y2USSPj .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-hEVjx7PJ7y2USSPj .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-hEVjx7PJ7y2USSPj .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-hEVjx7PJ7y2USSPj .actorPopupMenu{position:absolute;}#mermaid-svg-hEVjx7PJ7y2USSPj .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-hEVjx7PJ7y2USSPj .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-hEVjx7PJ7y2USSPj .actor-man circle,#mermaid-svg-hEVjx7PJ7y2USSPj line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-hEVjx7PJ7y2USSPj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击登录 GET /oauth2/authorize response_type=code、client_id、redirect_uri、code_challenge、state 校验 client 注册信息与 redirect_uri 白名单 302 跳登录页(Session 记录原始请求) 提交账号密码(表单登录,见 3.1) 认证成功,生成授权确认页 点击同意授权 302 重定向 redirect_uri?code=xxx、state=xxx POST /oauth2/token grant_type=authorization_code、code、code_verifier、redirect_uri 校验 code(一次性)+ PKCE 验算 返回 access_token / refresh_token / id_token GET /api/orders Authorization: Bearer access_token 取 JWKS 公钥验签,解析 scope 并授权 返回资源数据

关键认知 :授权码是「两次跳转防泄漏」设计------code 出现在 URL 上(可能被日志/Referer 泄漏),但只能一次性使用且有 PKCE 双保险;真正的令牌只通过后端 POST 通道(或前端内存)传递,从不进 URL。

3.3 微服务资源访问流程

资源服务侧一个受保护请求的完整经过:
#mermaid-svg-S9kZ45NbQfUQvgjU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-S9kZ45NbQfUQvgjU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-S9kZ45NbQfUQvgjU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-S9kZ45NbQfUQvgjU .error-icon{fill:#552222;}#mermaid-svg-S9kZ45NbQfUQvgjU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-S9kZ45NbQfUQvgjU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-S9kZ45NbQfUQvgjU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-S9kZ45NbQfUQvgjU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-S9kZ45NbQfUQvgjU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-S9kZ45NbQfUQvgjU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-S9kZ45NbQfUQvgjU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-S9kZ45NbQfUQvgjU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-S9kZ45NbQfUQvgjU .marker.cross{stroke:#333333;}#mermaid-svg-S9kZ45NbQfUQvgjU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-S9kZ45NbQfUQvgjU p{margin:0;}#mermaid-svg-S9kZ45NbQfUQvgjU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-S9kZ45NbQfUQvgjU .cluster-label text{fill:#333;}#mermaid-svg-S9kZ45NbQfUQvgjU .cluster-label span{color:#333;}#mermaid-svg-S9kZ45NbQfUQvgjU .cluster-label span p{background-color:transparent;}#mermaid-svg-S9kZ45NbQfUQvgjU .label text,#mermaid-svg-S9kZ45NbQfUQvgjU span{fill:#333;color:#333;}#mermaid-svg-S9kZ45NbQfUQvgjU .node rect,#mermaid-svg-S9kZ45NbQfUQvgjU .node circle,#mermaid-svg-S9kZ45NbQfUQvgjU .node ellipse,#mermaid-svg-S9kZ45NbQfUQvgjU .node polygon,#mermaid-svg-S9kZ45NbQfUQvgjU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-S9kZ45NbQfUQvgjU .rough-node .label text,#mermaid-svg-S9kZ45NbQfUQvgjU .node .label text,#mermaid-svg-S9kZ45NbQfUQvgjU .image-shape .label,#mermaid-svg-S9kZ45NbQfUQvgjU .icon-shape .label{text-anchor:middle;}#mermaid-svg-S9kZ45NbQfUQvgjU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-S9kZ45NbQfUQvgjU .rough-node .label,#mermaid-svg-S9kZ45NbQfUQvgjU .node .label,#mermaid-svg-S9kZ45NbQfUQvgjU .image-shape .label,#mermaid-svg-S9kZ45NbQfUQvgjU .icon-shape .label{text-align:center;}#mermaid-svg-S9kZ45NbQfUQvgjU .node.clickable{cursor:pointer;}#mermaid-svg-S9kZ45NbQfUQvgjU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-S9kZ45NbQfUQvgjU .arrowheadPath{fill:#333333;}#mermaid-svg-S9kZ45NbQfUQvgjU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-S9kZ45NbQfUQvgjU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-S9kZ45NbQfUQvgjU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-S9kZ45NbQfUQvgjU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-S9kZ45NbQfUQvgjU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-S9kZ45NbQfUQvgjU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-S9kZ45NbQfUQvgjU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-S9kZ45NbQfUQvgjU .cluster text{fill:#333;}#mermaid-svg-S9kZ45NbQfUQvgjU .cluster span{color:#333;}#mermaid-svg-S9kZ45NbQfUQvgjU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-S9kZ45NbQfUQvgjU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-S9kZ45NbQfUQvgjU rect.text{fill:none;stroke-width:0;}#mermaid-svg-S9kZ45NbQfUQvgjU .icon-shape,#mermaid-svg-S9kZ45NbQfUQvgjU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-S9kZ45NbQfUQvgjU .icon-shape p,#mermaid-svg-S9kZ45NbQfUQvgjU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-S9kZ45NbQfUQvgjU .icon-shape .label rect,#mermaid-svg-S9kZ45NbQfUQvgjU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-S9kZ45NbQfUQvgjU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-S9kZ45NbQfUQvgjU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-S9kZ45NbQfUQvgjU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 签名无效/过期
通过
无权限
有权限
GET /api/orders

Authorization: Bearer xxx
BearerTokenAuthenticationFilter

提取令牌
JwtDecoder 验签
ExceptionTranslationFilter → 401
JwtAuthenticationConverter

scope → authorities
写入 SecurityContext
AuthorizationFilter 授权
AccessDeniedHandler → 403
Controller 方法执行

3.4 网关 Token Relay

网关自身以 OAuth2 Client 身份(oauth2Login)持有登录会话,转发下游时由 TokenRelayGatewayFilterFactory 自动附上 Authorization: Bearer <access_token> 头,下游服务无需感知网关登录细节。网关的职责边界:路由 + 粗粒度控制;细粒度授权仍由各资源服务完成(网关不可替代服务端校验,详见 FAQ Q5)。网关实现细节见 \[Spring-Cloud-Gateway-知识点]。


4. 三端最小可运行示例

⚠️ Boot 4 依赖坐标变更 :Boot 4.0 起 OAuth2 相关 starter 统一加了 security- 前缀,旧坐标标记为 deprecated 仍可用(Classic Starter POMs),新项目用新坐标。

4.1 认证中心:依赖与核心配置

xml 复制代码
<!-- 认证中心(Boot 4 新坐标;Boot 3.5 对应 spring-boot-starter-oauth2-authorization-server) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>   <!-- JDBC 存储客户端/授权记录 -->
</dependency>
yaml 复制代码
# application.yml(认证中心,端口 9000)
server:
  port: 9000
spring:
  datasource:          # 存储客户端注册信息与授权记录
    url: jdbc:mysql://localhost:3306/auth_db?useUnicode=true&characterEncoding=utf8
    username: root
    password: root
java 复制代码
@Configuration
@EnableWebSecurity
public class AuthServerConfig {

    /** 链 1:授权服务器端点(/oauth2/authorize、/oauth2/token、/oauth2/jwks ...) */
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
                OAuth2AuthorizationServerConfigurer.authorizationServer();
        http.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
            .with(authorizationServerConfigurer, c -> c.oidc(Customizer.withDefaults())) // 启用 OIDC
            .authorizeHttpRequests(a -> a.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults());   // 登录页交给表单登录链
        return http.build();
    }

    /** 链 2:其余请求(登录页、授权确认页) */
    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }

    /** 用户源(示例用内存;生产替换为查库的 UserDetailsService) */
    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder encoder) {
        return new InMemoryUserDetailsManager(
                User.withUsername("alice")
                    .password(encoder.encode("secret"))
                    .roles("USER")
                    .build());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /** RSA 签名密钥对:私钥用于签发 JWT,公钥经 /oauth2/jwks 对外发布 */
    @Bean
    public JWKSource<SecurityContext> jwkSource() throws Exception {
        RSAKey rsaKey = new RSAKeyGenerator(2048).keyID("key-1").generate();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, context) -> jwkSelector.select(jwkSet);
    }

    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    /** 客户端注册信息走 JDBC 存储 */
    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
        return new JdbcRegisteredClientRepository(jdbcTemplate);
    }

    /** 启动时注册公共客户端(SPA):授权码 + PKCE,无 clientSecret */
    @Bean
    public ApplicationRunner registerClients(RegisteredClientRepository repo) {
        return args -> {
            if (repo.findByClientId("web-app") != null) {
                return;
            }
            RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("web-app")
                .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)   // 公共客户端:无密钥
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("http://localhost:3000/callback")
                .scope("orders:read")
                .scope("openid")
                .clientSettings(ClientSettings.builder()
                    .requireProofKey(true)          // 强制 PKCE
                    .requireAuthorizationConsent(true)   // 显示授权确认页
                    .build())
                .tokenSettings(TokenSettings.builder()
                    .accessTokenTimeToLive(Duration.ofMinutes(30))  // 短时访问令牌
                    .refreshTokenTimeToLive(Duration.ofDays(7))
                    .build())
                .build();
            repo.save(client);
        };
    }
}

JDBC 存储所需表结构(JdbcRegisteredClientRepository 依赖):

sql 复制代码
-- oauth2_registered_client:客户端注册信息
CREATE TABLE oauth2_registered_client (
    id                            varchar(100)  NOT NULL,
    client_id                     varchar(100)  NOT NULL,
    client_id_issued_at           timestamp     DEFAULT CURRENT_TIMESTAMP NOT NULL,
    client_secret                 varchar(200)  DEFAULT NULL,
    client_secret_expires_at      timestamp     DEFAULT NULL,
    client_name                   varchar(200)  NOT NULL,
    client_authentication_methods varchar(1000) NOT NULL,
    authorization_grant_types     varchar(1000) NOT NULL,
    redirect_uris                 varchar(1000) DEFAULT NULL,
    post_logout_redirect_uris     varchar(1000) DEFAULT NULL,
    scopes                        varchar(1000) NOT NULL,
    client_settings               varchar(2000) NOT NULL,
    token_settings                varchar(2000) NOT NULL,
    PRIMARY KEY (id)
);

-- oauth2_authorization_consent:用户授权同意记录
CREATE TABLE oauth2_authorization_consent (
    registered_client_id varchar(100)  NOT NULL,
    principal_name       varchar(200)  NOT NULL,
    authorities          varchar(1000) NOT NULL,
    PRIMARY KEY (registered_client_id, principal_name)
);

oauth2_authorization(授权码/令牌记录,列较多)无需手写:从 spring-security-oauth2-authorization-server 的 jar 包内复制官方 schema 即可,路径为 org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql

4.2 资源服务配置

xml 复制代码
<!-- 资源服务(Boot 4 新坐标;Boot 3.5 对应 spring-boot-starter-oauth2-resource-server) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
yaml 复制代码
# application.yml(订单服务,端口 8081)
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # 两种方式二选一:
          issuer-uri: http://localhost:9000     # 按 issuer 走 OIDC 发现自动拉 jwks
          # jwk-set-uri: http://localhost:9000/oauth2/jwks   # 直接指定 JWKS 端点
java 复制代码
@Configuration
@EnableWebSecurity
@EnableMethodSecurity          // 开启 @PreAuthorize 方法级授权
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain resourceSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(a -> a
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated())
            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
        return http.build();
    }
}

// Controller 方法级授权:JWT scope orders:read → authority SCOPE_orders:read
@RestController
public class OrderController {
    @GetMapping("/api/orders")
    @PreAuthorize("hasAuthority('SCOPE_orders:read')")
    public List<String> orders(JwtAuthenticationToken auth) {
        // auth.getName() → JWT sub;auth.getAuthorities() → SCOPE_xxx
        return List.of("order-1", "order-2");
    }
}

4.3 网关 TokenRelay 配置

xml 复制代码
<!-- 网关(Boot 4 新坐标;Boot 3.5 对应 spring-boot-starter-oauth2-client) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
java 复制代码
// 网关是 WebFlux 应用:SecurityWebFilterChain,而非 Servlet 的 SecurityFilterChain
@Configuration
@EnableWebFluxSecurity
public class GatewaySecurityConfig {
    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        http.authorizeExchange(ex -> ex.anyExchange().authenticated())
            .oauth2Login(Customizer.withDefaults());   // 网关作为 OAuth2 Client 登录
        return http.build();
    }
}
yaml 复制代码
# application.yml(网关,端口 8080)
spring:
  cloud:
    gateway:
      routes:
        - id: orders
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
          filters:
            - TokenRelay=          # 转发下游时自动附加 Bearer 头
  security:
    oauth2:
      client:
        registration:
          gateway:
            provider: auth-server
            client-id: gateway
            client-secret: gateway-secret   # 机密客户端(后端应用可保密密钥)
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope: orders:read, openid
        provider:
          auth-server:
            issuer-uri: http://localhost:9000

4.4 curl 调用链路验证

bash 复制代码
# 0. 生成 PKCE 参数(前端通常由 JS 库完成,这里用 openssl 演示原理)
CODE_VERIFIER=$(openssl rand -base64 48 | tr -d '\n' | tr '+/' '-_' | tr -d '=')
CODE_CHALLENGE=$(printf %s "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')

# 1. 发起授权(浏览器打开,认证中心会 302 到登录页)
curl -i "http://localhost:9000/oauth2/authorize?response_type=code&client_id=web-app&redirect_uri=http://localhost:3000/callback&scope=orders:read&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=xyz"
# → 浏览器完成登录与授权确认后,302 回 http://localhost:3000/callback?code=xxx&state=xyz

# 2. 用 code 换令牌(后端通道,公共客户端不带 client_secret)
curl -s -X POST http://localhost:9000/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code&code=<上一步的code>&code_verifier=$CODE_VERIFIER&redirect_uri=http://localhost:3000/callback&client_id=web-app"
# → {"access_token":"eyJ...","refresh_token":"...","id_token":"...","expires_in":1800}

# 3. 携带令牌经网关访问资源(网关 TokenRelay 透传 → 订单服务验签)
curl -s http://localhost:8080/api/orders -H "Authorization: Bearer <access_token>"
# → ["order-1","order-2"]

# 4. 取公钥验签(可选):确认签名合法、kid 匹配
curl -s http://localhost:9000/oauth2/jwks | jq '.keys[0] | {kid, kty, alg}'
# → {"kid": "key-1", "kty": "RSA", "alg": "RS256"}

5. 核心类关系图

#mermaid-svg-mGRy76BUkwrEvkDV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-mGRy76BUkwrEvkDV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-mGRy76BUkwrEvkDV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-mGRy76BUkwrEvkDV .error-icon{fill:#552222;}#mermaid-svg-mGRy76BUkwrEvkDV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-mGRy76BUkwrEvkDV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-mGRy76BUkwrEvkDV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-mGRy76BUkwrEvkDV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-mGRy76BUkwrEvkDV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-mGRy76BUkwrEvkDV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-mGRy76BUkwrEvkDV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-mGRy76BUkwrEvkDV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-mGRy76BUkwrEvkDV .marker.cross{stroke:#333333;}#mermaid-svg-mGRy76BUkwrEvkDV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-mGRy76BUkwrEvkDV p{margin:0;}#mermaid-svg-mGRy76BUkwrEvkDV g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-mGRy76BUkwrEvkDV g.classGroup text .title{font-weight:bolder;}#mermaid-svg-mGRy76BUkwrEvkDV .cluster-label text{fill:#333;}#mermaid-svg-mGRy76BUkwrEvkDV .cluster-label span{color:#333;}#mermaid-svg-mGRy76BUkwrEvkDV .cluster-label span p{background-color:transparent;}#mermaid-svg-mGRy76BUkwrEvkDV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-mGRy76BUkwrEvkDV .cluster text{fill:#333;}#mermaid-svg-mGRy76BUkwrEvkDV .cluster span{color:#333;}#mermaid-svg-mGRy76BUkwrEvkDV .nodeLabel,#mermaid-svg-mGRy76BUkwrEvkDV .edgeLabel{color:#131300;}#mermaid-svg-mGRy76BUkwrEvkDV .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-mGRy76BUkwrEvkDV .label text{fill:#131300;}#mermaid-svg-mGRy76BUkwrEvkDV .labelBkg{background:#ECECFF;}#mermaid-svg-mGRy76BUkwrEvkDV .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-mGRy76BUkwrEvkDV .classTitle{font-weight:bolder;}#mermaid-svg-mGRy76BUkwrEvkDV .node rect,#mermaid-svg-mGRy76BUkwrEvkDV .node circle,#mermaid-svg-mGRy76BUkwrEvkDV .node ellipse,#mermaid-svg-mGRy76BUkwrEvkDV .node polygon,#mermaid-svg-mGRy76BUkwrEvkDV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-mGRy76BUkwrEvkDV .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-mGRy76BUkwrEvkDV g.clickable{cursor:pointer;}#mermaid-svg-mGRy76BUkwrEvkDV g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-mGRy76BUkwrEvkDV g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-mGRy76BUkwrEvkDV .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-mGRy76BUkwrEvkDV .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-mGRy76BUkwrEvkDV .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-mGRy76BUkwrEvkDV .dashed-line{stroke-dasharray:3;}#mermaid-svg-mGRy76BUkwrEvkDV .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-mGRy76BUkwrEvkDV #compositionStart,#mermaid-svg-mGRy76BUkwrEvkDV .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mGRy76BUkwrEvkDV #compositionEnd,#mermaid-svg-mGRy76BUkwrEvkDV .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mGRy76BUkwrEvkDV #dependencyStart,#mermaid-svg-mGRy76BUkwrEvkDV .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mGRy76BUkwrEvkDV #dependencyStart,#mermaid-svg-mGRy76BUkwrEvkDV .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mGRy76BUkwrEvkDV #extensionStart,#mermaid-svg-mGRy76BUkwrEvkDV .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mGRy76BUkwrEvkDV #extensionEnd,#mermaid-svg-mGRy76BUkwrEvkDV .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mGRy76BUkwrEvkDV #aggregationStart,#mermaid-svg-mGRy76BUkwrEvkDV .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mGRy76BUkwrEvkDV #aggregationEnd,#mermaid-svg-mGRy76BUkwrEvkDV .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mGRy76BUkwrEvkDV #lollipopStart,#mermaid-svg-mGRy76BUkwrEvkDV .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mGRy76BUkwrEvkDV #lollipopEnd,#mermaid-svg-mGRy76BUkwrEvkDV .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-mGRy76BUkwrEvkDV .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-mGRy76BUkwrEvkDV .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-mGRy76BUkwrEvkDV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-mGRy76BUkwrEvkDV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-mGRy76BUkwrEvkDV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 表单登录链
资源服务链
委托认证
密码认证
查询用户
委托认证
验签解码
JWT→Authentication
写入上下文
写入上下文
SecurityFilterChain
+matches(HttpServletRequest) : boolean
+getFilters() : List<Filter>
UsernamePasswordAuthenticationFilter
+attemptAuthentication(req, res) : Authentication
BearerTokenAuthenticationFilter
+doFilterInternal(req, res, chain) : void
ProviderManager
-providers List<AuthenticationProvider>
+authenticate(Authentication) : Authentication
DaoAuthenticationProvider
+additionalAuthenticationChecks(user, auth) : void
JwtAuthenticationProvider
+authenticate(Authentication) : Authentication
NimbusJwtDecoder
+decode(String) : Jwt
JwtAuthenticationConverter
+convert(Jwt) : AbstractAuthenticationToken
UserDetailsService
+loadUserByUsername(String) : UserDetails
SecurityContextHolder
+getContext() : SecurityContext


6. 扩展点与常见问题

6.1 常见扩展点

扩展点 场景 方式
AuthenticationProvider 对接自研用户中心 自定义实现并注册进 ProviderManager
UserDetailsService 用户来自数据库/远程接口 自定义 Bean 即可
JwtAuthenticationConverter JWT 声明 → 权限映射(自定义 claim) 自定义转换器替换默认
JwtDecoder 自定义密钥源 / 多租户验签 自定义 Bean
AuthenticationEntryPoint / AccessDeniedHandler 401/403 统一 JSON 返回体 实现接口并配置
OncePerRequestFilter 自定义前置逻辑(如令牌黑名单) 继承并 addFilterBefore

自定义权限映射(把 JWT 自定义 claim 转成 authorities)示例:

java 复制代码
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
    scopes.setAuthorityPrefix("SCOPE_");   // scope orders:read → SCOPE_orders:read
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(scopes);
    return converter;
}

6.2 常见问题 FAQ

Q1:401 与 403 有什么区别?

401 = 未认证(不知道你是谁)→ 由 AuthenticationEntryPoint 兜底;403 = 已认证但无权限(知道你是谁,但你不配)→ 由 AccessDeniedHandler 兜底。令牌缺失/无效/过期都是 401,令牌有效但 scope 不够是 403。

Q2:JWT 无法撤销,用户注销/改密怎么办?

组合拳:短有效期 access token(30 分钟级)+ refresh token 轮换(每次刷新发新 token,旧 token 一次性作废)+ 认证中心维护 jti 黑名单(Redis,TTL = 剩余有效期),资源服务校验时查询黑名单兜底。业务上「改密/封禁」场景通常接受「最多等一个访问令牌周期」。

Q3:资源服务如何动态更新公钥?密钥轮换会中断服务吗?

通过 jwk-set-uri 指向认证中心 JWKS 端点,Nimbus 默认每 5 分钟拉取刷新;认证中心轮换密钥时签发新 kid 的 JWT,资源服务按 header 中 kid 匹配新公钥,旧令牌在其有效期内仍可用,实现无感轮换。

Q4:为什么密码必须用 BCrypt?

BCrypt 自带盐、可调计算成本(cost 参数)、对 GPU 暴力破解不友好;MD5/SHA 等快哈希在现代硬件上每秒可试数十亿次,形同虚设。存储必须是单向哈希,且每用户独立盐------BCrypt 一个算法同时满足。

Q5:网关已经校验了 JWT,微服务还要再验签吗?

要。网关校验只能挡住外部流量;服务间内网调用、绕过网关的直连流量(如负载均衡直连、内部任务调用)不受保护。纵深防御原则:网关做粗粒度路由控制,每个服务独立验签 + 授权,这是微服务安全的标准实践。

Q6:微服务里 CSRF 还要管吗?

基于 Bearer Token 的无状态 API 天然免疫 CSRF(令牌不随 Cookie 自动携带);只有仍使用 Cookie 会话的端点(如认证中心的表单登录、网关的 oauth2Login)需要 CSRF 防护。Spring Security 默认对登录端点开启,自定义时注意别把登录链的 CSRF 关掉。

Q7:JWT 的 payload 可以放敏感信息吗?

❌ 不可以。JWT 只做签名不加密(JWE 才加密,微服务场景极少用),任何人拿到令牌都能 base64 解码看内容。身份证号、手机号等敏感信息应只存 sub(用户 ID),详情由资源服务自行查库。

Q8:生产环境版本安全怎么跟?

2026-04 公告(AV26-373)指出 Spring Security 5.7~7.0 多个版本线、Authorization Server 1.3.0~1.5.6 存在需修复漏洞;2026-06 发布的 Security 6.5.11 / 7.0.6、Authorization Server 1.5.8 修复了 CVE-2026-41008(request_uri 开放重定向)。生产环境必须跟随官方安全补丁线,不要长期停留在旧 patch 版本。

6.3 参考资料

相关推荐
骇客野人37 分钟前
SpringBoot+SpringCloud高并发系统设计、搭建与落地实施方案
java·spring boot·spring cloud
lightningyang1 小时前
第一届全国技能大赛网络安全(模块A)-密码安全策略配置
安全·web安全·天枢一体化虚拟仿真平台·全国节能大赛网络安全赛项
Steve__evetS1 小时前
我的开源项目:python依赖安全修复助手
python·安全·agent
2601_967326831 小时前
无线扩音器哪家质量好?无线扩音器哪个牌子最好最安全?汇总对比
安全
灰灰20261 小时前
等保、密评、分保、关保、风评、PIA:政务六大安全合规体系一张表全部理清
安全·政务·等级保护·政务安全合规·分级保护·个人信息保护评估
Privasa-隐私实验室1 小时前
跨端技术选型|Flutter搭建隐私加密App,安卓与iOS双端差异适配实战
android·笔记·安全·flutter·ios·隐私安全·aes-256
adinnet20262 小时前
安全生产巡检:把隐患和处置经验沉淀下来
安全
随遇而安zx2 小时前
SpringCloud---Gateway vs Netflix Zuul 网关对比深度解析
spring·spring cloud·gateway