阶段四:从单体无状态认证到微服务统一身份治理(基于 OAuth2 + BFF 的单点登录(SSO)架构演进)
承接阶段三 JWT + Redis 混合无状态认证方案,在解决单服务跨域、多终端适配、令牌生命周期管控的基础上,针对微服务架构下认证孤岛与前端安全耦合问题,完成从单体无状态认证到微服务统一认证的核心升级。通过引入 OAuth2 行业标准协议与 BFF(Backend for Frontend)架构模式,实现认证职责分离、集中式身份管理与单点登录能力,兼顾系统安全性、可扩展性、多端一致性,构建标准化、可落地、可演进的企业级微服务认证体系,为大规模分布式系统下的统一身份治理奠定架构基石。
一、单服务无状态架构的微服务场景局限性:
阶段三实现的 JWT + Redis 混合无状态认证,仅解决了单服务内的跨域适配、令牌管控与无感刷新问题,未改变各服务独立认证、令牌暴露在前端的核心架构;而直接将阶段三方案复制到每个微服务,虽可实现局部认证,但无法应对微服务集群下的统一身份管理挑战。两种模式均无法满足多服务协作、集中安全管控的生产级要求,核心缺陷如下:
1、微服务认证孤岛,缺乏单点登录(SSO)能力:
每个微服务独立签发和验证令牌,用户访问不同服务(如 clientA 和 clientB)需重复登录,无法共享一次认证结果;且各服务令牌密钥管理分散,导致用户身份割裂、体验极差,违背微服务“高内聚、松耦合”的设计初衷。
2、前端安全耦合与暴露面过大:
阶段三要求前端直接持有 JWT 并存储于本地存储(LocalStorage/SessionStorage),面临严重的 XSS 窃取风险;且前端需自行实现令牌携带、过期判断、无感刷新等复杂逻辑,安全边界模糊,将认证安全部分责任转嫁给了前端,攻击面大幅扩大。
3、客户端凭据无法安全存储:
在 OAuth2 授权码流程中,若前端直接参与令牌交换,则客户端密钥(Client Secret)面临硬编码泄露或网络截获风险,无法满足 OAuth2 规范中“公开客户端”与“机密客户端”的职责区分,存在严重安全隐患。
4、多端接入成本居高不下:
不同终端(Web、APP、小程序)各自实现完整的 JWT 存储、刷新、携带逻辑,代码重复且难以维护;缺乏统一的后端认证代理层,无法实现一次适配、全端通用的认证接入能力。
5、资源服务器无法实现彻底无状态与独立扩展:
阶段三的资源服务器仍依赖 Redis 进行黑名单校验和会话管控,并未实现真正的无状态轻量化;在微服务大规模集群下,每个服务独立连接 Redis 进行令牌状态查询,增加了网络 I/O 与存储依赖,违背了资源服务器“无状态水平扩展”的最佳实践。
二、关键概念与设计基石:
1、OAuth 2.0 / 2.1 授权协议:
OAuth 2.0 是行业通用的开放式安全授权协议,主要解决分布式系统、多终端及多应用场景下的标准化授权与权限隔离问题。它允许第三方应用在不暴露用户核心凭证(如账号密码)的前提下完成安全授权,是目前微服务架构中实现认证授权与单点登录(SSO)的基石技术规范。
(1)、OAuth 2.1 安全增强:
随着安全需求的提升,OAuth 2.1 作为 OAuth 2.0 的安全增强规范(由 Spring Authorization Server 官方原生支持),在保留其核心优势的基础上进行了关键优化:
|
优化项 |
说明 |
|
废弃隐式模式(Implicit Grant) |
隐式模式将令牌直接暴露在浏览器前端,存在严重的 XSS 与令牌截获风险,OAuth 2.1 正式将其废弃。 |
|
废弃标准密码模式(Password Grant) |
密码模式要求客户端收集用户密码,违背“不暴露核心凭证”的设计初衷,且无法支持多因素认证等增强场景,OAuth 2.1 正式将其废弃。 |
|
强制公共客户端使用 PKCE |
PKCE(Proof Key for Code Exchange)通过生成 动态密钥(code_verifier ),并发送其哈希值(code_challenge)作为授权请求参数;换取令牌时,再发送原始的 code_verifier 供授权服务器验证。即使授权码在传输中被截获,由于攻击者无法得知 code_verifier,授权码也无法被恶意兑换,有效防止了中间人攻击,OAuth 2.1 将其作为公共客户端的强制要求。。 |
(2)、OAuth2.0四种授权模式:
OAuth2.0 定义的四种授权模式,核心差异体现在授权主体、令牌获取路径、安全等级与业务适用场景上,分别面向 Web 端、移动端、第三方应用、服务间通信等不同场景设计,且均支持标准的无感刷新流程。
1)、密码模式(Resource Owner Password Credentials):
|
核心定义 |
用户直接向客户端提交账号密码凭证,客户端凭用户身份信息直接向授权服务器申请令牌,是流程最直接的授权方式。 |
|
适用场景 |
企业自研移动端 App、官方内部管理后台,属于完全信任的自有客户端场景,不向外部第三方开放,也是单点登录体系中内部用户的核心登录方式。 |
|
行业现状 |
该模式要求客户端直接处理用户的账号密码,极易导致密码泄露;尤其在移动端、前端等无法安全存储密钥的公共客户端场景中,安全隐患不可控,同时违背 OAuth2.1 最新安全规范,因此被彻底废弃 |

2)、授权码模式(Authorization Code):
|
核心定义 |
OAuth2.0 官方推荐、安全等级最高的标准模式,通过临时授权码间接换取令牌,全程不传递密码、不直接暴露令牌至前端。 |
|
适用场景 |
Web 端第三方登录、外部 Web 应用、小程序、开放平台接入等不可信外部场景,是企业 Web 端授权、单点登录(SSO)的工业标准。 |

3)、客户端凭证模式(Client Credentials):
|
核心定义 |
无用户参与的纯服务级授权模式,客户端以自身 ID 与密钥申请令牌,仅用于微服务 / 服务器间自动化接口调用。 |
|
适用场景 |
微服务集群内部服务调用,如订单服务调用用户服务、定时任务同步数据、后台服务互相访问等机器间交互场景。 |

4)、简化/隐式模式(Implicit):
|
核心定义 |
早期简化授权模式,授权服务器直接将令牌返回前端,无需后端参与交换。 |
|
安全缺陷 |
牌直接暴露在浏览器地址栏,易被劫持窃取,无有效安全防护机制。 |
|
行业现状 |
OAuth2.1 已正式废弃该模式,企业级项目均禁止使用。 |

