阶段四:从单体无状态认证到微服务统一身份治理(基于 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 最新安全规范,因此被彻底废弃

25

2)、授权码模式(Authorization Code):

核心定义

OAuth2.0 官方推荐、安全等级最高的标准模式,通过临时授权码间接换取令牌,全程不传递密码、不直接暴露令牌至前端。

适用场景

Web 端第三方登录、外部 Web 应用、小程序、开放平台接入等不可信外部场景,是企业 Web 端授权、单点登录(SSO)的工业标准。

26

3)、客户端凭证模式(Client Credentials):

核心定义

无用户参与的纯服务级授权模式,客户端以自身 ID 与密钥申请令牌,仅用于微服务 / 服务器间自动化接口调用。

适用场景

微服务集群内部服务调用,如订单服务调用用户服务、定时任务同步数据、后台服务互相访问等机器间交互场景。

27

4)、简化/隐式模式(Implicit):

核心定义

早期简化授权模式,授权服务器直接将令牌返回前端,无需后端参与交换。

安全缺陷

牌直接暴露在浏览器地址栏,易被劫持窃取,无有效安全防护机制。

行业现状

OAuth2.1 已正式废弃该模式,企业级项目均禁止使用。

28

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、项目结构:

29

技术

版本

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 登录流程、管理用户会话,并代理前端对资源服务器的访问。

30

步骤

组件

操作

请求详情

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
username=admin&password=123456

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、单点登出流程:

31

步骤

组件

操作

1

前端

用户点击登出按钮,访问网关登出端点

2

网关

清除 WebSession(销毁存储的 OAuth2AuthorizedClient 和 JWT)

3

网关

重定向到授权服务器登出端点

4

授权服务器

清除 SecurityContext,销毁 HttpSession

5

授权服务器

重定向回网关首页

6

前端

用户回到未登录状态,需要重新登录才能访问受保护资源

5、请求访问控制流程:

32

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、核心实现流程:

33

(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

浏览器访问 http://localhost:8080/clientA/public/hello

显示公开内容

2

访问 http://localhost:8080/clientA/api/hello

自动重定向到登录页

3

输入用户名密码(admin/123456)

登录成功后重定向回网关

4

访问 http://localhost:8080/clientA/api/hello

成功获取资源

5

访问 http://localhost:8080/clientB/api/hello

同样成功(单点登录)

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

已登录状态下访问 http://localhost:8080/api/auth/logout

重定向到网关首页

2

再次访问 http://localhost:8080/clientA/api/hello

重定向到登录页

34

 

posted on 2026-06-24 00:19  爱文(Iven)  阅读(22)  评论(0)    收藏  举报

导航