2、OIDC(OpenID Connect)身份认证协议:
OpenID Connect(OIDC)是一种基于 OAuth 2.0 框架的身份验证协议(RFC OpenID Connect Core 1.0)。它在 OAuth 2.0 授权框架之上引入了身份层(Identity Layer),添加了身份验证(Authentication) 的功能。
(1)、OIDC 与 OAuth 2.0 的关系:
OIDC = OAuth 2.0 授权底座 + 身份认证标准层 + 标准化用户信息规范
|
维度 |
OAuth 2.0 |
OIDC |
|
核心关注点 |
授权(Authorization) |
认证(Authentication)+ 授权 |
|
核心问题 |
“第三方应用能否访问用户资源?” |
“用户是谁?”+“能否访问资源?” |
|
产出物 |
Access Token |
Access Token + ID Token |
(2)、核心组件:
|
组件 |
说明 |
|
OP(OpenID Provider) |
OIDC 身份提供者,本质上是具备身份认证能力的 OAuth 2.0 授权服务器 |
|
RP(Relying Party) |
OIDC 依赖方,即接入 OIDC 认证的客户端应用 |
|
ID Token |
JWT 格式的身份令牌,包含标准化的用户身份声明(iss发行者、sub用户唯一标识、aud受众、exp过期时间等),直接证明「用户已完成认证」 |
|
UserInfo Endpoint |
UserInfo 端点,标准化的用户信息查询接口,可获取更完整的用户属性(昵称、头像、邮箱等) |
3、BFF(Backend for Frontend)架构模式:
BFF 是一种架构模式,为前端(如 Web、移动端)专门设计一个后端服务层,作为前端与后端微服务之间的中间层。BFF 负责协议转换、数据聚合、认证代理等职责,是前端的“专属网关”。
(1)、BFF 的典型职责:
- 认证代理:作为 OAuth2 客户端,统一处理登录/登出流程,前端仅需发起重定向操作,无需接触 OAuth2 协议细节。
- 会话管理:将令牌存储在服务端,前端仅持有会话标识(如 HttpOnly Cookie),令牌对前端完全透明,有效杜绝 XSS 窃取风险。
- 协议转换:将前端友好的接口协议转换为后端内部协议,适配不同前端场景。
- 令牌透传:自动将服务端存储的令牌添加到转发至下游资源服务器的请求头中,下游服务无感知。
(2)、BFF 架构的核心价值:
- 安全边界收拢:敏感操作(如客户端密钥保管、授权码交换)收敛在 BFF 层,前端只负责业务交互;
- 认证职责前移:认证复杂度被完全封装在 BFF 层,前端开发只需关注 UI 与业务逻辑;
- 多端统一接入:不同前端(Web/APP/小程序)均可通过各自 BFF 层以统一标准接入认证体系。
4、单点登录(Single Sign-On, SSO):
SSO 允许用户使用一组凭据登录一次,即可访问多个相互信任的应用系统,无需重复认证。其核心在于统一的身份认证中心,所有应用系统信任该中心签发的身份凭证。
(1)、SSO 的核心机制:
- 统一认证中心:所有应用系统将认证操作委托给统一的认证中心,由该中心负责用户身份验证与凭证签发。
- 全局会话:认证中心维护用户的全局会话状态,应用系统通过验证中心签发的凭证来识别用户身份。
- 凭证共享:用户登录认证中心后,访问任意接入系统时,该系统通过验证凭证的有效性即可完成本地会话建立,无需再次认证。
(2)、SSO 的核心价值:
- 用户体验提升:一次登录,全网通行,消除重复认证带来的操作冗余;
- 集中身份管理:用户信息、角色权限仅在认证中心维护,各应用系统无需存储用户状态,保证数据一致性;
- 安全策略统一:密码策略、多因素认证、会话时长等安全策略可在认证中心集中配置与执行。
5、单点登出(Single Sign-Out):
单点登出是 SSO 的配套机制,用户在一个应用登出后,所有相关应用系统及认证中心均会清除会话,实现全局注销,防止残留会话导致的安全风险。
(1)、单点登出的核心机制:
- 本地会话清除:发起登出的应用系统首先清除自身的本地会话;
- 全局会话销毁:应用系统通知认证中心销毁全局会话,使全局认证状态失效;
- 级联通知:认证中心可进一步向其他接入系统推送登出通知,触发各系统清除本地会话(部分实现采用被动方式,即令牌失效后各系统自然无法再认证)。
(2)、单点登出的核心价值:
- 安全合规:用户退出后不留残留会话,防止未授权访问,符合企业级安全合规要求;
- 用户体验一致:用户登出操作在任一接入系统执行,效果全局生效,避免“登出不完全”的困惑。
三、OAuth2 + BFF 微服务统一认证核心思想:
本阶段采用 “OAuth2 授权码模式为协议底座 + BFF 为安全隔离边界 + 资源服务器无状态 JWT 校验” 的微服务认证范式。既保留无状态资源服务器的轻量化优势,又通过 BFF 层补齐前端安全短板与跨服务认证协同能力,是当前标准的微服务 SSO 方案,也是 Spring Authorization Server 官方推荐的架构模式(注:本方案虽然在 BFF 层引入服务端 Session,但资源服务器层依然保持完全无状态,实现分层混合架构)。
1、认证三权分立,职责边界清晰:
将认证体系拆分为三个独立角色,各司其职:
- 授权服务器(Authorization Server):专注用户身份认证、权限审核与 JWT 令牌签发,作为全局唯一的身份中心;
- BFF 网关(Backend for Frontend):作为 OAuth2 机密客户端,专注前端会话管理、授权码流程处理与令牌透传,是前后端之间的安全堡垒;
- 资源服务器(Resource Server):专注业务逻辑执行,仅通过公钥验证 JWT 合法性,完全无状态,不管理任何会话。
2、BFF 安全边界隔离,彻底解耦前端:
BFF 层持有客户端密钥,在后端安全地完成授权码换取令牌操作;生成的 JWT 完全存储在服务端 WebSession 中,浏览器仅持有 HttpOnly Cookie(JSESSIONID)。前端从此无需接触 JWT、无需实现刷新逻辑、无需担心 XSS 窃取,达到前后端认证的彻底解耦。
3、单点登录(SSO)与全局会话统一:
用户仅在授权服务器认证一次,通过 BFF 网关访问所有路由转发(clientA/B)时,网关自动从 Session 中取出 JWT 并透传到下游。所有资源服务器共享同一个 JWT,实现“一次登录,全网通行”;登出时由 BFF 统一销毁本地会话并通知授权服务器销毁全局会话,实现一处登出、处处登出。
4、资源服务器极致轻量化与水平扩展:
资源服务器完全移除 Redis 依赖,无需查询黑名单或会话状态。仅通过配置 issuer-uri,利用 Spring Security 自动拉取授权服务器的 JWKS 公钥,在本地完成 JWT 签名与过期校验。服务重启无需同步任何状态,天然支持 Kubernetes 等环境下的弹性伸缩。
四、本阶段核心落地任务:
本阶段完全复用阶段三的用户角色数据库体系(RBAC)、BCrypt 密码加密及权限注解(@PreAuthorize),但彻底重构认证传输方式与会话管理模式。引入微服务组件(Spring Cloud Gateway、Nacos 服务发现),最终构建微服务架构下的统一认证与单点登录完整闭环。核心任务如下:
1、微服务模块拆分与数据库扩展:
按职责拆分授权服务器(cloud-auth)、BFF 网关(cloud-gateway)、资源服务器(cloud-clientA/B)独立模块;扩展 OAuth2 协议相关表(oauth2_registered_client、oauth2_authorization),存储客户端注册信息与授权码/令牌状态,替代阶段三的 Redis 令牌存储(授权层面)。
2、授权服务器(Authorization Server)核心构建:
集成 Spring Authorization Server,配置授权端点(/oauth2/authorize)、令牌端点(/oauth2/token)、JWKS 端点(/oauth2/jwks);自定义 OAuth2TokenCustomizer 扩展 JWT 载荷,将阶段三的 username、user_id、authorities 写入 JWT;实现基于数据库的 UserDetailsService 与 BCrypt 密码匹配,支撑用户登录认证。
3、JWT 非对称加密密钥与签名配置:
生成 RSA 密钥对,配置 JWKSource 用于令牌签名(RS256);授权服务器持有私钥签发 JWT,资源服务器通过公开的 JWKS 端点获取公钥进行验签,彻底杜绝阶段三中对称加密(HS256)密钥分发困难的问题,提升微服务间密钥管理安全性。
4、BFF 网关 OAuth2 客户端适配与 Token Relay:
在网关配置 spring.security.oauth2.client,将网关自身注册为 OAuth2 机密客户端;开启 oauth2Login 自动处理授权码重定向与回调;通过 WebSessionServerOAuth2AuthorizedClientRepository 将换取的 JWT 存储于 WebSession;配置 TokenRelay 全局过滤器,自动从 Session 提取 JWT 并添加至下游请求的 Authorization: Bearer <JWT> 请求头,实现身份透传。
5、资源服务器无状态安全配置:
编写 ClientSecurityConfig,开启 oauth2ResourceServer 并配置 .jwt() 指定 issuer-uri(指向授权服务器地址);设置 SessionCreationPolicy.STATELESS,彻底关闭 Session;自定义 JwtAuthenticationConverter,从 JWT 的 authorities 声明映射为 Spring Security 的 GrantedAuthority,无缝承接阶段三的 @PreAuthorize 动态权限校验。
6、统一登录页与认证端点定制:
使用 Thymeleaf 模板开发风格统一的登录页面,替换授权服务器默认白标页;配置 SecurityConfig 设置 loginPage("/api/auth/login") 及 loginProcessingUrl,保留阶段三的登录表单提交体验;同时暴露自定义登出端点(/api/auth/logout),由网关和授权服务器协同完成 SSO 登出闭环。
7、单点登出(SSO Logout)全链路打通:
在网关开发 LogoutController,调用 WebSession 清除本地令牌,并重定向至授权服务器的 /api/auth/logout?redirect_uri=...;在授权服务器侧配置 oidc 支持并添加登出控制器,销毁 HttpSession 并清除 SecurityContext;最终重定向回网关首页,实现“一次登出、全网注销”的企业级安全合规要求。
8、前端交互极致简化与标准化适配:
前端从此无需携带 JWT、无需处理刷新、无需存储令牌。只需知道两个 URL:登录跳转(/oauth2/authorization/cloud-gateway)和登出跳转(/api/auth/logout);所有业务 API 请求利用浏览器自动携带 Cookie 机制,由网关无感透传身份。彻底将前端从复杂的认证逻辑中解放出来,降至“零认知成本”接入。
9、端到端 SSO 全链路闭环验证:
完成微服务全场景测试:验证用户(admin/user)登录后跨服务(clientA/B)免密访问;验证基于角色的权限隔离(USER 无法访问 ADMIN 接口);验证 JWT 透传效果;验证单点登出后全局会话销毁;验证 Postman 直连资源服务器(携带 JWT)亦可正常访问,证明资源服务器完全无状态,可独立接受任何合法来源的 JWT 调用。
五、微服务统一认证相关实践:
1、数据库设计:
本阶段数据库设计采用 “业务数据 + OAuth2 协议数据” 分离存储 的策略:
(1)、业务表:
复用阶段二/阶段三的数据库方案,采用 MySQL 存储用户身份、角色及关联关系数据,沿用用户-角色多对多关联模型设计数据表。
(2)、OAuth2 协议表:
Spring Authorization Server 要求以下三张核心表作为 OAuth2 协议数据存储基础设施。授权服务器通过 JDBC 方式与这三张表交互,完成客户端注册信息加载、授权码与令牌的持久化存储、用户授权同意的记录与查询。
-- 1. OAuth2 客户端注册表(oauth2_registered_client):存储已注册OAuth2/OIDC客户端信息 CREATE TABLE IF NOT EXISTS oauth2_registered_client ( id VARCHAR(100) PRIMARY KEY COMMENT '客户端ID', client_id VARCHAR(100) NOT NULL UNIQUE COMMENT '客户端标识', client_id_issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '客户端ID颁发时间', client_secret VARCHAR(200) COMMENT '客户端密钥(BCrypt加密)', client_secret_expires_at TIMESTAMP NULL COMMENT '客户端密钥过期时间', client_name VARCHAR(200) NOT NULL COMMENT '客户端名称', client_authentication_methods VARCHAR(1000) NOT NULL COMMENT '客户端认证方法,逗号分隔', authorization_grant_types VARCHAR(1000) NOT NULL COMMENT '授权类型,逗号分隔', redirect_uris VARCHAR(1000) COMMENT '重定向URI,逗号分隔', post_logout_redirect_uris VARCHAR(1000) COMMENT '登出后重定向URI,逗号分隔', scopes VARCHAR(1000) NOT NULL COMMENT '授权范围,逗号分隔', client_settings TEXT NOT NULL COMMENT '客户端设置(JSON格式)', token_settings TEXT NOT NULL COMMENT '令牌设置(JSON格式)' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OAuth2已注册客户端表'; -- 2. OAuth2 授权表(oauth2_authorization):存储所有授权信息,包括授权码、Access Token、Refresh Token 等 CREATE TABLE IF NOT EXISTS oauth2_authorization ( id VARCHAR(100) PRIMARY KEY COMMENT '授权ID', registered_client_id VARCHAR(100) NOT NULL COMMENT '已注册客户端ID', principal_name VARCHAR(200) NOT NULL COMMENT '用户主体名称', authorization_grant_type VARCHAR(100) NOT NULL COMMENT '授权类型', authorized_scopes VARCHAR(1000) COMMENT '已授权范围,逗号分隔', attributes TEXT COMMENT '授权属性(JSON格式)', state VARCHAR(500) COMMENT '状态参数', authorization_code_value TEXT COMMENT '授权码值', authorization_code_issued_at TIMESTAMP COMMENT '授权码颁发时间', authorization_code_expires_at TIMESTAMP COMMENT '授权码过期时间', authorization_code_metadata TEXT COMMENT '授权码元数据(JSON格式)', access_token_value TEXT COMMENT '访问令牌值', access_token_issued_at TIMESTAMP COMMENT '访问令牌颁发时间', access_token_expires_at TIMESTAMP COMMENT '访问令牌过期时间', access_token_metadata TEXT COMMENT '访问令牌元数据(JSON格式)', access_token_type VARCHAR(100) COMMENT '访问令牌类型', access_token_scopes VARCHAR(1000) COMMENT '访问令牌范围,逗号分隔', refresh_token_value TEXT COMMENT '刷新令牌值', refresh_token_issued_at TIMESTAMP COMMENT '刷新令牌颁发时间', refresh_token_expires_at TIMESTAMP COMMENT '刷新令牌过期时间', refresh_token_metadata TEXT COMMENT '刷新令牌元数据(JSON格式)', oidc_id_token_value TEXT COMMENT 'OIDC ID令牌值', oidc_id_token_issued_at TIMESTAMP COMMENT 'OIDC ID令牌颁发时间', oidc_id_token_expires_at TIMESTAMP COMMENT 'OIDC ID令牌过期时间', oidc_id_token_metadata TEXT COMMENT 'OIDC ID令牌元数据(JSON格式)', user_code_value TEXT COMMENT '用户码值', user_code_issued_at TIMESTAMP COMMENT '用户码颁发时间', user_code_expires_at TIMESTAMP COMMENT '用户码过期时间', user_code_metadata TEXT COMMENT '用户码元数据(JSON格式)', device_code_value TEXT COMMENT '设备码值', device_code_issued_at TIMESTAMP COMMENT '设备码颁发时间', device_code_expires_at TIMESTAMP COMMENT '设备码过期时间', device_code_metadata TEXT COMMENT '设备码元数据(JSON格式)', CONSTRAINT fk_oauth2_authorization_client FOREIGN KEY (registered_client_id) REFERENCES oauth2_registered_client(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OAuth2授权信息表'; -- 3. OAuth2 授权同意表(oauth2_authorization_consent):存储用户对客户端授权范围的同意记录 CREATE TABLE IF NOT EXISTS oauth2_authorization_consent ( registered_client_id VARCHAR(100) NOT NULL COMMENT '已注册客户端ID', principal_name VARCHAR(200) NOT NULL COMMENT '用户主体名称', authorities VARCHAR(1000) NOT NULL COMMENT '授权权限,逗号分隔', PRIMARY KEY (registered_client_id, principal_name), CONSTRAINT fk_oauth2_consent_client FOREIGN KEY (registered_client_id) REFERENCES oauth2_registered_client(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OAuth2用户授权确认表';
注:
OAuth2 协议表(oauth2_registered_client、oauth2_authorization、oauth2_authorization_consent)的数据由 Spring Authorization Server 在运行时通过 JDBC 方式自动读写,无需手动插入初始化 SQL。其中客户端注册信息(oauth2_registered_client)通过 RegisteredClientInitTest 测试类在启动时动态注册。
2、项目结构:

|
技术 |
版本 |
|
Java |
17 |
|
Spring Boot |
3.2.2 |
|
Spring Security |
6.x |
|
Spring Authorization Server |
1.2.2 |
|
Spring Cloud Gateway |
2023.0.0 |
|
Spring Cloud Alibaba Nacos |
2022.0.0.0 |
|
MyBatis-Plus |
3.5.5 |
|
Thymeleaf |
3.x |
|
MySQL |
8.x |
(1)、模块说明:
|
模块 |
端口 |
说明 |
|
cloud-auth |
9001 |
OAuth2 授权服务器,负责用户认证和 JWT 令牌签发 |
|
cloud-gateway |
8080 |
BFF 网关层,作为 OAuth2 客户端,管理用户会话 |
|
cloud-clientA |
9002 |
OAuth2 资源服务器,验证 JWT 并提供业务接口 |
|
cloud-clientB |
9003 |
OAuth2 资源服务器,验证 JWT 并提供业务接口 |
3、BFF(Backend for Frontend)架构设计:
本项目采用 BFF(Backend for Frontend) 架构模式,网关作为 OAuth2 客户端,统一处理 OAuth2 登录流程、管理用户会话,并代理前端对资源服务器的访问。

|
步骤 |
组件 |
操作 |
请求详情 |
|
1 |
前端 |
用户点击登录按钮,跳转至网关登录入口 |
GET http://localhost:8080/oauth2/authorization/cloud-gateway |
|
2 |
网关 |
检测到用户未登录,Spring Security 自动生成 state 参数,存入 Session 并重定向至授权服务器授权端点 |
GET http://localhost:9001/oauth2/authorize?response_type=code&client_id=cloud-gateway&redirect_uri=http://localhost:8080/login/oauth2/code/cloud-gateway&scope=openid%20profile%20roles&state={state} |
|
3 |
授权服务器 |
检测到用户未认证,重定向至登录页面 |
GET http://localhost:9001/api/auth/login |
|
4 |
前端 |
浏览器显示 Thymeleaf 渲染的登录页面 |
用户输入用户名 + 密码 |
|
5 |
前端 |
提交登录表单 |
POST http://localhost:9001/api/auth/login |
|
6 |
授权服务器 |
验证用户凭证(调用 UserDetailsServiceImpl),校验通过后生成授权码 |
内部逻辑执行 |
|
7 |
授权服务器 |
认证成功后,OAuth2AuthorizationCodeAuthenticationProvider 生成授权码(authorization_code),存储于 OAuth2AuthorizationService,重定向回网关回调地址 |
GET http://localhost:8080/login/oauth2/code/cloud-gateway?code={code}&state={state} |
|
8 |
网关 |
验证 state 参数与 Session 中存储的是否一致,校验通过后向授权服务器令牌端点发起请求,用授权码换取 Token |
POST http://localhost:9001/oauth2/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code={code}&redirect_uri=http://localhost:8080/login/oauth2/code/cloud-gateway&client_id=cloud-gateway&client_secret=cloud-gateway-secret |
|
9 |
授权服务器 |
验证授权码有效性(是否存在、是否过期、是否匹配 client_id),验证 client_secret,验证 redirect_uri 一致性;校验通过后标记授权码为已使用,签发 JWT(含自定义声明),返回 Token 响应 |
响应体包含 access_token、refresh_token、expires_in、scope |
|
10 |
网关 |
将 JWT 存储到 WebSession,向浏览器下发 JSESSIONID Cookie,重定向至网关首页 |
响应头携带 Set-Cookie: JSESSIONID={sessionId} |
4、单点登出流程:

|
步骤 |
组件 |
操作 |
|
1 |
前端 |
用户点击登出按钮,访问网关登出端点 |
|
2 |
网关 |
清除 WebSession(销毁存储的 OAuth2AuthorizedClient 和 JWT) |
|
3 |
网关 |
重定向到授权服务器登出端点 |
|
4 |
授权服务器 |
清除 SecurityContext,销毁 HttpSession |
|
5 |
授权服务器 |
重定向回网关首页 |
|
6 |
前端 |
用户回到未登录状态,需要重新登录才能访问受保护资源 |
5、请求访问控制流程:

6、Maven依赖:
(1)、父 POM:
<properties>
<java.version>17</java.version>
<spring-boot.version>3.2.2</spring-boot.version>
<spring-cloud.version>2023.0.0</spring-cloud.version>
<spring-cloud-alibaba.version>2022.0.0.0</spring-cloud-alibaba.version>
<spring-security-oauth2-authorization-server.version>1.2.2</spring-security-oauth2-authorization-server.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- ==================== Spring Boot 依赖统一管理 ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- ==================== Spring Cloud 依赖统一管理 ==================== -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- ==================== Spring Cloud Alibaba 依赖统一管理 ==================== -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- ==================== MyBatis-Plus ==================== -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- ==================== MySQL 驱动 ==================== -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.3.0</version>
</dependency>
<!-- ==================== Lombok 代码简化 ==================== -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</dependency>
<!-- ==================== OAuth2 授权服务端 ==================== -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>${spring-security-oauth2-authorization-server.version}</version>
</dependency>
<!-- ==================== OAuth2 客户端 ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- ==================== OAuth2 资源服务端 ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
<version>${spring-boot.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
(2)、网关模块(cloud-gateway):
<dependencies>
<!-- ==================== Spring Cloud Gateway 网关 ==================== -->
<!-- 网关核心(内置 WebFlux) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- 客户端负载均衡 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!-- ==================== Nacos 服务注册发现 ==================== -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- ==================== OAuth2 认证相关 ==================== -->
<!-- 资源服务:JWT 校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- OAuth2 客户端:授权码流程 + Token 转发 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
</dependencies>
(3)、授权服务器(cloud-auth):
<dependencies>
<!-- ==================== Spring Boot Web ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- ==================== 单元测试 ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- ==================== MySQL 驱动 ==================== -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- ==================== MyBatis-Plus ORM框架 ==================== -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<!-- ==================== Lombok 代码简化 ==================== -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- ==================== FastJSON2 JSON解析 ==================== -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.32</version>
</dependency>
<!-- ==================== OAuth2 授权服务端 ==================== -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
</dependency>
<!-- ==================== Thymeleaf 页面模板引擎 ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
(4)、资源服务器(cloud-clientA/B):
<dependencies>
<!-- ==================== Spring Boot Web ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- ==================== 单元测试 ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- ==================== Spring Security 安全框架 ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- ==================== OAuth2 资源服务端(JWT 令牌校验) ==================== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- ==================== Nacos 服务注册与发现 ==================== -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
</dependencies>
7、YML配置:
(1)、授权服务器(cloud-auth):
server: port: 9001 spring: application: name: cloud-auth # 数据源配置 datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/security_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123 hikari: minimum-idle: 5 maximum-pool-size: 20 idle-timeout: 30000 connection-timeout: 30000 max-lifetime: 1800000 # OAuth2 Authorization Server issuer 配置 security: oauth2: authorization-server: # 认证中心地址 issuer: http://localhost:9001 # MyBatis-Plus配置 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: AUTO
(2)、网关模块(cloud-gateway):
server: port: 8080 spring: application: name: cloud-gateway # Nacos服务发现配置 cloud: nacos: discovery: server-addr: localhost:8848 # Spring Cloud Gateway 路由配置 gateway: routes: # 客户端A路由 # 访问 http://gateway:8080/clientA/xxx → 转发到 cloud-clientA/xxx - id: cloud-clientA uri: lb://cloud-clientA predicates: - Path=/clientA/** filters: - StripPrefix=1 # 去掉路径前缀 /clientA - TokenRelay= # 透传JWT令牌到下游服务 # 客户端B路由 # 访问 http://gateway:8080/clientB/xxx → 转发到 cloud-clientB/xxx - id: cloud-clientB uri: lb://cloud-clientB predicates: - Path=/clientB/** filters: - StripPrefix=1 - TokenRelay= # ==================== OAuth2 Client 配置(机密客户端)==================== # 网关作为 OAuth2 机密客户端(Confidential Client) # 配置说明: # - 使用 client-secret 进行客户端认证(更安全) # - 适用于服务端应用(BFF 层) # - 授权服务器需要验证 client_secret # # 前端使用方式: # 1. 前端点击登录按钮,跳转到 /oauth2/authorization/cloud-gateway # 2. 网关自动重定向到授权服务器的授权端点 # 3. 用户在授权服务器登录 # 4. 授权服务器回调网关,网关用授权码换取 JWT # 5. 网关将 JWT 存储在 Session 中,并向浏览器下发 JSESSIONID Cookie # 6. 前端后续请求携带 Cookie,网关自动透传 JWT 到下游服务 security: oauth2: client: registration: cloud-gateway: provider: cloud-auth # 指定OAuth2提供者 client-id: cloud-gateway # 客户端ID(需在认证中心注册) client-secret: cloud-gateway-secret # 客户端密钥明文(机密客户端必须) authorization-grant-type: authorization_code # 授权码模式 redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" # 回调地址 scope: openid,profile,roles # 请求的权限范围 client-authentication-method: client_secret_post # 客户端认证方式(表单认证) provider: cloud-auth: issuer-uri: http://localhost:9001 # 认证中心地址 authorization-uri: http://localhost:9001/oauth2/authorize # 授权端点 token-uri: http://localhost:9001/oauth2/token # 令牌端点 jwk-set-uri: http://localhost:9001/oauth2/jwks # JWKS端点(用于验证JWT签名) # 注意:网关不需要配置 resourceserver.jwt # 网关作为 BFF 层,只需要 OAuth2 Client 功能 # - 前端 → 网关:使用 Session Cookie(JSESSIONID) # - 网关 → 资源服务器:使用 TokenRelay 透传 JWT # 如果配置了 resourceserver.jwt,网关会尝试从请求头解析 JWT,但前端只携带 Cookie,导致 401
(3)、资源服务器(cloud-clientA/B):
server: port: 9002 spring: application: name: cloud-clientA # ==================== Nacos 服务注册与发现 ==================== cloud: nacos: discovery: server-addr: localhost:8848 # ==================== OAuth2 Resource Server 配置 ==================== # 资源服务器验证JWT令牌的配置 # Spring Boot 自动配置会: # 1. 从 issuer-uri 获取 OAuth2 授权服务器元数据 # 2. 从 jwk-set-uri 获取公钥用于验证JWT签名 security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:9001
8、核心实现流程:

(1)、授权服务器核心配置(cloud-auth):
1)、AuthorizationServerConfig.java
OAuth2 授权服务器核心配置
/** * OAuth2 授权服务器配置类 * * 职责说明: * - 配置OAuth2 Authorization Server核心组件 * - 配置JWT密钥对 * - 配置客户端注册(从数据库加载) * - 配置令牌设置(JWT格式) * - 取消授权确认页面(autoApprove) * - 配置JWT自定义声明(username, user_id, authorities) */ @Configuration public class AuthorizationServerConfig { @Autowired private SysUserMapper sysUserMapper; /** * 授权服务器issuer地址 * 从配置文件读取,支持Nacos配置中心动态配置 * 默认值:http://localhost:9001(与 bootstrap-dev.yml 中的端口一致) */ @Value("${spring.security.oauth2.authorization-server.issuer:http://localhost:9001}") private String issuer; /** * 注册客户端仓库(从数据库加载) * 使用JdbcRegisteredClientRepository从oauth2_registered_client表读取客户端配置 */ @Bean public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) { return new JdbcRegisteredClientRepository(jdbcTemplate); } /** * OAuth2授权服务(使用JDBC存储) * 使用JdbcOAuth2AuthorizationService从oauth2_authorization表读取/存储授权信息 */ @Bean public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); } /** * OAuth2授权同意服务(从数据库加载) * 使用JdbcOAuth2AuthorizationConsentService从oauth2_authorization_consent表读取/存储授权同意信息 * 注意:授权同意信息可以存储在数据库,因为不需要频繁查询 */ @Bean public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository); } /** * 授权服务器端点配置 * * 配置OAuth2.0/OIDC协议端点路径及发行者标识。 * * 可配置端点: * - issuer(发行者标识): 自动推断, 配置方法 .issuer("http://xxx") * - 授权端点: /oauth2/authorize, 配置方法 .authorizationEndpoint() * - 令牌端点: /oauth2/token, 配置方法 .tokenEndpoint() * - 令牌吊销端点: /oauth2/revoke, 配置方法 .tokenRevocationEndpoint() * - OIDC用户信息端点: /userinfo, 配置方法 .userInfoEndpoint() * - OIDC单点登出端点: /end_session, 配置方法 .endSessionEndpoint() * - JWK公钥端点: /.well-known/jwks.json, 配置方法 .jwkSetEndpoint() * * issuer配置说明: * - issuer是JWT令牌的发行者标识,写入JWT的"iss"声明 * - 资源服务器通过issuer-uri验证JWT来源可信性 * - 未配置时,框架自动从请求Host头推断 * * 关联配置: * 资源服务器的issuer-uri必须与此处配置的issuer保持一致,否则JWT验证失败。 * 例如:本配置issuer=http://localhost:9001,则资源服务器需配置: * spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9001 * * @return AuthorizationServerSettings 授权服务器配置对象 * @see <a href="https://docs.spring.io/spring-authorization-server/reference/configuration-model.html">Spring Authorization Server配置文档</a> */ @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder() .issuer(issuer) // 示例:自定义端点路径 // .authorizationEndpoint("/custom/authorize") // .tokenEndpoint("/custom/token") .build(); } /** * JWT令牌自定义器 * 添加自定义声明以兼容上一阶段业务代码 * * 自定义声明: * - username: 用户登录名 * - user_id: 用户唯一标识 * - authorities: 角色权限列表,例如 ["ROLE_ADMIN", "ROLE_USER"] * * 资源服务器通过JwtAuthenticationConverter将authorities转换为GrantedAuthority对象 */ @Bean public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() { return context -> { if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) { Authentication authentication = context.getPrincipal(); context.getClaims().claims(claims -> { // 获取用户名 String username = authentication.getName(); claims.put("username", username); // 获取用户ID(从数据库查询,避免Jackson反序列化白名单问题) // 注意:不再使用CustomUserDetails,因为OAuth2授权流程会序列化Authentication到数据库 SysUser sysUser = sysUserMapper.selectByUsername(username); if (sysUser != null) { claims.put("user_id", sysUser.getId()); } // 获取权限列表(角色) List<String> authorities = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); claims.put("authorities", authorities); }); } }; } /** * 授权服务器安全过滤器链 * 配置OAuth2 Authorization Server的安全规则 */ @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { // 应用授权服务器的默认安全配置,包括OAuth2端点保护、令牌端点等 OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); // 获取OAuth2授权服务器配置器,用于自定义授权服务器行为 http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) // 启用OIDC端点 .oidc(Customizer.withDefaults()); // 指定未认证时的重定向地址 http.exceptionHandling(exceptions -> exceptions.authenticationEntryPoint( new LoginUrlAuthenticationEntryPoint("/api/auth/login")) ); return http.build(); } }
|
配置项 |
说明 |
|
issuer |
JWT 发行者标识,资源服务器需配置相同的 issuer-uri |
|
oidc() |
启用 OIDC 端点,支持单点登出 |
|
LoginUrlAuthenticationEntryPoint |
未认证时跳转到登录页 |
2)、JWKSourceConfig.java
JWT 密钥配置,提供JWT签名所需的RSA非对称密钥对
/** * JWK (JSON Web Key) 源配置类。 * * 职责: * 1. 提供用于签署JWT令牌的非对称密钥对 * 2. 构建JWK集供OAuth2 Authorization Server使用 * 3. 支持资源服务器通过 /oauth2/jwks 端点获取公钥进行JWT验证 * * 当前实现采用运行时动态生成密钥对的策略,适用于开发和测试环境。 * 生产环境必须使用持久化的密钥管理方案。 * * 密钥生成策略说明: * - 算法:RSA 2048位 * - 密钥存储:内存级(重启即失效) * - 适用场景:开发、测试、演示环境 * * 生产环境改造要求: * 必须从安全存储加载密钥,推荐方案包括: * 1. PEM格式密钥文件(需配合配置中心管理) * 2. 环境变量注入(通过密钥管理服务) * 3. 硬件安全模块(HSM) * 4. 托管密钥服务(如AWS KMS、Azure Key Vault) * * JWT签名与验证流程: * 1. Authorization Server使用私钥对JWT进行签名 * 2. 资源服务器通过JWKS端点获取公钥 * 3. 资源服务器使用公钥验证JWT的完整性和真实性 * * 当前实现的局限性: * - 密钥不持久化,每次启动生成新密钥对 * - 应用重启后,之前签发的JWT令牌将失效 * - 不支持多实例部署(各实例密钥独立) * - 不支持密钥轮换策略 */ @Slf4j @Configuration public class JWKSourceConfig { /** * 创建JWK源Bean,提供JWT签名所需的RSA密钥对。 * * @return JWKSource<SecurityContext> 包含RSA密钥的JWK集 */ @Bean public JWKSource<SecurityContext> 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(); log.info("JWT signing key pair generated. Key ID: {}, Algorithm: {}", rsaKey.getKeyID(), rsaKey.getAlgorithm()); log.info("Note: Running in development mode. Restarting the application will invalidate all existing tokens."); return new ImmutableJWKSet<>(new JWKSet(rsaKey)); } /** * 生成RSA密钥对。 * * 使用Java Security API生成2048位RSA密钥对。 * 密钥长度遵循NIST推荐的最小安全标准。 * * @return KeyPair 包含公钥和私钥的密钥对 * @throws IllegalStateException 密钥生成失败时抛出 */ private KeyPair generateRsaKey() { try { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); return keyPairGenerator.generateKeyPair(); } catch (Exception e) { log.error("Failed to generate RSA key pair: {}", e.getMessage()); throw new IllegalStateException("Unable to generate RSA key pair", e); } } /** * 生产环境密钥加载方案代码模板。 * * 使用方式: * 1. 取消以下代码注释 * 2. 删除当前的jwkSource()和generateRsaKey()方法 * 3. 在配置文件中添加密钥路径配置 * * 配置示例: * jwt: * public-key-path: classpath:jwt-public-key.pem * private-key-path: classpath:jwt-private-key.pem * * 密钥生成命令(OpenSSL): * openssl genrsa -out jwt-private-key.pem 2048 * openssl rsa -in jwt-private-key.pem -pubout -out jwt-public-key.pem */ /* @Value("${jwt.public-key-path}") private String publicKeyPath; @Value("${jwt.private-key-path}") private String privateKeyPath; @Bean public JWKSource<SecurityContext> jwkSource() throws Exception { RSAPublicKey publicKey = loadPublicKey(publicKeyPath); RSAPrivateKey privateKey = loadPrivateKey(privateKeyPath); RSAKey rsaKey = new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); log.info("JWT signing key pair loaded from files"); return new ImmutableJWKSet<>(new JWKSet(rsaKey)); } private RSAPublicKey loadPublicKey(String path) throws Exception { try (InputStream is = getClass().getResourceAsStream(path)) { byte[] keyBytes = is.readAllBytes(); String pem = new String(keyBytes).replace("-----BEGIN PUBLIC KEY-----", "") .replace("-----END PUBLIC KEY-----", "") .replaceAll("\\s", ""); byte[] decoded = Base64.getDecoder().decode(pem); X509EncodedKeySpec spec = new X509EncodedKeySpec(decoded); KeyFactory kf = KeyFactory.getInstance("RSA"); return (RSAPublicKey) kf.generatePublic(spec); } } private RSAPrivateKey loadPrivateKey(String path) throws Exception { try (InputStream is = getClass().getResourceAsStream(path)) { byte[] keyBytes = is.readAllBytes(); String pem = new String(keyBytes).replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s", ""); byte[] decoded = Base64.getDecoder().decode(pem); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded); KeyFactory kf = KeyFactory.getInstance("RSA"); return (RSAPrivateKey) kf.generatePrivate(spec); } } */ }
|
当前实现为开发环境使用,运行时动态生成密钥对 |
|
生产环境应从安全存储加载持久化密钥 |
|
资源服务器通过 /oauth2/jwks 端点获取公钥验证JWT |
3)、SecurityConfig.java
配置用户登录认证
/** * Spring Security 安全配置类 * * 职责说明: * - 配置常规Web安全规则 * - 使用默认登录页面(Spring Security自带) * - 配置免认证路径 * - 配置UserDetailsService用于数据库认证 */ @Configuration @EnableWebSecurity public class SecurityConfig { /** * 密码加密器 */ @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } /** * 认证管理器 */ @Bean public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); provider.setPasswordEncoder(passwordEncoder); return new ProviderManager(provider); } /** * 默认安全过滤器链 * 配置常规Web安全规则 */ @Bean @Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http // 禁用CSRF(OAuth2使用JWT,不需要CSRF) .csrf(csrf -> csrf.disable()) // 配置授权规则 .authorizeHttpRequests(authorize -> authorize // 放行登录页、OAuth2端点、错误页 .requestMatchers("/api/auth/**", "/oauth2/**", "/error", "/login").permitAll() // 其他所有请求需要认证 .anyRequest().authenticated() ) // 自定义登录页(使用 Thymeleaf 渲染) .formLogin(form -> form .loginPage("/api/auth/login") // 自定义登录页 URL .loginProcessingUrl("/api/auth/login") // 登录处理 URL .permitAll() ) // 注意:登出逻辑在 LoginController 中实现(GET /api/auth/logout) // 登出流程:网关 /api/auth/logout → 授权服务器 /api/auth/logout → 重定向回网关 .logout(logout -> logout.disable()); // 禁用默认 /logout 端点 return http.build(); } }
4)、UserDetailsServiceImpl.java
实现 Spring Security 的 UserDetailsService,用户登录时加载用户信息和角色权限
/** * 用户详情服务实现类 * * 职责说明: * - 实现Spring Security的UserDetailsService接口 * - 用户登录时加载用户信息和角色权限 * - 为认证过程提供用户凭证验证数据 */ @Service public class UserDetailsServiceImpl implements UserDetailsService { /** * 用户数据访问层 */ @Autowired private SysUserMapper sysUserMapper; /** * 角色数据访问层 */ @Autowired private SysRoleMapper sysRoleMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 1. 根据用户名查询用户信息 SysUser sysUser = sysUserMapper.selectByUsername(username); if (sysUser == null) { throw new UsernameNotFoundException("用户名不存在或账号已禁用"); } // 2. 查询用户角色列表 List<SysRole> roles = sysRoleMapper.selectByUserId(sysUser.getId()); if (CollectionUtils.isEmpty(roles)) { // 无角色用户不允许登录,增强安全性 throw new AuthenticationServiceException("用户未分配角色,无法登录"); } // 3、将角色代码转换为 GrantedAuthority(加上 ROLE_ 前缀) List<GrantedAuthority> authorities = roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getRoleCode())) .collect(Collectors.toList()); // 4. 返回 Spring Security 内置的 User 对象(避免 Jackson 反序列化白名单问题) // 因为 OAuth2 授权流程会把 Authentication 对象(包含 principal)序列化到数据库。 // 数据库反序列化时 Jackson 只认白名单内的类 return User.builder() .username(sysUser.getUsername()) .password(sysUser.getPassword()) .authorities(authorities) .disabled(false) .accountLocked(false) .credentialsExpired(false) .accountExpired(false) .build(); } }
5)、LoginController.java
登录页面控制器
/** * 登录页面控制器 * * 职责说明: * - 渲染自定义登录页面(Thymeleaf 模板) * - 处理登录页面相关的请求 * - 处理单点登出请求(销毁授权服务器的 HttpSession) * * @Controller 页面控制器(默认跳转视图,适用于模板页面、服务端渲染) * @RestController API 控制器(默认返回 JSON 数据,适用于前后端分离、OAuth2 资源服务、接口服务) */ @Controller public class LoginController { /** * 登录页面 * * @return 登录页面视图名称 */ @GetMapping("/api/auth/login") public String loginPage() { return "login"; // 返回 Thymeleaf 模板 } /** * 单点登出接口(GET 请求) * * 流程: * 1. 网关调用此接口(http://localhost:9001/api/auth/logout?redirect_uri=http://localhost:8080) * 2. 销毁授权服务器的 HttpSession * 3. 重定向回网关首页(或指定的重定向地址) * * @param redirectUri 登出后重定向地址 * @param request HTTP 请求 * @return 重定向到网关首页 */ @GetMapping("/api/auth/logout") public String logout(@RequestParam(value = "redirect_uri", required = false, defaultValue = "http://localhost:8080") String redirectUri, HttpServletRequest request) { // 1. 清除 SecurityContext SecurityContextHolder.clearContext(); // 2. 销毁 HttpSession HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); } // 3. 重定向回网关首页 return "redirect:" + redirectUri; } }
|
渲染自定义登录页面(Thymeleaf 模板) |
|
处理单点登出请求(销毁授权服务器的 HttpSession) |
6)、login.html
自定义登录页面(Thymeleaf模板)
<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>登录 - 统一认证中心</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; } .login-container { background: white; border-radius: 20px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); overflow: hidden; width: 100%; max-width: 420px; animation: slideUp 0.6s ease-out; } @keyframes slideUp { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } } .login-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center; color: white; } .login-header h1 { font-size: 28px; font-weight: 600; margin-bottom: 10px; } .login-header p { font-size: 14px; opacity: 0.9; } .login-body { padding: 40px 30px; } .form-group { margin-bottom: 24px; } .form-group label { display: block; margin-bottom: 8px; color: #555; font-weight: 500; font-size: 14px; } .form-group input { width: 100%; padding: 12px 16px; border: 2px solid #e1e5e9; border-radius: 10px; font-size: 15px; transition: all 0.3s ease; outline: none; } .form-group input:focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); } .form-group input::placeholder { color: #aaa; } .login-button { width: 100%; padding: 14px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; border-radius: 10px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); } .login-button:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5); } .login-button:active { transform: translateY(0); } .alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; animation: shake 0.5s ease-in-out; } @keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-10px); } 75% { transform: translateX(10px); } } .alert-error { background: #fee; color: #c33; border: 1px solid #fcc; } .alert-success { background: #efe; color: #3c3; border: 1px solid #cfc; } .footer { text-align: center; margin-top: 24px; padding-top: 20px; border-top: 1px solid #e1e5e9; color: #888; font-size: 13px; } .footer a { color: #667eea; text-decoration: none; font-weight: 500; } .footer a:hover { text-decoration: underline; } .icon { display: inline-block; width: 20px; height: 20px; vertical-align: middle; margin-right: 8px; } .loading { display: none; width: 20px; height: 20px; border: 2px solid #fff; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto; } @keyframes spin { to { transform: rotate(360deg); } } .login-button.loading .loading { display: block; } .login-button.loading span { display: none; } </style> </head> <body> <div class="login-container"> <div class="login-header"> <h1>🔐 统一认证中心</h1> <p>OAuth2 单点登录系统</p> </div> <div class="login-body"> <!-- 登录表单 --> <form th:action="@{/api/auth/login}" method="post" id="loginForm"> <div class="form-group"> <label for="username">👤 用户名</label> <input type="text" id="username" name="username" placeholder="请输入用户名" required autocomplete="username" > </div> <div class="form-group"> <label for="password">🔒 密码</label> <input type="password" id="password" name="password" placeholder="请输入密码" required autocomplete="current-password" > </div> <button type="submit" class="login-button" onclick="handleSubmit(this)"> <span>登录</span> <div class="loading"></div> </button> </form> <div class="footer"> <p>测试账号:admin / 123456</p> <p>© OAuth2 认证中心 | <a href="https://spring.io/projects/spring-authorization-server" target="_blank">Spring Authorization Server</a></p> </div> </div> </div> <script> function handleSubmit(button) { button.classList.add('loading'); document.getElementById('loginForm').submit(); } // 自动聚焦到用户名输入框 window.onload = function() { document.getElementById('username').focus(); }; // 回车键提交表单 document.addEventListener('keypress', function(e) { if (e.key === 'Enter') { document.getElementById('loginForm').submit(); } }); </script> </body> </html>
7)、RegisteredClientInitTest.java
OAuth2 客户端注册,通过测试类注册 OAuth2 客户端到数据库
/** * OAuth2客户端注册测试类 * * 职责说明: * - 使用RegisteredClientRepository直接向数据库添加OAuth2客户端 * - 替代手动编写SQL脚本的方式 * - 支持动态添加、查询、更新客户端配置 * * 使用方法: * 1. 运行Spring Boot测试环境 * 2. 调用saveClients()方法添加客户端 * 3. 数据库中自动生成oauth2_registered_client记录 */ @SpringBootTest public class RegisteredClientInitTest { /** * RegisteredClientRepository - OAuth2客户端仓储接口 * Spring Authorization Server自动配置的Bean * 用于CRUD操作oauth2_registered_client表 */ @Autowired private RegisteredClientRepository registeredClientRepository; /** * 保存OAuth2客户端到数据库(机密客户端 + 授权码模式) * * 使用说明: * 运行此测试方法后,数据库oauth2_registered_client表会新增1条记录(网关客户端) * * 客户端说明: * - cloud-gateway:网关客户端(BFF层),作为OAuth2客户端发起授权码流程 * - cloud-clientA/B:资源服务器,不需要在表中注册(只验证JWT,不发起OAuth2请求) * * 架构说明: * ┌─────────────┐ ┌─────────────────┐ ┌─────────────┐ * │ 前端 SPA │────►│ cloud-gateway │────►│ cloud-auth │ * │ (浏览器) │◄────│ (BFF层) │◄────│ (授权服务器) │ * └─────────────┘ └─────────────────┘ └─────────────┘ * │ * ▼ * ┌─────────────────┐ * │ cloud-clientA/B │ * │ (资源服务器) │ * └─────────────────┘ * * 机密客户端说明: * - 网关运行在服务端,不会泄露 client_secret * - 使用 client_secret_post 认证方式(表单提交) * - 不需要启用 PKCE(服务端之间通信更安全) * - 密钥必须与 application.yml 中的配置一致 */ @Test public void saveClients() { // 构建网关客户端(机密客户端,BFF层) // 网关作为OAuth2机密客户端,负责: // 1. 发起授权码流程 // 2. 存储JWT Token到Session // 3. 透传JWT到下游资源服务器 RegisteredClient gatewayClient = RegisteredClient // ============ 必填字段 ============ // 客户端唯一标识(UUID,数据库主键) .withId("cloud-gateway-id") // 客户端ID(OAuth2认证时使用) .clientId("cloud-gateway") // 客户端密钥(机密客户端必须,服务端安全存储) // 注意:必须与 application.yml 中的 client-secret 一致 // cloud-gateway-secret加密,存储纯 BCrypt 哈希:new BCryptPasswordEncoder().encode("cloud-gateway-secret"); .clientSecret("$2a$10$DAudyvm3bJwWSC8Zim/CKeSPlCYJvL05XoRae2x8G37IRYzs0S2vq") // 客户端名称(用于显示) .clientName("Cloud-Gateway-Name") // ============ 认证方法 ============ // client_secret_post: 机密客户端使用的认证方式(表单提交) // - 网关将 client_id 和 client_secret 通过表单提交到令牌端点 // - 适用于服务端应用(BFF层) // - 不需要 PKCE(服务端通信更安全) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) // ============ 授权类型 ============ // authorization_code: 授权码模式(用于获取访问令牌) // refresh_token: 刷新令牌(用于刷新过期令牌) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) // ============ 回调地址 ============ // OAuth2客户端回调地址(授权服务器授权成功后重定向到此地址) // 格式: {客户端地址}/login/oauth2/code/{registrationId} // http://localhost:8080 = cloud-gateway(网关)地址 // /login/oauth2/code/cloud-gateway = Spring Security OAuth2默认回调端点 // cloud-gateway = registrationId(必须与clientId一致) .redirectUri("http://localhost:8080/login/oauth2/code/cloud-gateway") // ============ 登出回调地址 ============ // 单点登出成功后重定向到网关首页 .postLogoutRedirectUri("http://localhost:8080") // ============ 授权范围 ============ // openid: OIDC标识(必须,用于OIDC协议) // profile: 用户信息(用户名、昵称等) // roles: 角色权限(用户拥有的角色列表) .scope("openid") .scope("profile") .scope("roles") // ============ 客户端设置 ============ // requireAuthorizationConsent: 是否需要用户授权确认页面 // - true: 用户每次授权都需要确认权限 // - false: 自动授权,不显示确认页面 // requireProofKey: 是否要求PKCE // - 机密客户端不需要 PKCE(服务端通信更安全) // - 公开客户端必须启用 PKCE .clientSettings(ClientSettings.builder() .requireAuthorizationConsent(false) // 不需要授权确认 .requireProofKey(false) // 机密客户端不需要 PKCE .build()) // ============ 令牌设置 ============ // accessTokenTimeToLive: 访问令牌有效期 // - Duration.ofHours(1) = 1小时 // - PT1H = ISO 8601格式,等同于1小时 // refreshTokenTimeToLive: 刷新令牌有效期 // - Duration.ofDays(7) = 7天 // - PT7D = ISO 8601格式,等同于7天 // reuseRefreshTokens: 刷新令牌是否可重用 // - true: 刷新令牌使用后不失效,可重复使用 // - false: 每次刷新后旧令牌失效(更安全) .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofHours(1)) // JWT有效期1小时 .refreshTokenTimeToLive(Duration.ofDays(7)) // 刷新令牌有效期7天 .reuseRefreshTokens(true) // 刷新令牌可重用 .build()) // 构建完成,生成RegisteredClient对象 .build(); // ============ 保存到数据库 ============ // 调用save方法后,数据会写入oauth2_registered_client表 registeredClientRepository.save(gatewayClient); System.out.println("网关客户端保存成功!"); // ============ 验证保存结果 ============ // 通过findByClientId查询验证数据是否正确保存 RegisteredClient savedGatewayClient = registeredClientRepository.findByClientId("cloud-gateway"); System.out.println("\n=== 网关客户端信息 ==="); System.out.println("Client ID: " + savedGatewayClient.getClientId()); System.out.println("Client Name: " + savedGatewayClient.getClientName()); System.out.println("Authentication Methods: " + savedGatewayClient.getClientAuthenticationMethods()); System.out.println("Client Secret: " + (savedGatewayClient.getClientSecret() != null ? "已设置" : "未设置")); System.out.println("Require PKCE: " + savedGatewayClient.getClientSettings().isRequireProofKey()); System.out.println("Scopes: " + savedGatewayClient.getScopes()); } /** * 查询已注册的客户端 */ @Test public void findClients() { RegisteredClient gatewayClient = registeredClientRepository.findByClientId("cloud-gateway"); if (gatewayClient != null) { System.out.println("=== 网关客户端 ==="); System.out.println("ID: " + gatewayClient.getId()); System.out.println("Client ID: " + gatewayClient.getClientId()); System.out.println("Client Name: " + gatewayClient.getClientName()); System.out.println("Authentication Methods: " + gatewayClient.getClientAuthenticationMethods()); System.out.println("Grant Types: " + gatewayClient.getAuthorizationGrantTypes()); System.out.println("Redirect URIs: " + gatewayClient.getRedirectUris()); System.out.println("Require PKCE: " + gatewayClient.getClientSettings().isRequireProofKey()); System.out.println("Scopes: " + gatewayClient.getScopes()); } else { System.out.println("未找到客户端 cloud-gateway"); } } }
(2)、网关服务器核心配置(cloud-gateway):
1)、GatewaySecurityConfig.java
BFF 层安全配置,作为 OAuth2 客户端处理登录流程
/** * Gateway 安全配置类(BFF 层) * * 职责说明: * - 作为 OAuth2 客户端,处理授权码流程 * - 在 Session 中保存 JWT Token * - 自动将 JWT 透传到下游资源服务器(TokenRelay) * - 提供自定义登出端点 /api/auth/logout * * 架构说明: * 前端 → 网关(OAuth2 Client + Session)→ 资源服务器(JWT 验证) */ @Configuration @EnableWebFluxSecurity public class GatewaySecurityConfig { @Bean public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { http // OAuth2 Client(授权码流程) // 前端访问 /oauth2/authorization/cloud-gateway 时,自动跳转到授权服务器 // .oauth2Client(Customizer.withDefaults()) // OAuth2 登录(授权码流程) // 自动处理授权码换取 Token 的逻辑 .oauth2Login(Customizer.withDefaults()) // 注意:网关不需要配置 oauth2ResourceServer // 网关作为 BFF 层,只需要 OAuth2 Client 功能 // 前端请求携带 Cookie,网关从 Session 中取出 JWT 透传到下游 // 资源服务器才需要配置 oauth2ResourceServer 验证 JWT // 授权规则 .authorizeExchange(exchanges -> exchanges // 公开接口(无需认证) .pathMatchers("/*/public/**").permitAll() // 自定义登出端点(无需认证) .pathMatchers("/api/auth/logout").permitAll() // 其他所有请求需要认证 .anyExchange().authenticated() ) // 禁用 CSRF .csrf(csrf -> csrf.disable()); return http.build(); } /** * OAuth2 客户端存储仓库 * 将 OAuth2 客户端信息(包括 JWT Token)存储在 WebSession 中 * * 作用: * - 保存授权码换取的 JWT Token * - TokenRelay 过滤器从此处获取 Token 并透传到下游 */ @Bean public ServerOAuth2AuthorizedClientRepository authorizedClientRepository() { return new WebSessionServerOAuth2AuthorizedClientRepository(); } }
|
配置 |
说明 |
|
网关不需要配置 oauth2ResourceServer: |
网关作为 BFF 层,前端请求携带 Session Cookie,而非 JWT |
|
TokenRelay 过滤器: |
自动从 Session 中取出 JWT,添加到转发请求的 Authorization 头中 |
|
Session 存储: |
JWT 存储在服务端 Session 中,浏览器通过 JSESSIONID Cookie 访问 |
2)、LogoutController.java
网关登出控制器,处理单点登出请求,清除网关 Session 并重定向到授权服务器完成登出
@RestController public class LogoutController { /** * 登出接口 * * 登出逻辑: * - 删除Session会话 * - 重定向到登录页面 */ @GetMapping("/api/auth/logout") public Mono<Void> logout(ServerWebExchange exchange) { return exchange.getSession() .doOnNext(session -> session.invalidate()) .then(Mono.defer(() -> { String redirectUri = "http://localhost:9001/api/auth/logout?redirect_uri=http://localhost:8080"; exchange.getResponse().setStatusCode(HttpStatus.FOUND); exchange.getResponse().getHeaders().setLocation(URI.create(redirectUri)); return exchange.getResponse().setComplete(); })); } }
|
登出流程说明: |
|
1、前端访问 GET /api/auth/logout(携带 JSESSIONID Cookie) |
|
2、网关获取并销毁 WebSession(清除存储的 OAuth2AuthorizedClient 和 JWT) |
|
3、重定向到授权服务器登出端点 http://localhost:9001/api/auth/logout |
|
4、授权服务器清除 SecurityContext 和 HttpSession |
|
5、授权服务器重定向回网关首页 |
(3)、资源服务器配置(cloud-clientA/B):
1)、ClientSecurityConfig.java
/** * 客户端A安全配置类 * * 职责: * - 配置OAuth2 Resource Server(验证JWT令牌) * * 说明: * - oauth2ResourceServer: 验证JWT令牌 * - SessionCreationPolicy.STATELESS: 无状态,不保存Session */ @Configuration @EnableWebSecurity @EnableMethodSecurity(prePostEnabled = true) public class ClientSecurityConfig { /** * 配置安全过滤器链 */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // 配置OAuth2资源服务器(验证JWT) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) ) // Session管理:无状态(纯JWT验证,不需要Session) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) // 配置授权规则 .authorizeHttpRequests(authorize -> authorize .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ) // 禁用CSRF(OAuth2使用JWT) .csrf(csrf -> csrf.disable()); return http.build(); } /** * JWT权限转换器:把JWT中的roles/authorities字段解析为Spring Security可识别的权限 * 适配@PreAuthorize("hasRole('USER')")注解 */ @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); // 指定JWT中存储角色的claim名称,根据授权服务器的实际配置调整 grantedAuthoritiesConverter.setAuthoritiesClaimName("authorities"); // 角色前缀,默认添加ROLE_,和hasRole对应 grantedAuthoritiesConverter.setAuthorityPrefix(""); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); return jwtAuthenticationConverter; } }
2)、HomeController.java
资源服务器业务接口,演示不同权限级别的接口访问控制
/** * 客户端A控制器 * * 演示资源服务器的权限控制: * - 公开接口:无需认证 * - 用户接口:需要USER角色 * - 管理接口:需要ADMIN角色 * - 使用UserContextUtil获取当前用户信息 */ @RestController public class HomeController { /** * 公开接口(无需认证) */ @GetMapping("/public/hello") public Map<String, Object> publicHello() { Map<String, Object> response = new HashMap<>(); response.put("message", "这是公开接口,无需认证"); response.put("client", "cloud-clientA"); return response; } /** * 用户接口(需要USER角色) */ @GetMapping("/user/info") @PreAuthorize("hasRole('USER')") public Map<String, Object> userInfo() { Map<String, Object> response = new HashMap<>(); response.put("message", "用户信息接口"); response.put("userId", UserContextUtil.getCurrentUserId()); response.put("username", UserContextUtil.getCurrentUsername()); response.put("roles", UserContextUtil.getCurrentUserRoles()); return response; } /** * 管理接口(需要ADMIN角色) */ @GetMapping("/admin/info") @PreAuthorize("hasRole('ADMIN')") public Map<String, Object> adminInfo() { Map<String, Object> response = new HashMap<>(); response.put("message", "管理员信息接口"); response.put("userId", UserContextUtil.getCurrentUserId()); response.put("username", UserContextUtil.getCurrentUsername()); response.put("roles", UserContextUtil.getCurrentUserRoles()); return response; } /** * 测试接口(已认证即可访问) */ @GetMapping("/api/hello") public Map<String, Object> hello() { Map<String, Object> response = new HashMap<>(); response.put("message", "Hello from Client A!"); response.put("username", UserContextUtil.getCurrentUsername()); response.put("token", UserContextUtil.getCurrentToken() != null ? "已获取令牌" : "未获取令牌"); return response; } }
3)、UserContextUtil.java
用户上下文工具类
/** * 用户上下文工具类 * * 职责说明: * - 提供获取当前用户信息的便捷方法 * - 从SecurityContext中提取JwtAuthenticationToken * - 兼容上一阶段的API,业务代码零改动 * * 使用示例: * ```java * // 获取当前用户ID * Long userId = UserContextUtil.getCurrentUserId(); * * // 获取当前用户名 * String username = UserContextUtil.getCurrentUsername(); * * // 获取当前用户角色列表 * List<String> roles = UserContextUtil.getCurrentUserRoles(); * ``` */ public class UserContextUtil { /** * 获取当前认证对象 * * @return Authentication对象,如果未认证返回null */ public static Authentication getAuthentication() { return SecurityContextHolder.getContext().getAuthentication(); } /** * 判断当前用户是否已认证 * * @return 是否已认证 */ public static boolean isAuthenticated() { Authentication authentication = getAuthentication(); return authentication != null && authentication.isAuthenticated(); } /** * 获取当前用户ID * * @return 用户ID,如果未认证返回null */ public static Long getCurrentUserId() { if (!isAuthenticated()) { return null; } Jwt jwt = getJwt(); if (jwt == null) { return null; } Object userId = jwt.getClaim("user_id"); if (userId instanceof Number) { return ((Number) userId).longValue(); } return null; } /** * 获取当前用户名 * * @return 用户名,如果未认证返回null */ public static String getCurrentUsername() { if (!isAuthenticated()) { return null; } Jwt jwt = getJwt(); if (jwt == null) { return null; } return jwt.getClaim("username"); } /** * 获取当前用户角色列表 * * @return 角色列表,如果未认证返回空列表 */ @SuppressWarnings("unchecked") public static List<String> getCurrentUserRoles() { if (!isAuthenticated()) { return List.of(); } Jwt jwt = getJwt(); if (jwt == null) { return List.of(); } return jwt.getClaim("authorities"); } /** * 获取当前用户的JWT对象 * * @return Jwt对象,如果未认证或不是JWT认证返回null */ public static Jwt getJwt() { Authentication authentication = getAuthentication(); if (authentication instanceof JwtAuthenticationToken) { JwtAuthenticationToken jwtToken = (JwtAuthenticationToken) authentication; return jwtToken.getToken(); } return null; } /** * 获取当前用户的完整JWT字符串 * * @return JWT字符串,如果未认证返回null */ public static String getCurrentToken() { Jwt jwt = getJwt(); return jwt != null ? jwt.getTokenValue() : null; } /** * 判断当前用户是否具有指定角色 * * @param role 角色名称(不含ROLE_前缀) * @return 是否具有该角色 */ public static boolean hasRole(String role) { List<String> roles = getCurrentUserRoles(); if (roles == null) { return false; } // 支持带ROLE_前缀和不带前缀两种格式 String roleWithPrefix = role.startsWith("ROLE_") ? role : "ROLE_" + role; return roles.contains(roleWithPrefix); } /** * 判断当前用户是否具有指定权限 * * @param authority 权限名称 * @return 是否具有该权限 */ public static boolean hasAuthority(String authority) { List<String> authorities = getCurrentUserRoles(); if (authorities == null) { return false; } return authorities.contains(authority); } /** * 清理当前用户上下文 */ public static void clearContext() { SecurityContextHolder.clearContext(); } }
六、测试与验证:
1、单点登录测试:
|
步骤 |
操作 |
预期结果 |
|
1 |
显示公开内容 |
|
|
2 |
自动重定向到登录页 |
|
|
3 |
输入用户名密码(admin/123456) |
登录成功后重定向回网关 |
|
4 |
成功获取资源 |
|
|
5 |
同样成功(单点登录) |
2、角色权限测试:
|
步骤 |
操作 |
预期结果 |
|
1 |
切换到 admin 用户登录 |
登录成功 |
|
2 |
admin 用户访问 http://localhost:8080/clientA/admin/info |
成功访问 |
|
3 |
admin 用户访问 http://localhost:8080/clientA/user/info |
返回 403 Forbidden |
|
4 |
切换到 user 用户登录 |
登录成功 |
|
5 |
user 用户访问 http://localhost:8080/clientA/admin/info |
返回 403 Forbidden |
|
6 |
user 用户访问 http://localhost:8080/clientA/user/info |
成功访问 |
3、单点登出测试:
|
步骤 |
操作 |
预期结果 |
|
1 |
重定向到网关首页 |
|
|
2 |
重定向到登录页 |

浙公网安备 33010602011771号