Appearance
CAS OAuth2.0 JWT AccessToken 签名与加密:从原理到生产级深度定制
作者: 必码 | bima.cc
前言:当 OAuth2.0 遇上 JWT,企业级安全认证的终极形态
在当今企业级应用架构中,OAuth 2.0 已经成为事实上的授权标准协议。无论是微服务之间的 API 调用、移动应用的登录认证,还是第三方系统的单点登录集成,OAuth 2.0 都扮演着不可替代的角色。而作为企业级单点登录(SSO)的事实标准,Apereo CAS 对 OAuth 2.0 的支持,使得"SSO + OAuth 2.0 一体化"的架构模式成为可能——企业无需额外部署 Keycloak、Auth0 等独立授权服务器,即可在已有的 CAS 基础设施上提供完整的 OAuth 2.0 服务。
然而,在实际项目中,我们发现一个普遍存在的痛点:大多数开发者在使用 CAS OAuth 2.0 时,仅仅停留在"能用"的阶段,对 Token 的签名、加密、生命周期管理等核心安全机制缺乏深入理解。 这种认知上的缺失,往往会导致以下问题:
痛点一:Token 安全性不足。 默认的 opaque token(不透明令牌)虽然简单,但在分布式系统中需要频繁回调授权服务器进行 Token 验证,不仅增加了网络开销,还引入了单点故障风险。而切换到 JWT(JSON Web Token)后,如果签名密钥管理不当,又会面临 Token 伪造的严重安全威胁。
痛点二:跨版本升级困难。 CAS 从 5.3 到 6.6 再到 7.3,配置体系经历了从 camelCase 到 kebab-case 的命名规范变化,加密配置从分散到统一,API 接口从 Java EE 到 Jakarta EE 的全面迁移。这些变化使得很多团队在升级过程中踩坑无数。
痛点三:用户信息定制化需求难以满足。 CAS 默认的 OAuth 2.0 用户信息返回格式往往无法满足业务需求——需要添加自定义字段、修改返回结构、甚至完全重写用户信息的生成逻辑。而 CAS 在不同版本中提供的扩展点差异巨大,从 5.3 的反射获取用户 ID 到 7.3 的直接 API 调用,演进路径并不平滑。
痛点四:生产环境安全加固缺乏指导。 密钥如何轮换?Token 有效期如何合理设置?回调域名如何做白名单校验?这些生产环境中的关键安全实践,在官方文档中往往一笔带过,缺乏系统性的指导。
本文正是基于我们团队在多个大型项目中实际完成的 CAS OAuth 2.0 深度定制经验,从 CAS 5.3、6.6 到 7.3 三个版本的演进视角,全面解析 JWT AccessToken 的签名与加密机制。文章内容基于实际项目源码分析整理,代码示例均已转化为教学简化版本,旨在帮助开发者深入理解 CAS OAuth 2.0 的安全内核,并为生产环境的部署提供可落地的最佳实践。
无论你是正在评估 CAS OAuth 2.0 的技术决策者,还是负责实施落地的架构师和开发者,本文都将为你提供从理论到实践的完整知识体系。让我们开始这场深入 CAS OAuth 2.0 安全内核的技术之旅。
一、OAuth 2.0 AccessToken 机制概述
1.1 OAuth 2.0 协议中的 Token 体系
在深入 CAS 的具体实现之前,我们需要先建立对 OAuth 2.0 Token 体系的完整认知。OAuth 2.0 协议(RFC 6749)定义了两种核心令牌类型:Access Token 和 Refresh Token。理解它们的职责边界和协作关系,是后续讨论 JWT 签名与加密的基础。
Access Token 是 OAuth 2.0 体系中最核心的令牌,它代表资源所有者(用户)授予客户端的访问权限。客户端在每次访问受保护资源时,都需要携带 Access Token。从协议层面来说,Access Token 具有以下特征:
- 不透明性(Opaqueness): RFC 6749 将 Access Token 定义为不透明字符串,即客户端和资源服务器不应尝试解析 Token 的内部结构。Token 的含义只有授权服务器理解。
- 绑定性: Access Token 绑定了特定的客户端(client_id)、用户(resource owner)和权限范围(scope)。
- 时效性: Access Token 具有有限的有效期,过期后需要通过 Refresh Token 获取新的 Access Token,或者重新发起授权流程。
Refresh Token 是用于获取新 Access Token 的凭证。它的有效期通常远长于 Access Token,主要解决以下问题:
- 用户体验: 避免用户频繁重新登录。当 Access Token 过期时,客户端可以使用 Refresh Token 静默获取新的 Access Token。
- 安全性: Access Token 的短有效期降低了 Token 泄露后的安全风险窗口。即使 Access Token 被截获,攻击者能利用的时间也很有限。
- 令牌轮换: 每次使用 Refresh Token 获取新 Access Token 时,授权服务器可以选择签发新的 Refresh Token 并使旧的失效,实现令牌轮换(Token Rotation)。
在 CAS OAuth 2.0 的实现中,这两种 Token 的管理方式会根据配置的不同而有所差异。特别是当启用 JWT AccessToken 后,Token 的本质和验证方式都会发生根本性的变化。
1.2 CAS 中 OAuth 2.0 的四种授权模式
CAS 完整实现了 RFC 6749 中定义的全部四种授权模式,每种模式适用于不同的应用场景。理解这些模式的工作原理和适用场景,是合理设计 OAuth 2.0 授权架构的前提。
1.2.1 授权码模式(Authorization Code Grant)
授权码模式是 OAuth 2.0 中安全性最高的授权类型,也是 CAS 中默认启用且推荐使用的模式。它引入了一个中间凭证——授权码(Authorization Code),通过授权码交换 Access Token 的两步流程,确保 Access Token 不会直接暴露在浏览器中。
授权码模式的完整流程如下:
步骤1: Client → User Agent → CAS Authorization Endpoint
GET /oauth2.0/authorize?response_type=code&client_id=xxx&redirect_uri=xxx&scope=xxx
步骤2: CAS 验证用户会话,未登录则展示登录页面
步骤3: 用户登录成功后,CAS 展示授权确认页面
步骤4: CAS 生成授权码,重定向回客户端
HTTP 302 → redirect_uri?code=AUTHORIZATION_CODE&state=xxx
步骤5: Client Backend → CAS Token Endpoint
POST /oauth2.0/accessToken
grant_type=authorization_code&code=AUTHORIZATION_CODE&client_id=xxx&client_secret=xxx&redirect_uri=xxx
步骤6: CAS 验证授权码,签发 Access Token(和 Refresh Token)
HTTP 200 → { access_token: "xxx", token_type: "bearer", expires_in: 7200, refresh_token: "yyy" }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在 CAS 中,授权码(OAuth20AuthorizationCode)是一个临时的、一次性的凭证。它默认的有效期非常短(通常为 30 秒),且只能使用一次。这种设计确保了即使授权码在传输过程中被截获,攻击者也无法利用它(因为授权码可能已经过期或已被使用)。
授权码模式特别适用于以下场景:
- 有服务端的 Web 应用: 后端可以安全地保存 client_secret,在服务端完成授权码到 Access Token 的交换。
- 移动应用: 可以通过 PKCE(Proof Key for Code Exchange)增强安全性,避免 client_secret 泄露风险。
- 需要长期访问权限的场景: 授权码模式支持签发 Refresh Token,实现长期访问。
1.2.2 隐式模式(Implicit Grant)
隐式模式省略了授权码交换的步骤,Access Token 直接通过 URL 片段(#)返回给客户端。这种模式主要设计用于纯前端应用(如单页应用 SPA)。
Client → CAS Authorization Endpoint
GET /oauth2.0/authorize?response_type=token&client_id=xxx&redirect_uri=xxx
CAS → User Login & Consent
CAS → Redirect to client
HTTP 302 → redirect_uri#access_token=xxx&token_type=bearer&expires_in=7200&state=xxx1
2
3
4
5
6
7
2
3
4
5
6
7
重要安全提示: 由于 Access Token 直接暴露在浏览器 URL 中,隐式模式存在显著的安全风险。在 CAS 7.x 中,强烈建议使用 PKCE 增强的授权码模式替代隐式模式。PKCE 允许公开客户端(如 SPA)安全地使用授权码模式,而无需 client_secret。
1.2.3 资源所有者密码模式(Resource Owner Password Credentials Grant)
资源所有者密码模式允许客户端直接使用用户的用户名和密码获取 Access Token。这种模式跳过了用户交互的授权确认步骤,适用于高度信任的客户端场景。
POST /oauth2.0/accessToken
Content-Type: application/x-www-form-urlencoded
grant_type=password&username=user@example.com&password=xxx&client_id=xxx&client_secret=xxx1
2
3
4
2
3
4
在 CAS 7.3 中,可以通过以下配置增强密码模式的安全性:
yaml
# 教学示例 - CAS 7.3 application.yml
cas:
oauth:
grants:
resource-owner:
# 要求请求中必须携带特定的 Service Header
require-service-header: true1
2
3
4
5
6
7
2
3
4
5
6
7
当 require-service-header 设置为 true 时,只有携带了特定 Service Header 的请求才能使用密码模式。这是一种额外的安全层,确保只有受信任的内部系统才能使用此模式。
安全警告: 密码模式要求客户端必须高度可信,因为客户端会直接接触用户的凭据。在公开客户端(如浏览器应用、移动 App)中,应严格避免使用此模式。
1.2.4 客户端凭证模式(Client Credentials Grant)
客户端凭证模式适用于服务器对服务器(M2M)的通信场景,不涉及用户参与。客户端使用自己的 client_id 和 client_secret 获取 Access Token。
POST /oauth2.0/accessToken
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=xxx&client_secret=xxx1
2
3
4
2
3
4
在 CAS 中,客户端凭证模式签发的 Token 不关联任何用户身份,仅代表客户端应用本身的身份。这种模式适用于以下场景:
- 定时任务和批处理系统之间的 API 调用
- 微服务之间的内部通信
- 系统集成和自动化运维脚本
1.3 AccessToken 的生命周期管理
AccessToken 的生命周期管理是 OAuth 2.0 安全架构的核心组成部分。在 CAS 中,Token 的生命周期涉及创建、存储、验证、续期和销毁五个阶段。理解每个阶段的实现细节,对于构建安全可靠的 OAuth 2.0 系统至关重要。
1.3.1 Token 创建阶段
当 CAS 接收到合法的 Token 请求后,会根据授权类型创建相应的 Token 实体。在 CAS 的内部实现中,Token 的创建过程涉及多个组件的协作:
Token Request → OAuth20TokenGenerator → OAuth20AccessTokenFactory → OAuth20AccessToken1
Token 创建的核心逻辑包括:
- 唯一标识生成: 每个 Token 都需要一个全局唯一的标识符。CAS 使用
UUID或自定义的 ID 生成策略来确保 Token ID 的唯一性。 - 属性填充: Token 的属性包括 client_id、scope、用户标识、创建时间、过期时间等。这些属性决定了 Token 的权限边界和有效范围。
- 签名与加密: 如果启用了 JWT AccessToken,Token 在创建时就会进行签名和加密处理(详见后续章节)。
- 持久化存储: Token 创建后需要持久化存储,以便后续的验证和撤销操作。
1.3.2 Token 存储机制
CAS 支持多种 Token 存储后端,不同的存储后端对 Token 生命周期管理有显著影响:
| 存储后端 | 特点 | 适用场景 |
|---|---|---|
| 内存存储(Default) | 简单高效,但不支持集群共享 | 开发测试环境、单节点部署 |
| Redis | 支持集群共享,自动过期 | 生产环境、多节点集群部署 |
| 数据库(JPA/Hibernate) | 持久化存储,支持审计 | 需要长期审计的场景 |
| Hazelcast | 分布式内存网格,高性能 | 大规模集群部署 |
在生产环境中,我们强烈推荐使用 Redis 作为 Token 存储后端。Redis 的自动过期机制(TTL)天然适合 Token 的生命周期管理,而且其高性能和集群支持能力可以满足企业级部署的需求。
1.3.3 Token 验证阶段
Token 验证是资源服务器(或 API 网关)在每次接收到请求时执行的操作。验证的严格程度取决于 Token 的类型:
- Opaque Token: 资源服务器需要回调 CAS 的 Token 自省端点(
/oauth2.0/introspect)来验证 Token 的有效性。这意味着每次 API 请求都会产生一次网络调用。 - JWT Token: 资源服务器可以本地验证 JWT 的签名和过期时间,无需回调授权服务器。这显著减少了网络开销,提高了系统的响应速度和可用性。
1.3.4 Token 续期机制
当 Access Token 过期后,客户端可以使用 Refresh Token 获取新的 Access Token。在 CAS 中,Refresh Token 的续期机制支持以下策略:
- 直接续期: 使用 Refresh Token 获取新的 Access Token,同时保留原 Refresh Token。
- 令牌轮换(Token Rotation): 每次使用 Refresh Token 时,CAS 签发新的 Access Token 和新的 Refresh Token,同时使旧的 Refresh Token 失效。这种策略可以检测 Refresh Token 的重放攻击。
1.3.5 Token 销毁机制
Token 的销毁可以通过以下方式触发:
- 自然过期: Token 到达其过期时间后自动失效。
- 主动撤销: 用户或管理员通过 Token 撤销端点(
/oauth2.0/revoke)主动撤销 Token。 - 用户登出: 用户登出 CAS 时,所有关联的 Token 都会被撤销。
- 服务禁用: 当 OAuth 2.0 服务定义被禁用时,所有关联的 Token 都会失效。
1.4 传统 Opaque Token vs JWT Token 深度对比
这是本文最核心的对比分析,理解 Opaque Token 和 JWT Token 的本质差异,是选择合适 Token 策略的关键。
1.4.1 Opaque Token(不透明令牌)
Opaque Token 是 OAuth 2.0 协议最初定义的 Token 形式。它本质上是一个随机生成的字符串,其内部含义只有授权服务器能够理解。
Opaque Token 的典型结构:
AT-2_xYzAbCdEfGhIjKlMnOpQrStUvWx1
这个字符串本身不携带任何信息,它只是一个指向授权服务器内部存储的"指针"。资源服务器在接收到这个 Token 后,必须回调授权服务器进行验证。
Opaque Token 的验证流程:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Resource │ │ Auth │ │ Token │
│ Server │────>│ Server │────>│ Store │
│ │<────│ (CAS) │<────│ (Redis/DB) │
└──────────────┘ └──────────────┘ └──────────────┘
请求携带Token 回调验证Token 查询Token信息1
2
3
4
5
6
2
3
4
5
6
Opaque Token 的优势:
- 即时撤销能力: 由于每次验证都需要回调授权服务器,Token 的撤销可以立即生效。
- 安全性高: Token 本身不携带任何信息,即使被截获也无法解析出用户数据。
- 协议兼容性好: 完全符合 RFC 6749 的原始定义。
Opaque Token 的劣势:
- 网络开销大: 每次 API 请求都需要一次额外的网络调用(Token 验证)。
- 单点故障风险: 授权服务器不可用时,所有 API 请求都会失败。
- 性能瓶颈: 在高并发场景下,Token 验证可能成为系统的性能瓶颈。
1.4.2 JWT Token(JSON Web Token)
JWT(JSON Web Token,RFC 7519)是一种基于 JSON 的开放标准(RFC 7515),用于在各方之间安全地传输信息。当 CAS 启用 JWT AccessToken 后,Access Token 的本质就从"指针"变成了"自包含的数据包"。
JWT Token 的典型结构:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. ← Header(头部)
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. ← Payload(载荷)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature(签名)1
2
3
2
3
JWT 由三部分组成,每部分之间用点号(.)分隔:
Header(头部): 描述 Token 的元信息,通常包含签名算法和 Token 类型。
json
{
"alg": "HS256",
"typ": "JWT"
}1
2
3
4
2
3
4
Payload(载荷): 携带实际的声明信息(Claims)。CAS 在 JWT Payload 中会包含以下标准声明和自定义声明:
json
{
"sub": "1234567890",
"aud": "client-app-id",
"iss": "https://cas.example.com",
"exp": 1735689600,
"iat": 1735682400,
"jti": "unique-token-id",
"scope": "openid profile email",
"client_id": "client-app-id",
"attributes": {
"displayName": "张三",
"email": "zhangsan@example.com"
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
Signature(签名): 使用密钥对 Header 和 Payload 进行签名,确保 Token 的完整性和真实性。
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
signing-key
)1
2
3
4
2
3
4
JWT Token 的验证流程:
┌──────────────┐ ┌──────────────┐
│ Resource │ │ Local │
│ Server │────>│ Verify │
│ │<────│ (签名校验) │
└──────────────┘ └──────────────┘
本地验证Token 无需网络调用1
2
3
4
5
6
2
3
4
5
6
JWT Token 的优势:
- 无状态验证: 资源服务器可以本地验证 Token,无需回调授权服务器。
- 高性能: 消除了 Token 验证的网络开销,显著提高 API 响应速度。
- 跨服务共享: 同一个 JWT 可以被多个微服务独立验证,无需共享 Token 存储。
- 自包含信息: Token 本身携带用户身份和权限信息,减少额外的数据查询。
JWT Token 的劣势:
- 撤销困难: 由于 Token 是自包含的,一旦签发就无法即时撤销(除非使用 Token 黑名单机制)。
- 体积较大: JWT 的 Base64 编码使其体积远大于 Opaque Token,可能影响 HTTP Header 的大小。
- 密钥管理复杂: 签名密钥的安全性至关重要,密钥泄露意味着所有 Token 都可以被伪造。
1.4.3 选型决策矩阵
基于我们的项目经验,以下是 Token 选型的决策参考:
| 评估维度 | Opaque Token | JWT Token |
|---|---|---|
| 验证方式 | 回调授权服务器 | 本地验证 |
| 性能开销 | 高(每次网络调用) | 低(本地计算) |
| 撤销能力 | 即时撤销 | 延迟撤销(需黑名单) |
| 跨服务验证 | 需要共享存储 | 天然支持 |
| 安全性 | 高(无信息泄露) | 取决于密钥管理 |
| 适用场景 | 高安全要求、需即时撤销 | 微服务架构、高性能要求 |
| CAS 配置复杂度 | 低(默认) | 中(需配置密钥) |
我们的建议: 在微服务架构中,优先选择 JWT AccessToken。CAS 7.3 提供了完善的 JWT 签名和加密配置,可以同时满足安全性和性能的需求。对于需要即时撤销能力的场景,可以结合 Redis Token 黑名单机制实现。
二、CAS OAuth 2.0 配置体系深度解析
2.1 CAS 配置体系的演进
CAS 的配置体系经历了从简单到复杂、从分散到统一的演进过程。理解这种演进,对于在不同版本中正确配置 OAuth 2.0 至关重要。
2.1.1 CAS 5.3 的配置风格
CAS 5.3 基于 Spring Boot 1.5.x,配置属性主要使用 camelCase 命名风格。在 CAS 5.3 中,OAuth 2.0 的配置通常直接写在 application.properties 中:
properties
# 教学示例 - CAS 5.3 配置风格
# OAuth 2.0 Token 有效期配置(单位:秒)
cas.oauth.accessToken.timeToKillInSeconds=7200
cas.oauth.code.timeToKillInSeconds=30
cas.oauth.refreshToken.timeToKillInSeconds=25920001
2
3
4
5
2
3
4
5
这种配置风格的特点是属性名较长,且嵌套关系通过点号(.)表达。虽然直观,但随着配置项的增多,属性名变得冗长且不易管理。
2.1.2 CAS 6.6 的过渡风格
CAS 6.6 基于 Spring Boot 2.7.x,开始引入 kebab-case 命名风格,但仍然兼容 camelCase。这是一个过渡期,两种命名风格可以混用:
yaml
# 教学示例 - CAS 6.6 配置风格(两种命名并存)
cas:
oauth:
# camelCase 风格(兼容旧版)
accessToken:
timeToKillInSeconds: 7200
# kebab-case 风格(推荐新版)
access-token:
time-to-kill-in-seconds: 72001
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
2.1.3 CAS 7.3 的统一风格
CAS 7.3 基于 Spring Boot 3.5.x,全面采用 kebab-case 命名风格,并使用 YAML 格式作为主要配置文件格式。同时,配置结构更加模块化和层次化:
yaml
# 教学示例 - CAS 7.3 配置风格
cas:
oauth:
access-token:
time-to-kill-in-seconds: 7200
code:
time-to-kill-in-seconds: 30
refresh-token:
time-to-kill-in-seconds: 2592000
user-profile-view-type: NESTED
grants:
resource-owner:
require-service-header: true1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
跨版本迁移建议: 如果你正在从 CAS 5.3 或 6.6 升级到 7.3,建议在升级前将所有配置属性统一转换为 kebab-case 风格。CAS 7.3 虽然仍然兼容部分 camelCase 属性,但官方已明确表示将在未来版本中移除对旧命名风格的支持。
2.2 Token 有效期配置详解
Token 有效期是 OAuth 2.0 安全策略中最基础的配置项。合理的有效期设置需要在安全性(短有效期降低风险窗口)和用户体验(长有效期减少重新认证频率)之间取得平衡。
2.2.1 三种 Token 的有效期配置
CAS OAuth 2.0 中有三种核心 Token,每种都有独立的有效期配置:
yaml
# 教学示例 - Token 有效期配置
cas:
oauth:
# 授权码有效期(默认 30 秒)
# 授权码是一次性凭证,有效期应设置得非常短
code:
time-to-kill-in-seconds: 30
# Access Token 有效期(默认 7200 秒 = 2 小时)
# 这是用户访问 API 的凭证,有效期需要根据业务场景调整
access-token:
time-to-kill-in-seconds: 7200
# Refresh Token 有效期(默认 2592000 秒 = 30 天)
# Refresh Token 用于获取新的 Access Token,有效期可以设置得较长
refresh-token:
time-to-kill-in-seconds: 25920001
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
有效期设置的最佳实践:
| Token 类型 | 开发环境 | 测试环境 | 生产环境 | 说明 |
|---|---|---|---|---|
| Authorization Code | 60s | 30s | 10-30s | 越短越好,一次性使用 |
| Access Token | 28800s (8h) | 7200s (2h) | 900-3600s (15min-1h) | 根据安全要求调整 |
| Refresh Token | 7776000s (90d) | 2592000s (30d) | 604800-2592000s (7-30d) | 配合令牌轮换使用 |
2.2.2 JWT Token 的过期时间处理
当启用 JWT AccessToken 后,Token 的过期时间会被编码到 JWT 的 Payload 中(exp 声明)。这意味着即使 CAS 服务器不可用,资源服务器仍然可以通过检查 JWT 的 exp 声明来判断 Token 是否过期。
json
{
"exp": 1735689600,
"iat": 1735682400
}1
2
3
4
2
3
4
需要注意的是,JWT 的过期时间是绝对时间戳(Unix Epoch),而不是相对时间。这要求 CAS 服务器和资源服务器的时间必须保持同步。如果时间偏差过大(通常超过 5 分钟),可能导致 Token 验证失败。建议在生产环境中使用 NTP(Network Time Protocol)服务确保时间同步。
2.3 userProfileViewType 配置解析
userProfileViewType 是 CAS OAuth 2.0 中一个容易被忽视但非常重要的配置项。它决定了用户信息端点(/oauth2.0/profile)返回数据的格式。
2.3.1 FLAT 模式(默认)
在 FLAT 模式下,用户的所有属性都被"展平"到顶层对象中:
json
{
"id": "zhangsan",
"attributes": {
"displayName": ["张三"],
"email": ["zhangsan@example.com"],
"phone": ["13800138000"]
}
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
2.3.2 NESTED 模式
在 NESTED 模式下,用户属性以嵌套结构返回,更适合复杂的用户信息场景:
json
{
"id": "zhangsan",
"attributes": {
"displayName": ["张三"],
"email": ["zhangsan@example.com"],
"phone": ["13800138000"],
"department": {
"name": "技术部",
"code": "TECH"
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
配置方式:
yaml
# 教学示例 - CAS 7.3
cas:
oauth:
user-profile-view-type: NESTED1
2
3
4
2
3
4
选择建议: 如果你的用户属性结构较复杂(包含嵌套对象),或者需要在前端直接使用用户信息而不做额外处理,建议使用 NESTED 模式。FLAT 模式适用于简单的属性结构,且与旧版客户端的兼容性更好。
2.4 grants.resource-owner.requireServiceHeader 配置
这是 CAS 7.3 中为资源所有者密码模式新增的安全配置项。当设置为 true 时,使用密码模式获取 Token 的请求必须携带特定的 Service Header。
yaml
# 教学示例 - CAS 7.3
cas:
oauth:
grants:
resource-owner:
require-service-header: true1
2
3
4
5
6
2
3
4
5
6
工作原理:
当 require-service-header 启用后,CAS 会在处理密码模式的 Token 请求时检查 HTTP 请求头中是否包含特定的 Service Header。这个 Header 的名称和值通常与 OAuth 2.0 服务定义中的 serviceId 关联。
POST /oauth2.0/accessToken HTTP/1.1
Host: cas.example.com
Content-Type: application/x-www-form-urlencoded
X-Cas-Service: https://app.example.com
grant_type=password&username=zhangsan&password=xxx&client_id=internal-app&client_secret=xxx1
2
3
4
5
6
2
3
4
5
6
这种机制的核心价值在于:它为密码模式增加了一层"来源验证"。即使攻击者获取了 client_id 和 client_secret,如果不知道需要携带的 Service Header 值,也无法使用密码模式获取 Token。
适用场景:
- 企业内部系统之间的集成,双方由同一组织控制
- 需要额外安全层的密码模式使用场景
- 防止 client_secret 泄露后的密码模式滥用
2.5 跨版本配置属性命名变化速查表
为了帮助开发者快速完成跨版本升级,我们整理了以下配置属性命名变化速查表:
| 功能 | CAS 5.3 (camelCase) | CAS 6.6 (过渡) | CAS 7.3 (kebab-case) |
|---|---|---|---|
| Access Token 有效期 | cas.oauth.accessToken.timeToKillInSeconds | 两种风格均可 | cas.oauth.access-token.time-to-kill-in-seconds |
| 授权码有效期 | cas.oauth.code.timeToKillInSeconds | 两种风格均可 | cas.oauth.code.time-to-kill-in-seconds |
| Refresh Token 有效期 | cas.oauth.refreshToken.timeToKillInSeconds | 两种风格均可 | cas.oauth.refresh-token.time-to-kill-in-seconds |
| 用户信息视图类型 | cas.oauth.userProfileViewType | 两种风格均可 | cas.oauth.user-profile-view-type |
| 密码模式安全头 | 不支持 | 不支持 | cas.oauth.grants.resource-owner.require-service-header |
| JWT 访问令牌 | jwtAccessToken: true(服务定义) | 同左 | 同左 |
| 加密密钥 | cas.oauth.accessToken.crypto.* | 两种风格均可 | cas.oauth.access-token.crypto.* |
三、JWT AccessToken 启用与配置
3.1 JWT AccessToken 的启用方式
在 CAS 中,JWT AccessToken 的启用不是全局配置,而是在每个 OAuth 2.0 服务定义中单独控制的。这意味着你可以为不同的客户端配置不同的 Token 类型——有的使用 Opaque Token,有的使用 JWT Token。
3.1.1 OidcRegisteredService 中的 jwtAccessToken 配置
在 CAS 的管理工具(CAS Management)或 JSON 服务定义文件中,通过设置 jwtAccessToken 属性为 true 来启用 JWT AccessToken:
json
// 教学示例 - JSON 服务定义文件
{
"@class": "org.apereo.cas.support.oauth.services.OidcRegisteredService",
"serviceId": "^https://api\\.example\\.com/.*",
"name": "Example API Service",
"id": 1001,
"description": "启用了 JWT AccessToken 的 API 服务",
"evaluationOrder": 100,
"supportedGrantTypes": [
"java.util.HashSet",
[
"authorization_code",
"refresh_token",
"client_credentials"
]
],
"supportedResponseTypes": [
"java.util.HashSet",
["code"]
],
"jwtAccessToken": true
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
关键理解: jwtAccessToken: true 的作用是告诉 CAS 的 Token 生成器,在为这个服务签发 Access Token 时,使用 JWT 格式而不是默认的 Opaque Token 格式。这个配置只影响 Access Token 的格式,不影响 Refresh Token 和 Authorization Code 的格式。
3.1.2 为什么使用 OidcRegisteredService
你可能会注意到,即使我们只是在配置 OAuth 2.0(而不是 OIDC),服务定义的类型仍然是 OidcRegisteredService 而不是 OAuth20RegisteredService。这是因为:
- 功能继承关系:
OidcRegisteredService继承自OAuth20RegisteredService,它包含了所有 OAuth 2.0 的配置能力,同时增加了 OIDC 特有的配置项(如jwtAccessToken)。 - JWT 支持: JWT AccessToken 的功能是在 OIDC 模块中实现的,因此需要使用
OidcRegisteredService类型。 - 向前兼容: 使用
OidcRegisteredService不会影响现有的 OAuth 2.0 功能,你可以在需要时随时启用 OIDC 特性。
依赖要求: 要使用 OidcRegisteredService 和 JWT AccessToken,需要在 build.gradle 中引入 OIDC 模块依赖:
groovy
// 教学示例 - build.gradle
dependencies {
// CAS OIDC 支持(包含 JWT AccessToken 功能)
implementation "org.apereo.cas:cas-server-support-oidc:${casServerVersion}"
}1
2
3
4
5
2
3
4
5
3.2 JWT Token 的结构深度解析
理解 JWT 的内部结构,是掌握签名与加密机制的前提。一个标准的 JWT 由三部分组成:Header、Payload 和 Signature,每部分都是 Base64URL 编码的 JSON 对象。
3.2.1 Header(头部)
Header 定义了 Token 的元信息,CAS 生成的 JWT Header 通常包含以下字段:
json
{
"alg": "HS256",
"enc": "A256GCM",
"typ": "JWT",
"kid": "key-1"
}1
2
3
4
5
6
2
3
4
5
6
字段说明:
alg:签名算法。CAS 默认使用HS256(HMAC-SHA256),也支持RS256(RSA-SHA256)、ES256(ECDSA-SHA256)等。enc:加密算法。当启用 Token 加密时,此字段指定加密方式。A256GCM表示使用 AES-256-GCM 加密。typ:Token 类型,固定为JWT。kid:密钥标识符。用于密钥轮换场景,指示使用哪个密钥进行签名/加密。
3.2.2 Payload(载荷)
Payload 是 JWT 中携带实际数据的部分。CAS 在 JWT Payload 中包含丰富的声明信息:
json
{
"sub": "zhangsan",
"aud": "https://api.example.com",
"iss": "https://cas.example.com/oauth2.0",
"exp": 1735689600,
"iat": 1735682400,
"nbf": 1735682400,
"jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"scope": "openid profile email",
"client_id": "example-api-client",
"grant_type": "authorization_code",
"attributes": {
"displayName": "张三",
"email": "zhangsan@example.com",
"phone": "13800138000",
"department": "技术部"
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
标准声明(Registered Claims)说明:
| 声明 | 全称 | 说明 |
|---|---|---|
iss | Issuer | Token 签发者,通常是 CAS 的 URL |
sub | Subject | Token 主体,通常是用户标识 |
aud | Audience | Token 受众,通常是客户端标识 |
exp | Expiration | Token 过期时间(Unix 时间戳) |
iat | Issued At | Token 签发时间(Unix 时间戳) |
nbf | Not Before | Token 生效时间(Unix 时间戳) |
jti | JWT ID | Token 唯一标识符 |
CAS 自定义声明:
| 声明 | 说明 |
|---|---|
scope | 授权范围 |
client_id | 客户端标识 |
grant_type | 使用的授权类型 |
attributes | 用户属性集合 |
3.2.3 Signature(签名)
Signature 是 JWT 安全性的核心保障。CAS 使用配置的签名密钥对 Header 和 Payload 进行签名,确保 Token 的完整性和真实性。
HMAC 签名过程(HS256):
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
signing-key
) → Signature1
2
3
4
2
3
4
签名验证过程:
资源服务器在接收到 JWT 后,使用相同的密钥和算法重新计算签名,然后与 Token 中携带的签名进行比较。如果签名不匹配,说明 Token 已被篡改,应拒绝该请求。
3.2.4 JWT 的编码与传输
一个完整的 JWT 在传输时是一个由点号分隔的字符串:
eyJhbGciOiJIUzI1NiIsImVuYyI6IkEyNTZHQ00iLCJ0eXAiOiJKV1QiLCJraWQiOiJrZXktMSJ9.eyJzdWIiOiJ6aGFuZ3NhbiIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUuY29tIiwiaXNzIjoiaHR0cHM6Ly9jYXMuZXhhbXBsZS5jb20vb2F1dGgyLjAiLCJleHAiOjE3MzU2ODk2MDAsImlhdCI6MTczNTY4MjQwMCwibmJmIjoxNzM1NjgyNDAwLCJqdGkiOiJhMWIyYzNkNC1lNWY2LTc4OTAtYWJjZC1lZjEyMzQ1Njc4OTAiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIiwiY2xpZW50X2lkIjoiZXhhbXBsZS1hcGktY2xpZW50IiwiZ3JhbnRfdHlwZSI6ImF1dGhvcml6YXRpb25fY29kZSIsImF0dHJpYnV0ZXMiOnsiZGlzcGxheU5hbWUiOiLlvKDkuozmiqUiLCJlbWFpbCI6InpoYW5nc2FuQGV4YW1wbGUuY29tIn19.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c1
这个字符串通过 HTTP Header 传输:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...1
3.3 CAS 7.3 中 access-token.crypto 配置详解
CAS 7.3 对加密配置进行了全面重构,引入了统一的 crypto 配置块。对于 JWT AccessToken,加密配置位于 cas.oauth.access-token.crypto 路径下。
3.3.1 加密密钥配置(encryption.key)
encryption.key 用于配置 JWT Payload 的加密密钥。当配置了加密密钥后,CAS 会对 JWT 的 Payload 部分进行加密,确保即使 Token 被截获,攻击者也无法读取其中的用户信息。
yaml
# 教学示例 - CAS 7.3 JWT AccessToken 加密配置
cas:
oauth:
access-token:
crypto:
encryption:
key: "YOUR_AES_ENCRYPTION_KEY_HERE"
signing:
key: "YOUR_HMAC_SIGNING_KEY_HERE"1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
加密密钥的技术要求:
- 密钥长度: AES-256 加密需要 256 位(32 字节)的密钥。建议使用 Base64 编码的随机密钥。
- 密钥生成: 可以使用以下命令生成安全的随机密钥:
bash
# 生成 256 位 AES 加密密钥(Base64 编码)
openssl rand -base64 32
# 生成 512 位 HMAC 签名密钥(Base64 编码)
openssl rand -base64 641
2
3
4
5
2
3
4
5
- 密钥格式: CAS 支持多种密钥格式:
- 纯文本字符串(不推荐,安全性较低)
- Base64 编码字符串(推荐)
- 十六进制编码字符串
- 文件路径(指向包含密钥的文件)
3.3.2 签名密钥配置(signing.key)
signing.key 用于配置 JWT 的签名密钥。签名密钥用于生成 JWT 的 Signature 部分,确保 Token 的完整性和真实性。
yaml
# 教学示例 - CAS 7.3 JWT AccessToken 签名配置
cas:
oauth:
access-token:
crypto:
signing:
key: "YOUR_HMAC_SIGNING_KEY_HERE"
alg: "HS256"1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
签名算法选择:
| 算法 | 类型 | 密钥长度 | 安全性 | 性能 | 适用场景 |
|---|---|---|---|---|---|
| HS256 | HMAC | 256 位 | 高 | 高 | 单节点部署、密钥可共享 |
| HS384 | HMAC | 384 位 | 很高 | 中 | 需要更高安全强度的场景 |
| HS512 | HMAC | 512 位 | 极高 | 中 | 最高安全要求 |
| RS256 | RSA | 2048 位 | 高 | 低 | 公私钥分离的场景 |
| RS512 | RSA | 4096 位 | 极高 | 很低 | 最高安全要求 |
| ES256 | ECDSA | 256 位 | 高 | 中 | 移动端和 IoT 场景 |
对称签名(HMAC)vs 非对称签名(RSA/ECDSA):
- 对称签名(HMAC): 签名和验证使用同一个密钥。适用于 CAS 服务器和资源服务器由同一团队管理、可以安全共享密钥的场景。性能更好,配置更简单。
- 非对称签名(RSA/ECDSA): CAS 使用私钥签名,资源服务器使用公钥验证。适用于资源服务器由不同团队管理、无法安全共享密钥的场景。安全性更高,但性能较差。
我们的建议: 在大多数企业内部场景中,HMAC(HS256/HS512)是最佳选择,因为它的性能更好,配置更简单。只有在需要跨组织共享 Token 验证能力时,才需要考虑 RSA 或 ECDSA。
3.3.3 完整的 crypto 配置示例
以下是一个生产就绪的 JWT AccessToken 加密配置示例:
yaml
# 教学示例 - CAS 7.3 完整 JWT AccessToken 配置
cas:
oauth:
access-token:
# Token 有效期
time-to-kill-in-seconds: 3600
# JWT 签名与加密配置
crypto:
# 加密配置(可选,用于加密 JWT Payload)
encryption:
key: "${CAS_OAUTH_ENCRYPTION_KEY:}"
# 加密算法(默认 AES)
# alg: "A256GCM"
# 签名配置(必须,用于签名 JWT)
signing:
key: "${CAS_OAUTH_SIGNING_KEY:}"
# 签名算法(默认 HS256)
alg: "HS256"1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
安全最佳实践: 密钥不应硬编码在配置文件中,而应通过环境变量注入。上面的示例使用了 Spring Boot 的 ${VARIABLE:DEFAULT} 语法,从环境变量 CAS_OAUTH_ENCRYPTION_KEY 和 CAS_OAUTH_SIGNING_KEY 中读取密钥值。
3.4 session-replication.cookie.crypto 配置
除了 JWT AccessToken 的加密配置外,CAS 7.3 还提供了会话复制 Cookie 的加密配置。当 CAS 部署在集群环境中时,会话信息可能通过 Cookie 在节点之间传递,此时需要对 Cookie 进行加密保护。
yaml
# 教学示例 - CAS 7.3 会话复制 Cookie 加密配置
cas:
session-replication:
cookie:
crypto:
encryption:
key: "${CAS_SESSION_ENCRYPTION_KEY:}"
signing:
key: "${CAS_SESSION_SIGNING_KEY:}"1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
为什么需要单独配置?
JWT AccessToken 的密钥和会话 Cookie 的密钥应该使用不同的密钥,原因如下:
- 职责分离: JWT Token 和会话 Cookie 是不同的安全域,使用不同的密钥可以限制密钥泄露的影响范围。
- 密钥轮换: 不同的密钥允许独立轮换。例如,你可以只轮换 JWT Token 的签名密钥,而不影响会话 Cookie。
- 合规要求: 某些安全合规标准(如 PCI-DSS)要求不同类型的数据使用不同的加密密钥。
3.5 JWT 签名与加密的完整流程
现在,让我们将签名和加密的流程串联起来,形成完整的 JWT Token 生成链路:
┌─────────────────────────────────────────────────────────────────┐
│ JWT Token 生成流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 构建 Payload │
│ ├── 用户标识(sub) │
│ ├── 客户端标识(client_id, aud) │
│ ├── 时间信息(iat, exp, nbf) │
│ ├── 权限范围(scope) │
│ └── 用户属性(attributes) │
│ │
│ 2. 加密 Payload(如果配置了 encryption.key) │
│ └── AES-256-GCM 加密 → Encrypted Payload │
│ │
│ 3. 构建 Header │
│ ├── 签名算法(alg) │
│ ├── 加密算法(enc,如果启用了加密) │
│ └── 密钥标识(kid) │
│ │
│ 4. 签名(使用 signing.key) │
│ └── HMAC-SHA256(Header + "." + Payload) → Signature │
│ │
│ 5. 组装 JWT │
│ └── Base64URL(Header) + "." + Base64URL(Payload) + "." + │
│ Base64URL(Signature) │
│ │
└─────────────────────────────────────────────────────────────────┘1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
资源服务器验证流程:
┌─────────────────────────────────────────────────────────────────┐
│ JWT Token 验证流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 拆分 JWT Token │
│ └── Header.Payload.Signature │
│ │
│ 2. 验证签名(使用 signing.key) │
│ ├── 重新计算 HMAC-SHA256(Header + "." + Payload) │
│ └── 与 Signature 比较 → 不匹配则拒绝 │
│ │
│ 3. 验证过期时间(exp) │
│ └── 当前时间 > exp → 拒绝 │
│ │
│ 4. 验证签发者(iss) │
│ └── iss 不匹配 → 拒绝 │
│ │
│ 5. 验证受众(aud) │
│ └── aud 不匹配当前服务 → 拒绝 │
│ │
│ 6. 解密 Payload(如果配置了 encryption.key) │
│ └── AES-256-GCM 解密 → Original Payload │
│ │
│ 7. 提取用户信息 │
│ └── 从 Payload 中读取 sub、attributes 等字段 │
│ │
└─────────────────────────────────────────────────────────────────┘1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
四、自定义 OAuth20UserProfileDataCreator 的三代演进
OAuth20UserProfileDataCreator 是 CAS OAuth 2.0 中负责创建用户资料数据的接口。它的实现决定了 /oauth2.0/profile 端点返回的用户信息内容和格式。在我们的项目中,这个接口经历了从 CAS 5.3 到 7.3 的三代演进,每一次演进都反映了 CAS 框架本身的变化和我们业务需求的升级。
4.1 第一代:CAS 5.3 的基础实现
4.1.1 实现背景
在 CAS 5.3 中,OAuth20UserProfileDataCreator 是一个独立的接口,需要开发者手动实现。CAS 5.3 的 API 设计相对底层,获取用户标识的方式不够直接,需要通过反射机制来获取。
4.1.2 核心实现思路
CAS 5.3 版本的自定义实现需要完成以下工作:
- 通过构造器注入
ServicesManager,用于获取服务定义信息 - 从
Ticket对象中提取认证信息 - 使用反射获取用户 ID(因为 CAS 5.3 的 API 没有提供直接的方法)
- 构建用户属性 Map
java
// 教学示例 - CAS 5.3 自定义 UserProfileDataCreator
public class CustomUserProfileDataCreator implements OAuth20UserProfileDataCreator {
private final ServicesManager servicesManager;
// 构造器注入 ServicesManager
public CustomUserProfileDataCreator(final ServicesManager servicesManager) {
this.servicesManager = servicesManager;
}
@Override
public Map<String, Object> create(final AccessToken accessToken) {
// 获取认证信息
Authentication authentication = accessToken.getAuthentication();
Principal principal = authentication.getPrincipal();
// CAS 5.3 中需要通过反射获取用户 ID
String userId = extractUserIdViaReflection(principal);
// 构建用户属性
Map<String, Object> profile = new LinkedHashMap<>();
profile.put("id", userId);
// 添加自定义属性
Map<String, Object> attributes = new LinkedHashMap<>();
attributes.put("displayName", principal.getAttribute("displayName"));
attributes.put("email", principal.getAttribute("mail"));
profile.put("attributes", attributes);
return profile;
}
// 通过反射获取用户 ID 的辅助方法
private String extractUserIdViaReflection(Principal principal) {
try {
Method getIdMethod = principal.getClass().getMethod("getId");
return (String) getIdMethod.invoke(principal);
} catch (Exception e) {
// 反射失败时的降级处理
return principal.getId();
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
4.1.3 注册自定义实现
在 CAS 5.3 中,需要通过 Spring 配置注册自定义实现:
java
// 教学示例 - CAS 5.3 配置类
@Configuration
public class CustomOAuthConfig {
@Autowired
private ServicesManager servicesManager;
@Bean
public OAuth20UserProfileDataCreator customUserProfileDataCreator() {
return new CustomUserProfileDataCreator(servicesManager);
}
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
4.1.4 CAS 5.3 实现的局限性
- 反射的脆弱性: 使用反射获取用户 ID 依赖于 CAS 内部实现细节,版本升级时容易失效。
- 缺乏错误处理: 没有完善的异常处理机制,反射失败时可能导致空指针异常。
- 日志不足: 缺少关键操作的日志记录,排查问题时困难。
- 配置方式陈旧: 使用 XML 或 Java Config 注册 Bean,不如 CAS 7.3 的自动装配机制优雅。
4.2 第二代:CAS 6.6 的增强实现
4.2.1 实现背景
CAS 6.6 基于 Spring Boot 2.7.x,API 设计更加规范。在这个版本中,我们对自定义实现进行了全面增强,主要改进点包括:增加完善的日志记录、增加错误处理机制、引入 createErrorProfile 方法。
4.2.2 核心实现思路
java
// 教学示例 - CAS 6.6 增强版 UserProfileDataCreator
@Slf4j
public class EnhancedUserProfileDataCreator implements OAuth20UserProfileDataCreator {
private final ServicesManager servicesManager;
public EnhancedUserProfileDataCreator(final ServicesManager servicesManager) {
this.servicesManager = servicesManager;
log.info("[OAuth2.0] EnhancedUserProfileDataCreator 初始化完成");
}
@Override
public Map<String, Object> create(final AccessToken accessToken) {
log.debug("[OAuth2.0] 开始创建用户 Profile, Ticket ID: {}",
accessToken.getId());
try {
Authentication authentication = accessToken.getAuthentication();
if (authentication == null) {
log.warn("[OAuth2.0] AccessToken 中无认证信息, Ticket ID: {}",
accessToken.getId());
return createErrorProfile("AUTHENTICATION_MISSING",
"Access token 中未找到认证信息");
}
Principal principal = authentication.getPrincipal();
if (principal == null) {
log.warn("[OAuth2.0] 认证信息中无 Principal, Ticket ID: {}",
accessToken.getId());
return createErrorProfile("PRINCIPAL_MISSING",
"认证信息中未找到用户主体");
}
String userId = principal.getId();
log.debug("[OAuth2.0] 用户 ID: {}", userId);
// 构建用户属性
Map<String, Object> attributes = new LinkedHashMap<>();
attributes.put("displayName", principal.getAttribute("displayName"));
attributes.put("email", principal.getAttribute("mail"));
attributes.put("phone", principal.getAttribute("phone"));
Map<String, Object> profile = new LinkedHashMap<>();
profile.put("id", userId);
profile.put("attributes", attributes);
log.debug("[OAuth2.0] 用户 Profile 创建完成, 包含 {} 个属性",
attributes.size());
return profile;
} catch (Exception e) {
log.error("[OAuth2.0] 创建用户 Profile 异常, Ticket ID: {}",
accessToken.getId(), e);
return createErrorProfile("INTERNAL_ERROR",
"创建用户 Profile 时发生内部错误");
}
}
/**
* 创建错误 Profile
* 当用户信息创建失败时,返回包含错误信息的 Profile
*/
private Map<String, Object> createErrorProfile(String errorCode,
String errorMessage) {
Map<String, Object> errorProfile = new LinkedHashMap<>();
errorProfile.put("error", errorCode);
errorProfile.put("error_description", errorMessage);
return errorProfile;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
4.2.3 CAS 6.6 版本的关键改进
- 日志增强: 使用
@Slf4j注解引入日志,在关键节点记录 debug 和 warn 级别的日志,便于问题排查。 - 空值检查: 对
authentication和principal进行空值检查,避免空指针异常。 - 错误 Profile: 引入
createErrorProfile方法,在异常情况下返回结构化的错误信息,而不是抛出未处理的异常。 - 异常捕获: 使用 try-catch 包裹整个创建逻辑,确保任何异常都不会导致系统崩溃。
4.3 第三代:CAS 7.3 的现代化实现
4.3.1 实现背景
CAS 7.3 基于 Spring Boot 3.5.x 和 Jakarta EE 10,API 设计更加现代化。最关键的改进是:不再需要通过反射获取用户 ID。CAS 7.3 的 Principal 接口直接提供了 getId() 方法,使得获取用户标识变得简单直接。
4.3.2 核心实现思路
java
// 教学示例 - CAS 7.3 现代化 UserProfileDataCreator
import org.apereo.cas.support.oauth.web.OAuth20UserProfileDataCreator;
import org.apereo.cas.authentication.principal.Principal;
import org.apereo.cas.ticket.AccessToken;
import org.apereo.cas.authentication.Authentication;
@Slf4j
public class ModernUserProfileDataCreator implements OAuth20UserProfileDataCreator {
@Override
public Map<String, Object> create(final AccessToken accessToken) {
log.debug("[OAuth2.0] 开始创建用户 Profile");
try {
// 直接获取认证信息和用户主体
Authentication authentication = accessToken.getAuthentication();
Principal principal = authentication.getPrincipal();
// CAS 7.3: 直接调用 getId(),无需反射!
String userId = principal.getId();
log.debug("[OAuth2.0] 用户 ID: {}", userId);
// 构建用户属性
Map<String, Object> attributes = new LinkedHashMap<>();
attributes.put("displayName", principal.getAttribute("displayName"));
attributes.put("email", principal.getAttribute("mail"));
Map<String, Object> profile = new LinkedHashMap<>();
profile.put("id", userId);
profile.put("attributes", attributes);
return profile;
} catch (Exception e) {
log.error("[OAuth2.0] 创建用户 Profile 异常", e);
return createErrorProfile("INTERNAL_ERROR",
"创建用户 Profile 时发生内部错误");
}
}
private Map<String, Object> createErrorProfile(String errorCode,
String errorMessage) {
Map<String, Object> errorProfile = new LinkedHashMap<>();
errorProfile.put("error", errorCode);
errorProfile.put("error_description", errorMessage);
return errorProfile;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
4.3.3 CAS 7.3 版本的关键改进
- 消除反射:
principal.getId()直接返回用户 ID,代码更简洁、更安全、更高效。 - 无需 ServicesManager 注入: 在大多数场景下,不再需要注入
ServicesManager,减少了组件间的耦合。 - 接口更清晰:
OAuth20UserProfileDataCreator接口的方法签名更加明确,参数类型更加具体。 - Jakarta EE 迁移: 所有
javax.*包名替换为jakarta.*,与 Spring Boot 3.x 完全兼容。
4.3.4 三代实现对比总结
| 对比维度 | CAS 5.3 | CAS 6.6 | CAS 7.3 |
|---|---|---|---|
| 获取用户 ID | 反射 | 反射 + 降级处理 | 直接调用 getId() |
| 依赖注入 | 构造器注入 ServicesManager | 构造器注入 ServicesManager | 可选注入 |
| 错误处理 | 基本无 | 完善(createErrorProfile) | 完善(createErrorProfile) |
| 日志记录 | 无 | Slf4j + 多级别日志 | Slf4j + 多级别日志 |
| 代码复杂度 | 高(反射逻辑) | 中 | 低 |
| 维护成本 | 高 | 中 | 低 |
| API 命名空间 | javax.* | javax.* | jakarta.* |
五、自定义 OAuth20UserProfileViewRenderer(CAS 7.3 新增)
5.1 问题背景:userInfo 对象被转为数组
在 CAS 7.3 的实际使用中,我们发现一个令人困惑的问题:当通过 /oauth2.0/profile 端点获取用户信息时,返回的 JSON 中,attributes 下的某些字段值被转换成了数组,而不是预期的单值。
例如,期望的返回格式:
json
{
"id": "zhangsan",
"attributes": {
"displayName": "张三",
"email": "zhangsan@example.com"
}
}1
2
3
4
5
6
7
2
3
4
5
6
7
但实际返回的格式:
json
{
"id": "zhangsan",
"attributes": {
"displayName": ["张三"],
"email": ["zhangsan@example.com"]
}
}1
2
3
4
5
6
7
2
3
4
5
6
7
这个问题的根源在于 CAS 内部的属性处理机制。CAS 的 Principal 接口中,属性的值类型是 Object,但在实际存储中,为了支持多值属性,CAS 默认将所有属性值都存储为 List 类型。当 CAS 的默认 OAuth20UserProfileViewRenderer 将这些属性序列化为 JSON 时,List 类型的值自然就被转换成了 JSON 数组。
5.2 解决方案:自定义 OAuth20UserProfileViewRenderer
CAS 7.3 提供了 OAuth20UserProfileViewRenderer 接口,允许开发者自定义用户信息的渲染方式。通过实现这个接口,我们可以控制用户信息的最终输出格式。
java
// 教学示例 - CAS 7.3 自定义 UserProfileViewRenderer
import org.apereo.cas.support.oauth.web.OAuth20UserProfileViewRenderer;
import org.springframework.http.ResponseEntity;
@Slf4j
public class CustomUserProfileViewRenderer implements OAuth20UserProfileViewRenderer {
@Override
public ResponseEntity<?> render(final Map<String, Object> model) {
log.debug("[OAuth2.0] 自定义渲染用户 Profile, model keys: {}",
model.keySet());
// 直接返回 model,保持原始结构
// CAS 默认的渲染器可能会对 model 进行额外处理
// 我们的自定义渲染器直接返回,避免不必要的转换
return ResponseEntity.ok(model);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
5.3 注册自定义 ViewRenderer
在 CAS 7.3 中,通过 Spring Boot 的自动装配机制注册自定义 ViewRenderer:
java
// 教学示例 - CAS 7.3 配置类
@Configuration
@EnableConfigurationProperties(CasConfigurationProperties.class)
public class CustomOAuthViewRendererConfig {
@Bean
@ConditionalOnMissingBean
public OAuth20UserProfileViewRenderer customUserProfileViewRenderer() {
return new CustomUserProfileViewRenderer();
}
}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
@ConditionalOnMissingBean 注解的作用: 这个注解确保只有当 CAS 没有自动配置默认的 OAuth20UserProfileViewRenderer 时,我们的自定义实现才会生效。这是一种优雅的覆盖策略,不会与 CAS 的自动配置机制冲突。
5.4 深入理解:为什么直接返回 ResponseEntity.ok(model) 就能解决问题
CAS 默认的 OAuth20UserProfileViewRenderer 实现中,可能会对 model 进行以下处理:
- 属性值转换: 将
Principal的属性值从List类型转换为其他类型。 - 模板渲染: 使用 Thymeleaf 模板引擎渲染用户信息页面。
- 内容协商: 根据
AcceptHeader 决定返回 JSON 还是 HTML。
我们的自定义实现通过直接返回 ResponseEntity.ok(model),跳过了上述处理步骤,让 Spring MVC 直接将 model 序列化为 JSON。这种方式简单直接,但需要注意:
- 确保 model 的结构是正确的 JSON 友好格式。 如果 model 中包含不可序列化的对象,会导致 JSON 序列化失败。
- 考虑内容协商。 如果客户端请求的是 HTML 格式(
Accept: text/html),直接返回 JSON 可能不是最佳选择。可以通过检查AcceptHeader 来决定返回格式。
java
// 教学示例 - 增强版 ViewRenderer,支持内容协商
@Slf4j
public class EnhancedUserProfileViewRenderer implements OAuth20UserProfileViewRenderer {
@Override
public ResponseEntity<?> render(final Map<String, Object> model) {
log.debug("[OAuth2.0] 渲染用户 Profile");
// 对 model 进行预处理,确保属性值格式正确
Map<String, Object> processedModel = preprocessModel(model);
return ResponseEntity.ok(processedModel);
}
/**
* 预处理 model,将 List 类型的单值属性转换为单值
*/
@SuppressWarnings("unchecked")
private Map<String, Object> preprocessModel(Map<String, Object> model) {
Map<String, Object> result = new LinkedHashMap<>(model);
if (result.containsKey("attributes")) {
Object attributesObj = result.get("attributes");
if (attributesObj instanceof Map) {
Map<String, Object> attributes = (Map<String, Object>) attributesObj;
Map<String, Object> processedAttrs = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : attributes.entrySet()) {
Object value = entry.getValue();
// 如果是单元素 List,提取为单值
if (value instanceof List && ((List<?>) value).size() == 1) {
processedAttrs.put(entry.getKey(),
((List<?>) value).get(0));
} else {
processedAttrs.put(entry.getKey(), value);
}
}
result.put("attributes", processedAttrs);
}
}
return result;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
六、CAS 5.3 独有的 OAuth 2.0 深度定制
CAS 5.3 虽然是一个较老的版本,但在很多企业中仍然广泛使用。在这个版本中,我们进行了一系列深度定制,这些定制方案对于理解 CAS OAuth 2.0 的内部机制非常有价值。
6.1 OAuth20CallbackController:回调重定向 URL 域名白名单校验
6.1.1 安全背景
OAuth 2.0 的授权码模式中,CAS 在用户授权后会通过 HTTP 302 重定向将授权码返回给客户端。这个重定向的目标 URL(redirect_uri)是由客户端在授权请求中指定的。如果 CAS 不对 redirect_uri 进行严格校验,攻击者可以构造恶意的 redirect_uri,将授权码重定向到攻击者控制的服务器,从而窃取授权码。
虽然 OAuth 2.0 协议要求授权服务器必须校验 redirect_uri 是否与预注册的值匹配,但在实际项目中,我们发现了额外的安全需求:不仅需要校验 redirect_uri 的精确匹配,还需要限制允许的域名范围。
6.1.2 实现方案
我们通过自定义 OAuth20CallbackController,在回调重定向之前增加了域名白名单校验:
java
// 教学示例 - CAS 5.3 自定义 OAuth20CallbackController
@Controller
public class CustomOAuth20CallbackController {
// 域名白名单正则表达式
// 只允许重定向到 *.bima.cc 域名下的 URL
private static final Pattern DOMAIN_WHITELIST_PATTERN =
Pattern.compile("^(http|https|imaps)://.*\\.bima\\.cc.*");
private static final Logger log =
LoggerFactory.getLogger(CustomOAuth20CallbackController.class);
/**
* 在执行重定向之前,校验目标 URL 是否在白名单内
*/
private void validateRedirectUrl(String redirectUrl) {
if (redirectUrl == null || redirectUrl.isEmpty()) {
throw new IllegalArgumentException("重定向 URL 不能为空");
}
if (!DOMAIN_WHITELIST_PATTERN.matcher(redirectUrl).matches()) {
log.warn("[OAuth2.0 Security] 回调重定向 URL 域名校验失败: {}",
redirectUrl);
throw new SecurityException(
"回调重定向 URL 不在允许的域名白名单内");
}
log.debug("[OAuth2.0 Security] 回调重定向 URL 域名校验通过: {}",
redirectUrl);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
6.1.3 正则表达式解析
^(http|https|imaps)://.*\\.bima\\.cc.*1
这个正则表达式的含义是:
^— 匹配字符串开头(http|https|imaps)— 只允许 http、https、imaps 三种协议://— 协议分隔符.*— 任意子域名(如app、api、auth等)\\.bima\\.cc— 必须以.bima.cc结尾的域名.*— 允许任意路径和查询参数
匹配示例:
| URL | 是否匹配 | 说明 |
|---|---|---|
https://app.bima.cc/callback | 匹配 | 标准的 HTTPS 回调 |
https://api.bima.cc/oauth/callback?code=xxx | 匹配 | 带查询参数的回调 |
http://test.bima.cc:8080/callback | 匹配 | 自定义端口的回调 |
https://evil.com/callback | 不匹配 | 非白名单域名 |
ftp://app.bima.cc/callback | 不匹配 | 不允许的协议 |
https://bima.cc.evil.com/callback | 不匹配 | 域名后缀被利用 |
安全增强建议: 正则表达式中的 .* 通配符虽然灵活,但可能被攻击者利用(如最后一个例子中的域名后缀利用)。在生产环境中,建议使用更精确的域名匹配规则,或者维护一个明确的域名白名单列表。
6.2 Oauth20ProfileController:支持 POST 方法获取用户信息
6.2.1 需求背景
OAuth 2.0 协议规范中,用户信息端点(/oauth2.0/profile)通常只支持 GET 方法。然而,在某些特殊场景下,我们需要通过 POST 方法获取用户信息:
- Token 过长: 当使用 JWT AccessToken 时,Token 可能非常长,放在 URL 中可能超过浏览器的 URL 长度限制。
- 安全性: 将 Token 放在 POST 请求体中,而不是 URL 中,可以避免 Token 被记录在服务器访问日志、浏览器历史记录和 Referer Header 中。
- 客户端限制: 某些 HTTP 客户端库对 GET 请求的 Header 大小有限制。
6.2.2 实现方案
java
// 教学示例 - CAS 5.3 自定义 ProfileController
@RestController
@RequestMapping("/oauth2.0")
public class CustomOauth20ProfileController {
private static final Logger log =
LoggerFactory.getLogger(CustomOauth20ProfileController.class);
/**
* 支持 POST 方法获取用户信息
* Token 通过请求体传递,格式: access_token=xxx
*/
@PostMapping(value = "/profile",
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> getProfileByPost(
@RequestParam(value = "access_token", required = false)
String accessTokenParam,
@RequestHeader(value = "Authorization", required = false)
String authHeader) {
// 优先从 Header 中获取 Token
String tokenValue = extractTokenFromHeader(authHeader);
if (tokenValue == null) {
tokenValue = accessTokenParam;
}
if (tokenValue == null || tokenValue.isEmpty()) {
log.warn("[OAuth2.0] POST /profile 请求中未提供 access_token");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "invalid_token",
"error_description", "缺少 access_token"));
}
log.debug("[OAuth2.0] POST /profile 请求, Token: {}...",
tokenValue.substring(0,
Math.min(8, tokenValue.length())));
// 验证 Token 并获取用户信息
// ...(具体验证逻辑省略)
return ResponseEntity.ok(buildUserProfile(tokenValue));
}
/**
* 从 Authorization Header 中提取 Bearer Token
*/
private String extractTokenFromHeader(String authHeader) {
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return null;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
6.3 FilterConfig + Oauth2AuthorizeFilter + Oauth2AccessTokenFilter:自动补全参数
6.3.1 需求背景
在某些遗留系统中,客户端在发起 OAuth 2.0 请求时,可能不会携带完整的参数。例如,授权请求中缺少 response_type 参数,或者 Token 请求中缺少 grant_type 参数。为了兼容这些遗留客户端,我们实现了一组过滤器,在请求到达 CAS 控制器之前自动补全缺失的参数。
6.3.2 FilterConfig:过滤器注册
java
// 教学示例 - CAS 5.3 FilterConfig
@Configuration
public class CustomFilterConfig {
@Bean
public FilterRegistrationBean<Oauth2AuthorizeFilter>
oauth2AuthorizeFilterRegistration() {
FilterRegistrationBean<Oauth2AuthorizeFilter> registration =
new FilterRegistrationBean<>();
registration.setFilter(new Oauth2AuthorizeFilter());
registration.addUrlPatterns("/oauth2.0/authorize");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
registration.setName("oauth2AuthorizeFilter");
return registration;
}
@Bean
public FilterRegistrationBean<Oauth2AccessTokenFilter>
oauth2AccessTokenFilterRegistration() {
FilterRegistrationBean<Oauth2AccessTokenFilter> registration =
new FilterRegistrationBean<>();
registration.setFilter(new Oauth2AccessTokenFilter());
registration.addUrlPatterns("/oauth2.0/accessToken");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
registration.setName("oauth2AccessTokenFilter");
return registration;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
6.3.3 Oauth2AuthorizeFilter:自动补全 response_type
java
// 教学示例 - CAS 5.3 Oauth2AuthorizeFilter
public class Oauth2AuthorizeFilter extends OncePerRequestFilter {
private static final Logger log =
LoggerFactory.getLogger(Oauth2AuthorizeFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 使用包装器包装请求
HttpServletRequest wrappedRequest =
new Oauth2AuthorizeRequestWrapper(request);
// 如果请求中缺少 response_type 参数,自动补全为 "code"
if (wrappedRequest.getParameter("response_type") == null) {
log.info("[OAuth2.0 Filter] 自动补全 response_type=code");
// 包装器会在 getParameter 时返回默认值
}
filterChain.doFilter(wrappedRequest, response);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
6.3.4 Oauth2AccessTokenFilter:自动补全 grant_type
java
// 教学示例 - CAS 5.3 Oauth2AccessTokenFilter
public class Oauth2AccessTokenFilter extends OncePerRequestFilter {
private static final Logger log =
LoggerFactory.getLogger(Oauth2AccessTokenFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
HttpServletRequest wrappedRequest =
new Oauth2AccessTokenRequestWrapper(request);
if (wrappedRequest.getParameter("grant_type") == null) {
log.info("[OAuth2.0 Filter] 自动补全 grant_type=authorization_code");
}
filterChain.doFilter(wrappedRequest, response);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
6.4 Oauth2AuthorizeRequestWrapper / Oauth2AccessTokenRequestWrapper
6.4.1 设计原理
HttpServletRequestWrapper 是 Servlet API 提供的装饰器模式实现。它允许开发者在不修改原始请求对象的前提下,对请求的参数、Header、方法等信息进行修改或增强。我们的自定义 Wrapper 在 getParameter() 方法中增加了默认值逻辑。
6.4.2 Oauth2AuthorizeRequestWrapper 实现
java
// 教学示例 - CAS 5.3 Oauth2AuthorizeRequestWrapper
public class Oauth2AuthorizeRequestWrapper
extends HttpServletRequestWrapper {
public Oauth2AuthorizeRequestWrapper(HttpServletRequest request) {
super(request);
}
@Override
public String getParameter(String name) {
String value = super.getParameter(name);
// 自动补全 response_type 参数
if ("response_type".equals(name) && value == null) {
return "code";
}
return value;
}
@Override
public Map<String, String[]> getParameterMap() {
Map<String, String[]> params = new LinkedHashMap<>(
super.getParameterMap());
// 确保 response_type 参数存在
if (!params.containsKey("response_type")) {
params.put("response_type", new String[]{"code"});
}
return params;
}
@Override
public Enumeration<String> getParameterNames() {
Set<String> names = new LinkedHashSet<>();
Enumeration<String> originalNames = super.getParameterNames();
while (originalNames.hasMoreElements()) {
names.add(originalNames.nextElement());
}
// 确保 response_type 在参数名列表中
if (!names.contains("response_type")) {
names.add("response_type");
}
return Collections.enumeration(names);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
6.4.3 Oauth2AccessTokenRequestWrapper 实现
java
// 教学示例 - CAS 5.3 Oauth2AccessTokenRequestWrapper
public class Oauth2AccessTokenRequestWrapper
extends HttpServletRequestWrapper {
public Oauth2AccessTokenRequestWrapper(HttpServletRequest request) {
super(request);
}
@Override
public String getParameter(String name) {
String value = super.getParameter(name);
// 自动补全 grant_type 参数
if ("grant_type".equals(name) && value == null) {
return "authorization_code";
}
return value;
}
@Override
public Map<String, String[]> getParameterMap() {
Map<String, String[]> params = new LinkedHashMap<>(
super.getParameterMap());
if (!params.containsKey("grant_type")) {
params.put("grant_type",
new String[]{"authorization_code"});
}
return params;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
6.4.4 安全考量
自动补全参数虽然方便了遗留客户端的接入,但也引入了安全风险:
- 参数劫持: 如果攻击者故意发送不带
response_type的请求,过滤器会自动补全为code,这可能不是攻击者期望的行为。 - 日志审计: 自动补全的参数应该记录在日志中,便于安全审计。
- 逐步废弃: 自动补全功能应该作为一个过渡方案,最终要求所有客户端都发送完整的参数。
我们的建议: 在新项目中,不建议使用自动补全功能。它只应作为遗留系统迁移的临时方案。在生产环境中,如果确实需要使用,应该添加严格的日志记录和监控告警。
七、OAuth 2.0 确认页面模板定制
7.1 CAS 模板覆盖机制
CAS 使用 Thymeleaf 作为模板引擎,所有的前端页面都可以通过在 Overlay 项目的 src/main/resources/templates 目录下放置同名模板文件来覆盖。这种机制允许开发者在不修改 CAS 源码的情况下,完全自定义前端页面的外观和行为。
cas-overlay/
└── src/
└── main/
└── resources/
└── templates/
└── oauth/
├── confirm.html # 授权确认页面
├── deviceCodeApproval.html # 设备码审批页面(6.6+)
├── deviceCodeApproved.html # 设备码已审批页面(6.6+)
└── accountprofileaccesstokens.html # Access Token 管理页面(7.3+)1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
7.2 三个版本的 confirm.html 对比
授权确认页面(confirm.html)是 OAuth 2.0 授权流程中用户交互的关键节点。当用户首次授权某个应用时,CAS 会展示此页面,告知用户该应用请求的权限范围,并要求用户确认或拒绝。
7.2.1 CAS 5.3 的 confirm.html
CAS 5.3 基于 Bootstrap 3.x,页面风格较为传统。confirm.html 的核心结构如下:
html
<!-- 教学示例 - CAS 5.3 confirm.html 核心结构 -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>授权确认</title>
<!-- Bootstrap 3.x CSS -->
<link rel="stylesheet"
th:href="@{/webjars/bootstrap/css/bootstrap.min.css}" />
</head>
<body>
<div class="container">
<div class="panel panel-default">
<div class="panel-heading">
<h3>应用授权确认</h3>
</div>
<div class="panel-body">
<!-- 应用信息 -->
<p>
<strong th:text="${serviceName}">应用名称</strong>
请求以下权限:
</p>
<!-- 权限范围列表 -->
<ul>
<li th:each="scope : ${scopes}"
th:text="${scope}">权限范围</li>
</ul>
<!-- 用户信息提示 -->
<p>
您当前以 <strong th:text="${principal.id}">用户名</strong>
的身份登录。
</p>
<!-- 操作按钮 -->
<form method="post" th:action="@{/oauth2.0/authorize}">
<input type="hidden" name="action" value="approve" />
<button type="submit" class="btn btn-primary">
同意授权
</button>
<a th:href="@{/oauth2.0/authorize?action=deny}"
class="btn btn-default">
拒绝
</a>
</form>
</div>
</div>
</div>
</body>
</html>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
CAS 5.3 confirm.html 的特点:
- 使用 Bootstrap 3.x 的面板(panel)组件
- 表单提交方式为 POST
- 模板变量通过
${serviceName}、${scopes}、${principal}等获取 - 页面结构简单,缺少品牌定制元素
7.2.2 CAS 6.6 的 confirm.html
CAS 6.6 升级到 Bootstrap 4.x,页面风格更加现代化:
html
<!-- 教学示例 - CAS 6.6 confirm.html 核心结构 -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>授权确认</title>
<!-- Bootstrap 4.x CSS -->
<link rel="stylesheet"
th:href="@{/webjars/bootstrap/css/bootstrap.min.css}" />
<style>
/* 自定义样式 */
.oauth-confirm-card {
max-width: 480px;
margin: 60px auto;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.scope-badge {
display: inline-block;
padding: 4px 12px;
margin: 4px;
border-radius: 16px;
background-color: #e3f2fd;
color: #1565c0;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<div class="card oauth-confirm-card">
<div class="card-header text-center">
<h4>应用授权确认</h4>
</div>
<div class="card-body text-center">
<!-- 应用图标和名称 -->
<div class="mb-3">
<span class="text-muted">应用:</span>
<strong th:text="${serviceName}">应用名称</strong>
</div>
<!-- 权限范围以标签形式展示 -->
<div class="mb-3">
<span class="text-muted">请求权限:</span>
<div class="mt-2">
<span th:each="scope : ${scopes}"
class="scope-badge"
th:text="${scope}">权限范围</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="mt-4">
<form method="post" th:action="@{/oauth2.0/authorize}">
<input type="hidden" name="action" value="approve" />
<button type="submit" class="btn btn-primary btn-lg">
同意授权
</button>
</form>
<form method="post" th:action="@{/oauth2.0/authorize}">
<input type="hidden" name="action" value="deny" />
<button type="submit" class="btn btn-outline-secondary">
拒绝授权
</button>
</form>
</div>
</div>
</div>
</div>
</body>
</html>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
CAS 6.6 confirm.html 的改进:
- 使用 Bootstrap 4.x 的卡片(card)组件,视觉更现代
- 权限范围以标签(badge)形式展示,更直观
- 添加了自定义 CSS,支持品牌定制
- 两个按钮都使用 POST 表单提交,更安全
7.2.3 CAS 7.3 的 confirm.html
CAS 7.3 升级到 Bootstrap 5.x,并引入了更加精致的设计语言:
html
<!-- 教学示例 - CAS 7.3 confirm.html 核心结构 -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>授权确认</title>
<!-- Bootstrap 5.x CSS -->
<link rel="stylesheet"
th:href="@{/webjars/bootstrap/css/bootstrap.min.css}" />
<style>
.confirm-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.confirm-card {
max-width: 520px;
width: 100%;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.confirm-header {
background: #fff;
padding: 32px;
text-align: center;
}
.confirm-body {
background: #f8f9fa;
padding: 32px;
}
.scope-item {
display: flex;
align-items: center;
padding: 12px 16px;
background: #fff;
border-radius: 8px;
margin-bottom: 8px;
}
.scope-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: #e3f2fd;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
color: #1565c0;
}
</style>
</head>
<body>
<div class="confirm-container">
<div class="confirm-card">
<div class="confirm-header">
<div class="mb-3">
<!-- 应用图标 -->
<div style="width:64px;height:64px;border-radius:16px;
background:#e3f2fd;margin:0 auto;display:flex;
align-items:center;justify-content:center;">
<span style="font-size:28px;color:#1565c0;">
🌐
</span>
</div>
</div>
<h4 th:text="${serviceName}">应用名称</h4>
<p class="text-muted">希望访问您的账户信息</p>
</div>
<div class="confirm-body">
<!-- 权限范围列表 -->
<div th:each="scope : ${scopes}" class="scope-item">
<div class="scope-icon">✓</div>
<div>
<div th:text="${scope}" class="fw-bold">权限范围</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="mt-4 d-grid gap-2">
<form method="post" th:action="@{/oauth2.0/authorize}">
<input type="hidden" name="action" value="approve" />
<button type="submit"
class="btn btn-primary btn-lg w-100">
同意授权
</button>
</form>
<form method="post" th:action="@{/oauth2.0/authorize}">
<input type="hidden" name="action" value="deny" />
<button type="submit"
class="btn btn-outline-secondary w-100">
拒绝授权
</button>
</form>
</div>
</div>
</div>
</div>
</body>
</html>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
CAS 7.3 confirm.html 的改进:
- 使用 Bootstrap 5.x,全面拥抱现代 CSS 特性
- 渐变背景、卡片阴影、圆角等视觉元素更加精致
- 权限范围以列表项形式展示,每个权限带有图标
- 响应式设计,适配移动端
- 按钮使用全宽布局(
w-100),操作更便捷
7.3 设备码审批页面(CAS 6.6/7.3 新增)
设备码流程(Device Authorization Grant,RFC 8628)是 OAuth 2.0 的一种扩展授权类型,主要适用于智能电视、IoT 设备等输入受限的设备。CAS 6.6 开始支持设备码流程,并新增了两个相关模板。
7.3.1 deviceCodeApproval.html
当用户在浏览器中访问设备码验证 URL 时,CAS 展示此页面,要求用户输入设备上显示的用户码并确认授权:
html
<!-- 教学示例 - CAS 6.6/7.3 deviceCodeApproval.html 核心结构 -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>设备码授权</title>
<link rel="stylesheet"
th:href="@{/webjars/bootstrap/css/bootstrap.min.css}" />
</head>
<body>
<div class="container">
<div class="card mt-5 mx-auto" style="max-width:480px;">
<div class="card-header">
<h4>设备授权</h4>
</div>
<div class="card-body">
<p class="text-muted">
您的设备正在请求访问您的账户。请在下方输入设备上显示的用户码。
</p>
<form method="post" th:action="@{/oauth2.0/device_approval}">
<div class="mb-3">
<label class="form-label">用户码</label>
<input type="text" name="user_code"
class="form-control form-control-lg text-center"
placeholder="XXXX-XXXX"
required autofocus />
</div>
<button type="submit" class="btn btn-primary w-100">
提交授权
</button>
</form>
</div>
</div>
</div>
</body>
</html>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
7.3.2 deviceCodeApproved.html
当用户成功确认设备码授权后,CAS 展示此页面,告知用户授权已完成:
html
<!-- 教学示例 - CAS 6.6/7.3 deviceCodeApproved.html 核心结构 -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>设备授权成功</title>
<link rel="stylesheet"
th:href="@{/webjars/bootstrap/css/bootstrap.min.css}" />
</head>
<body>
<div class="container">
<div class="card mt-5 mx-auto" style="max-width:480px;">
<div class="card-body text-center">
<div class="mb-3">
<!-- 成功图标 -->
<span style="font-size:64px;color:#4caf50;">✓</span>
</div>
<h4>授权成功</h4>
<p class="text-muted">
您已成功授权该设备。您可以返回设备继续操作。
</p>
<p class="text-muted small">
此页面可以安全关闭。
</p>
</div>
</div>
</div>
</body>
</html>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
7.4 Access Token 管理页面(CAS 7.3 新增)
CAS 7.3 新增了 accountprofileaccesstokens.html 模板,这是 OIDC Access Token 管理页面,允许用户查看和管理自己授权的 Access Token。这个页面的引入,使得用户可以:
- 查看所有已授权的 Access Token 列表
- 查看每个 Token 的关联客户端、权限范围和过期时间
- 手动撤销不再需要的 Token
html
<!-- 教学示例 - CAS 7.3 accountprofileaccesstokens.html 核心结构 -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Access Token 管理</title>
<link rel="stylesheet"
th:href="@{/webjars/bootstrap/css/bootstrap.min.css}" />
</head>
<body>
<div class="container mt-4">
<h3>已授权的 Access Token</h3>
<!-- Token 列表 -->
<table class="table table-striped">
<thead>
<tr>
<th>客户端</th>
<th>权限范围</th>
<th>签发时间</th>
<th>过期时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr th:each="token : ${accessTokens}">
<td th:text="${token.clientId}">客户端名称</td>
<td th:text="${token.scope}">openid profile</td>
<td th:text="${#dates.format(token.issuedAt,
'yyyy-MM-dd HH:mm')}">2024-01-01 12:00</td>
<td th:text="${#dates.format(token.expiresAt,
'yyyy-MM-dd HH:mm')}">2024-01-01 14:00</td>
<td>
<span th:if="${token.expired}"
class="badge bg-secondary">已过期</span>
<span th:unless="${token.expired}"
class="badge bg-success">有效</span>
</td>
<td>
<form method="post"
th:action="@{/oauth2.0/revoke}">
<input type="hidden" name="token"
th:value="${token.id}" />
<button type="submit"
class="btn btn-sm btn-outline-danger"
th:disabled="${token.expired}">
撤销
</button>
</form>
</td>
</tr>
</tbody>
</table>
<!-- 空状态 -->
<div th:if="${#lists.isEmpty(accessTokens)}"
class="text-center text-muted mt-5">
<p>您当前没有已授权的 Access Token。</p>
</div>
</div>
</body>
</html>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
这个页面的价值:
- 用户自主权: 用户可以直观地看到哪些应用获得了自己的授权,增强了用户对个人数据的控制感。
- 安全意识: 通过展示 Token 列表,帮助用户了解 OAuth 2.0 的授权机制,提高安全意识。
- 快速撤销: 提供一键撤销功能,用户可以在发现异常授权时快速撤销。
八、生产环境安全建议
8.1 Crypto 密钥管理最佳实践
密钥是 JWT 签名与加密体系的核心资产。密钥管理不当,可能导致 Token 被伪造、用户数据被泄露等严重安全事件。以下是我们在多个大型项目中总结的密钥管理最佳实践。
8.1.1 密钥生成规范
使用密码学安全的随机数生成器:
bash
# 生成 256 位 AES 加密密钥(Base64 编码,44 字符)
openssl rand -base64 32
# 生成 512 位 HMAC 签名密钥(Base64 编码,88 字符)
openssl rand -base64 64
# 生成 2048 位 RSA 密钥对
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
密钥强度要求:
| 密钥类型 | 最小长度 | 推荐长度 | 说明 |
|---|---|---|---|
| AES 加密密钥 | 128 位 | 256 位 | 使用 AES-256-GCM |
| HMAC 签名密钥 | 256 位 | 512 位 | 使用 HS512 |
| RSA 私钥 | 2048 位 | 4096 位 | 使用 RS512 |
| ECDSA 私钥 | 256 位 | 384 位 | 使用 ES384 |
8.1.2 密钥存储规范
禁止的做法:
- 将密钥硬编码在源代码中
- 将密钥明文存储在配置文件中并提交到版本控制系统
- 在日志中打印密钥
- 通过不安全的渠道(如邮件、即时通讯)传输密钥
推荐的做法:
- 环境变量注入: 通过操作系统环境变量注入密钥,这是最简单也最安全的方式。
yaml
# 教学示例 - 通过环境变量注入密钥
cas:
oauth:
access-token:
crypto:
encryption:
key: "${CAS_OAUTH_ENCRYPTION_KEY}"
signing:
key: "${CAS_OAUTH_SIGNING_KEY}"1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
- 密钥管理服务(KMS): 使用专业的密钥管理服务,如 HashiCorp Vault、AWS KMS、Azure Key Vault。
yaml
# 教学示例 - 通过 Vault 注入密钥(需要 spring-cloud-vault 依赖)
cas:
oauth:
access-token:
crypto:
signing:
key: "${vault.secret.oauth.signing-key}"1
2
3
4
5
6
7
2
3
4
5
6
7
- 密钥文件: 将密钥存储在受保护的文件中,文件权限设置为仅应用用户可读。
yaml
# 教学示例 - 从文件读取密钥
cas:
oauth:
access-token:
crypto:
signing:
key: "file:/etc/cas/keys/signing.key"1
2
3
4
5
6
7
2
3
4
5
6
7
8.1.3 密钥轮换策略
密钥轮换是密钥管理的核心实践。定期更换密钥可以限制密钥泄露的影响范围。
CAS 中的密钥轮换方案:
CAS 支持通过 kid(Key ID)机制实现密钥轮换。当配置了多个密钥时,CAS 可以使用新密钥签名新的 Token,同时仍然接受旧密钥签名的 Token(在过渡期内)。
yaml
# 教学示例 - CAS 7.3 多密钥配置(密钥轮换)
cas:
oauth:
access-token:
crypto:
signing:
# 当前活跃密钥
key: "${CAS_OAUTH_SIGNING_KEY_V2}"
# 密钥标识
kid: "key-v2"1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
密钥轮换流程:
阶段1: 只有 key-v1
- 所有新 Token 使用 key-v1 签名
- 所有 Token 验证使用 key-v1
阶段2: 引入 key-v2(过渡期开始)
- 新 Token 使用 key-v2 签名(kid=key-v2)
- Token 验证同时接受 key-v1 和 key-v2
- 过渡期长度 = Access Token 最大有效期
阶段3: 过渡期结束,废弃 key-v1
- 新 Token 使用 key-v2 签名
- Token 验证只接受 key-v2
- key-v1 从配置中移除1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
轮换频率建议:
| 密钥类型 | 建议轮换频率 | 说明 |
|---|---|---|
| HMAC 签名密钥 | 90 天 | 对称密钥建议更频繁轮换 |
| RSA 私钥 | 1 年 | 非对称密钥轮换成本较高 |
| AES 加密密钥 | 90 天 | 加密密钥建议与签名密钥同步轮换 |
8.2 Token 有效期策略
Token 有效期的设置需要在安全性和用户体验之间取得平衡。以下是基于我们项目经验的最佳实践建议。
8.2.1 分层有效期策略
不同的应用场景对 Token 有效期的要求不同。建议根据客户端类型和敏感度进行分层管理:
yaml
# 教学示例 - 分层 Token 有效期配置
cas:
oauth:
# 高安全场景(如金融应用)
access-token:
time-to-kill-in-seconds: 900 # 15 分钟
refresh-token:
time-to-kill-in-seconds: 86400 # 1 天
# 一般安全场景(如企业内部应用)
# access-token: 3600 (1 小时)
# refresh-token: 604800 (7 天)
# 低安全场景(如开发测试)
# access-token: 28800 (8 小时)
# refresh-token: 2592000 (30 天)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
8.2.2 JWT Token 有效期的特殊考量
当使用 JWT AccessToken 时,需要特别注意以下几点:
时钟偏移: JWT 的
exp声明使用绝对时间戳,CAS 服务器和资源服务器之间的时钟偏差可能导致 Token 验证失败。建议在资源服务器中配置合理的时钟偏移容忍度(通常为 30-60 秒)。无法即时撤销: JWT 的自包含特性意味着一旦签发就无法即时撤销。为了缓解这个问题,可以:
- 缩短 Access Token 有效期(如 15 分钟)
- 配合 Refresh Token 使用
- 在资源服务器中维护 Token 黑名单(如 Redis 缓存)
Token 刷新策略: 当 Access Token 即将过期时,客户端应主动使用 Refresh Token 获取新的 Access Token,而不是等到 Token 完全过期后再刷新。建议在 Token 过期前 30% 的时间开始刷新。
8.3 域名白名单配置
域名白名单是保护 OAuth 2.0 回调端点的重要安全措施。它确保授权码只能被重定向到预注册的可信 URL。
8.3.1 CAS 服务定义中的 redirect_uri 匹配
在 CAS 的服务定义中,serviceId 字段同时用于匹配 OAuth 2.0 的 redirect_uri。CAS 使用正则表达式进行匹配:
json
// 教学示例 - 服务定义中的 redirect_uri 白名单
{
"@class": "org.apereo.cas.support.oauth.services.OidcRegisteredService",
"serviceId": "^https://(app|api|admin)\\.example\\.com/.*",
"name": "Example OAuth Service",
"id": 1001
}1
2
3
4
5
6
7
2
3
4
5
6
7
正则表达式安全建议:
- 使用锚点: 始终使用
^和$(或.*结尾)来限定匹配范围。 - 避免过于宽松: 不要使用
^https?://.*这样的宽松匹配,它允许任意 URL。 - 转义特殊字符: 域名中的
.需要转义为\\.,否则会匹配任意字符。 - 限制协议: 只允许
https://,除非有特殊需求才允许http://。
8.3.2 额外的域名白名单校验层
除了 CAS 服务定义中的匹配外,我们建议在回调控制器层面增加额外的域名白名单校验(如本文第六章所述的 OAuth20CallbackController 定制方案)。这种双重校验机制可以提供更深层次的安全保护。
请求到达 CAS
│
▼
第一层:CAS 服务定义匹配(serviceId 正则)
│ 匹配失败 → 拒绝请求
▼ 匹配成功
第二层:自定义域名白名单校验(CallbackController)
│ 校验失败 → 拒绝请求
▼ 校验通过
执行回调重定向1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
8.4 动态客户端注册安全
CAS 支持动态客户端注册(RFC 7591),允许客户端在运行时自动注册。虽然这个功能非常便利,但在生产环境中需要谨慎使用。
8.4.1 动态注册的风险
- 未授权的客户端注册: 攻击者可以注册恶意客户端,获取 client_id 和 client_secret。
- redirect_uri 劫持: 恶意客户端可以注册指向攻击者服务器的 redirect_uri。
- 权限范围滥用: 恶意客户端可能请求超出合理范围的权限。
8.4.2 安全加固措施
yaml
# 教学示例 - CAS 7.3 动态客户端注册安全配置
cas:
oauth:
# 动态客户端注册配置
dynamic-client-registration:
# 是否启用动态注册(生产环境建议关闭)
enabled: false
# 如果必须启用,限制可注册的权限范围
allowed-scopes:
- "openid"
- "profile"
# 限制可注册的 redirect_uri 域名
allowed-redirect-uris:
- "^https://.*\\.example\\.com/.*"1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
替代方案: 如果不需要动态注册功能,建议完全禁用,改用预注册的方式管理客户端。所有客户端的注册信息通过 JSON 服务定义文件或 CAS Management 工具进行管理。
8.5 其他安全建议
8.5.1 HTTPS 强制
所有 OAuth 2.0 端点必须使用 HTTPS。CAS 可以通过以下配置强制 HTTPS:
yaml
# 教学示例 - 强制 HTTPS
cas:
web:
security:
require-https: true1
2
3
4
5
2
3
4
5
8.5.2 PKCE 支持
对于公开客户端(如 SPA、移动 App),强制使用 PKCE(Proof Key for Code Exchange):
yaml
# 教学示例 - CAS 7.3 PKCE 配置
cas:
oauth:
pkce:
# 是否要求公开客户端必须使用 PKCE
required-for-public-clients: true
# 支持的 PKCE 挑战方法
supported-challenge-methods:
- "S256"1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
8.5.3 速率限制
对 OAuth 2.0 端点实施速率限制,防止暴力破解和拒绝服务攻击:
yaml
# 教学示例 - CAS 7.3 速率限制配置
cas:
web:
security:
ip-throttling:
enabled: true
max-attempts-per-second: 101
2
3
4
5
6
7
2
3
4
5
6
7
8.5.4 审计日志
启用 OAuth 2.0 操作的审计日志,记录所有 Token 的签发、验证和撤销操作:
yaml
# 教学示例 - CAS 7.3 审计日志配置
cas:
audit:
log:
enabled: true
# 审计日志文件路径
destination: "file:/var/log/cas/audit.log"1
2
3
4
5
6
7
2
3
4
5
6
7
九、JWT AccessToken 在微服务架构中的集成实践
9.1 微服务架构下的 Token 验证模式
在微服务架构中,JWT AccessToken 的无状态验证特性使其成为理想的认证方案。然而,将 JWT 从 CAS 集成到微服务体系中,需要考虑多个技术维度的问题。本节基于我们实际项目的经验,介绍几种常见的集成模式。
9.1.1 API 网关统一验证模式
这是最常见的集成模式。API 网关作为所有外部请求的入口,统一负责 JWT Token 的验证。验证通过后,网关将用户信息注入到请求头中,传递给下游微服务。
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ Client │────>│ API Gateway │────>│ Micro │
│ App │ │ (JWT验证) │ │ Service A │
│ │ │ │ │ │
└──────────┘ └──────┬───────┘ └──────────────┘
│
│ 注入用户信息到请求头
│ X-User-Id: zhangsan
│ X-User-Name: 张三
│ X-Scope: openid profile
│
┌──────▼───────┐
│ Micro │
│ Service B │
└──────────────┘1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
API 网关 JWT 验证的核心逻辑:
java
// 教学示例 - API 网关 JWT 验证过滤器
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 从 CAS 配置中获取的签名密钥
@Value("${cas.oauth.jwt.signing-key}")
private String signingKey;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 1. 从 Authorization Header 中提取 JWT
String token = extractBearerToken(request);
if (token == null) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("{\"error\":\"missing_token\"}");
return;
}
try {
// 2. 验证 JWT 签名
Jws<Claims> jws = Jwts.parserBuilder()
.setSigningKey(signingKey.getBytes(StandardCharsets.UTF_8))
.build()
.parseClaimsJws(token);
Claims claims = jws.getBody();
// 3. 验证过期时间
Date expiration = claims.getExpiration();
if (expiration.before(new Date())) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("{\"error\":\"token_expired\"}");
return;
}
// 4. 验证签发者
String issuer = claims.getIssuer();
if (!issuer.startsWith("https://cas.example.com")) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("{\"error\":\"invalid_issuer\"}");
return;
}
// 5. 将用户信息注入到请求头中,传递给下游服务
String userId = claims.getSubject();
String scope = (String) claims.get("scope");
HttpServletRequest wrappedRequest = new JwtHeaderWrapper(
request, userId, scope);
filterChain.doFilter(wrappedRequest, response);
} catch (JwtException e) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("{\"error\":\"invalid_token\"}");
}
}
private String extractBearerToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
}
return null;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
9.1.2 微服务本地验证模式
在这种模式下,每个微服务都独立验证 JWT Token,不依赖 API 网关。这种模式适用于没有统一 API 网关的架构,或者对安全性要求更高的场景。
java
// 教学示例 - 微服务本地 JWT 验证工具类
public class JwtTokenValidator {
private final SecretKey signingKey;
private final String expectedIssuer;
public JwtTokenValidator(String signingKey, String expectedIssuer) {
this.signingKey = Keys.hmacShaKeyFor(
signingKey.getBytes(StandardCharsets.UTF_8));
this.expectedIssuer = expectedIssuer;
}
/**
* 验证 JWT Token 并提取用户信息
*/
public JwtValidationResult validate(String token) {
try {
Jws<Claims> jws = Jwts.parserBuilder()
.setSigningKey(signingKey)
.requireIssuer(expectedIssuer)
.build()
.parseClaimsJws(token);
Claims claims = jws.getBody();
return JwtValidationResult.success(
claims.getSubject(),
(String) claims.get("scope"),
(String) claims.get("client_id"),
claims.getExpiration()
);
} catch (ExpiredJwtException e) {
return JwtValidationResult.failure("TOKEN_EXPIRED",
"Token 已过期");
} catch (JwtException e) {
return JwtValidationResult.failure("INVALID_TOKEN",
"Token 无效: " + e.getMessage());
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
9.1.3 混合验证模式
混合验证模式结合了网关验证和本地验证的优点。API 网关执行基本的 Token 验证(签名、过期时间),而关键微服务在网关验证的基础上,执行额外的权限检查(如 scope 验证、资源所有权验证)。
请求流程:
Client → API Gateway(基本验证)→ Micro Service(权限验证)→ Resource
验证层次:
第一层(API Gateway):签名验证、过期时间验证、签发者验证
第二层(Micro Service):Scope 验证、资源权限验证、业务规则验证1
2
3
4
5
6
2
3
4
5
6
9.2 JWT Token 黑名单机制
由于 JWT 的自包含特性,一旦签发就无法即时撤销。在生产环境中,这可能导致安全问题——例如,当用户修改密码或被禁用时,之前签发的 JWT 仍然有效。为了解决这个问题,我们引入了 Token 黑名单机制。
9.2.1 基于 Redis 的 Token 黑名单
java
// 教学示例 - JWT Token 黑名单服务
@Service
public class JwtTokenBlacklistService {
private final RedisTemplate<String, String> redisTemplate;
private static final String BLACKLIST_PREFIX = "jwt:blacklist:";
/**
* 将 Token 加入黑名单
* @param jti JWT 的唯一标识符
* @param remainingMillis Token 的剩余有效时间(毫秒)
*/
public void blacklist(String jti, long remainingMillis) {
String key = BLACKLIST_PREFIX + jti;
// 设置过期时间与 Token 的剩余有效期一致
// 这样黑名单条目会自动清理,不会无限增长
redisTemplate.opsForValue().set(
key, "revoked",
Duration.ofMillis(remainingMillis));
}
/**
* 检查 Token 是否在黑名单中
*/
public boolean isBlacklisted(String jti) {
String key = BLACKLIST_PREFIX + jti;
Boolean exists = redisTemplate.hasKey(key);
return Boolean.TRUE.equals(exists);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
9.2.2 与 CAS Token 撤销端点的集成
当用户在 CAS 中撤销 Token 时,CAS 的 /oauth2.0/revoke 端点会被调用。我们可以通过监听 Token 撤销事件,自动将对应的 JWT 加入黑名单:
java
// 教学示例 - Token 撤销事件监听器
@Component
public class TokenRevocationListener {
private final JwtTokenBlacklistService blacklistService;
/**
* 当 CAS 撤销 Token 时,将 JWT 加入黑名单
*/
public void onTokenRevoked(String tokenValue) {
try {
// 解析 JWT 获取 jti 和过期时间
Claims claims = Jwts.parserBuilder()
.setSigningKey(signingKey.getBytes(StandardCharsets.UTF_8))
.build()
.parseClaimsJws(tokenValue)
.getBody();
String jti = claims.getId();
Date expiration = claims.getExpiration();
long remainingMillis = expiration.getTime() -
System.currentTimeMillis();
if (remainingMillis > 0) {
blacklistService.blacklist(jti, remainingMillis);
}
} catch (JwtException e) {
// 非 JWT 格式的 Token,忽略
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
9.3 多租户场景下的 JWT 定制
在多租户(Multi-tenant)场景中,不同的租户可能有不同的安全要求和用户属性结构。CAS 的 JWT AccessToken 可以通过自定义 OAuth20UserProfileDataCreator 来支持多租户需求。
java
// 教学示例 - 多租户 UserProfileDataCreator
@Slf4j
public class MultiTenantUserProfileDataCreator
implements OAuth20UserProfileDataCreator {
@Override
public Map<String, Object> create(final AccessToken accessToken) {
String clientId = accessToken.getClientId();
String tenantId = extractTenantId(clientId);
log.debug("[OAuth2.0] 创建多租户用户 Profile, 租户: {}", tenantId);
Authentication auth = accessToken.getAuthentication();
Principal principal = auth.getPrincipal();
String userId = principal.getId();
Map<String, Object> profile = new LinkedHashMap<>();
profile.put("id", userId);
profile.put("tenant_id", tenantId);
// 根据租户定制用户属性
Map<String, Object> attributes = new LinkedHashMap<>();
switch (tenantId) {
case "tenant-finance":
// 金融租户:包含额外的安全属性
attributes.put("displayName",
principal.getAttribute("displayName"));
attributes.put("email",
principal.getAttribute("mail"));
attributes.put("securityLevel",
principal.getAttribute("securityLevel"));
attributes.put("riskScore",
principal.getAttribute("riskScore"));
break;
case "tenant-retail":
// 零售租户:包含会员等级信息
attributes.put("displayName",
principal.getAttribute("displayName"));
attributes.put("email",
principal.getAttribute("mail"));
attributes.put("memberLevel",
principal.getAttribute("memberLevel"));
attributes.put("loyaltyPoints",
principal.getAttribute("loyaltyPoints"));
break;
default:
// 默认租户:标准属性
attributes.put("displayName",
principal.getAttribute("displayName"));
attributes.put("email",
principal.getAttribute("mail"));
}
profile.put("attributes", attributes);
return profile;
}
/**
* 从 client_id 中提取租户标识
* 约定 client_id 格式: {tenant-id}-{app-name}
*/
private String extractTenantId(String clientId) {
int separatorIndex = clientId.indexOf('-');
if (separatorIndex > 0) {
return clientId.substring(0, separatorIndex);
}
return "default";
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
十、CAS OAuth 2.0 常见问题与故障排查
10.1 JWT Token 验证失败常见原因
在实际项目中,JWT Token 验证失败是最常见的问题之一。以下是我们在多个项目中遇到的典型故障场景和排查思路。
10.1.1 签名不匹配
症状: 资源服务器返回 invalid_token 错误,日志中显示签名验证失败。
常见原因:
- 密钥不一致: CAS 服务器和资源服务器使用的签名密钥不同。这是最常见的原因,通常发生在密钥轮换后,资源服务器没有同步更新密钥。
- 密钥格式错误: 密钥在传输过程中被截断或修改。例如,Base64 编码的密钥中包含
+、/、=等特殊字符,在配置文件中可能需要转义。 - 编码问题: 密钥的字节编码不一致。CAS 和资源服务器可能使用不同的字符编码(如 UTF-8 vs ISO-8859-1)来处理密钥字符串。
排查步骤:
bash
# 步骤1: 确认 CAS 服务器的签名密钥
curl -s https://cas.example.com/actuator/env/CAS_OAUTH_SIGNING_KEY \
-H "Authorization: Bearer $MANAGEMENT_TOKEN"
# 步骤2: 确认资源服务器的签名密钥
curl -s https://api.example.com/actuator/env/CAS_OAUTH_JWT_SIGNING_KEY \
-H "Authorization: Bearer $MANAGEMENT_TOKEN"
# 步骤3: 使用 jwt.io 在线工具验证签名
# 将 Token 和密钥粘贴到 jwt.io,检查签名是否匹配
# 步骤4: 使用命令行工具验证
echo "YOUR_JWT_TOKEN" | cut -d'.' -f1,2 | tr '.-' '/+' | \
base64 -d 2>/dev/null | python3 -m json.tool1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
10.1.2 Token 过期
症状: Token 刚签发不久就被拒绝,提示已过期。
常见原因:
- 时钟不同步: CAS 服务器和资源服务器的时间不一致。JWT 的
exp声明使用绝对时间戳,如果资源服务器的时钟比 CAS 服务器快,会导致 Token 被提前判定为过期。 - 时区配置错误: 服务器使用了不同的时区设置。
- Token 有效期配置过短:
time-to-kill-in-seconds设置得太小。
排查步骤:
bash
# 检查两台服务器的时间差
# 在 CAS 服务器上执行
date +%s
# 在资源服务器上执行
date +%s
# 如果时间差超过 60 秒,需要同步时间
# 使用 NTP 同步时间
sudo ntpdate pool.ntp.org
# 或者使用 chrony(推荐)
sudo systemctl enable chronyd
sudo systemctl start chronyd1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
10.1.3 Audience 不匹配
症状: Token 验证失败,错误信息中包含 aud 或 audience 相关的描述。
常见原因:
- 服务定义变更: CAS 中 OAuth 2.0 服务定义的
serviceId发生了变化,但已签发的 JWT 中的aud声明仍然是旧值。 - 多服务共享 Token: 同一个 JWT 被用于访问多个资源服务,但
aud声明只包含其中一个服务的标识。
解决方案:
在资源服务器中配置宽松的 Audience 验证策略,或者确保所有需要共享 Token 的服务都在 CAS 的服务定义中正确注册。
10.2 CAS OAuth 2.0 端点调试技巧
10.2.1 使用 curl 测试完整授权流程
bash
#!/bin/bash
# 教学示例 - CAS OAuth 2.0 完整授权流程测试脚本
CAS_SERVER="https://cas.example.com"
CLIENT_ID="test-client"
CLIENT_SECRET="test-secret"
REDIRECT_URI="https://app.example.com/callback"
echo "=== 步骤1: 获取授权码 ==="
echo "请在浏览器中访问以下 URL:"
echo "${CAS_SERVER}/oauth2.0/authorize?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&scope=openid profile"
echo ""
read -p "请输入回调 URL 中的 code 参数值: " AUTH_CODE
echo ""
echo "=== 步骤2: 用授权码换取 Access Token ==="
TOKEN_RESPONSE=$(curl -s -X POST "${CAS_SERVER}/oauth2.0/accessToken" \
-d "grant_type=authorization_code" \
-d "code=${AUTH_CODE}" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d "redirect_uri=${REDIRECT_URI}")
echo "Token 响应: ${TOKEN_RESPONSE}"
ACCESS_TOKEN=$(echo "${TOKEN_RESPONSE}" | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null)
echo ""
echo "=== 步骤3: 使用 Access Token 获取用户信息 ==="
curl -s "${CAS_SERVER}/oauth2.0/profile?access_token=${ACCESS_TOKEN}" | python3 -m json.tool
echo ""
echo "=== 步骤4: Token 自省 ==="
curl -s -X POST "${CAS_SERVER}/oauth2.0/introspect" \
-d "token=${ACCESS_TOKEN}" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" | python3 -m json.tool
echo ""
echo "=== 步骤5: 撤销 Token ==="
curl -s -X POST "${CAS_SERVER}/oauth2.0/revoke" \
-d "token=${ACCESS_TOKEN}" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}"
echo "Token 已撤销"1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
10.2.2 JWT Token 解码与验证
bash
# 教学示例 - JWT Token 解码脚本
# 使用方法: ./decode-jwt.sh YOUR_JWT_TOKEN
TOKEN=$1
if [ -z "$TOKEN" ]; then
echo "用法: $0 <JWT_TOKEN>"
exit 1
fi
# 解码 Header
echo "=== Header ==="
echo "$TOKEN" | cut -d'.' -f1 | tr '_-' '/+' | \
base64 -d 2>/dev/null | python3 -m json.tool
# 解码 Payload
echo ""
echo "=== Payload ==="
echo "$TOKEN" | cut -d'.' -f2 | tr '_-' '/+' | \
base64 -d 2>/dev/null | python3 -m json.tool
# 显示 Signature(Base64 编码)
echo ""
echo "=== Signature ==="
echo "$TOKEN" | cut -d'.' -f3
echo ""
echo "注意: Signature 是 Base64URL 编码的,无法直接解码为可读内容。"
echo "请使用 jwt.io 等工具配合密钥进行签名验证。"1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
10.3 CAS OAuth 2.0 性能优化建议
10.3.1 JWT Token 缓存
在资源服务器中,可以缓存已验证的 JWT Token,避免重复的签名验证计算:
java
// 教学示例 - JWT Token 验证结果缓存
@Service
public class CachedJwtTokenValidator {
private final JwtTokenValidator delegate;
private final Cache<String, JwtValidationResult> cache;
public CachedJwtTokenValidator(JwtTokenValidator delegate) {
this.delegate = delegate;
// 使用 Caffeine 缓存,最大 10000 条,过期时间 5 分钟
this.cache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(Duration.ofMinutes(5))
.build();
}
public JwtValidationResult validate(String token) {
// 使用 JWT 的 jti(唯一标识符)作为缓存键
String jti = extractJti(token);
if (jti != null) {
JwtValidationResult cached = cache.getIfPresent(jti);
if (cached != null && cached.isSuccess()) {
return cached;
}
}
// 缓存未命中,执行实际验证
JwtValidationResult result = delegate.validate(token);
if (jti != null && result.isSuccess()) {
cache.put(jti, result);
}
return result;
}
private String extractJti(String token) {
try {
// 快速解码 JWT 获取 jti,不验证签名
String payload = token.split("\\.")[1];
String json = new String(
Base64.getUrlDecoder().decode(payload),
StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree(json);
return node.has("jti") ? node.get("jti").asText() : null;
} catch (Exception e) {
return null;
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
10.3.2 Token 存储优化
当使用 Redis 作为 Token 存储后端时,可以通过以下配置优化性能:
yaml
# 教学示例 - Redis Token 存储优化配置
spring:
data:
redis:
host: redis-cluster.example.com
port: 6379
# 使用连接池
lettuce:
pool:
max-active: 50
max-idle: 20
min-idle: 5
max-wait: 3000ms
# 启用 SSL
ssl: true
# 连接超时
timeout: 5000ms1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Redis 集群部署建议:
- 主从复制: 至少配置 1 主 2 从,确保高可用。
- 哨兵模式: 使用 Redis Sentinel 实现自动故障转移。
- 持久化策略: Token 数据不需要持久化(重启后可以重新签发),建议关闭 RDB 和 AOF,使用纯内存模式以提高性能。
- 内存淘汰策略: 设置
maxmemory-policy为volatile-ttl,Redis 会优先淘汰即将过期的 Key。
总结与展望
本文从 CAS OAuth 2.0 的 Token 机制概述出发,深入解析了 JWT AccessToken 的签名与加密体系,涵盖了从 CAS 5.3 到 7.3 三个版本的演进历程。让我们回顾一下本文的核心要点:
Token 机制层面: 我们对比了 Opaque Token 和 JWT Token 的本质差异,明确了 JWT Token 在微服务架构中的优势,以及它带来的撤销困难和密钥管理等新挑战。
配置体系层面: 我们详细解析了 CAS 7.3 中 access-token.crypto 配置的每一个细节,包括加密密钥(AES)、签名密钥(HMAC)的配置方式,以及跨版本配置属性命名从 camelCase 到 kebab-case 的迁移路径。
代码定制层面: 我们展示了 OAuth20UserProfileDataCreator 从 CAS 5.3 的反射获取用户 ID 到 7.3 的直接 API 调用的三代演进,以及 OAuth20UserProfileViewRenderer 在 CAS 7.3 中解决 userInfo 对象被转为数组问题的方案。
安全加固层面: 我们提供了覆盖密钥管理、Token 有效期、域名白名单、动态客户端注册、PKCE、速率限制等多个维度的生产环境安全建议。
展望未来,CAS OAuth 2.0 的发展趋势包括:
DPoP(Demonstrating Proof-of-Possession): 作为 OAuth 2.0 的新一代安全绑定机制,DPoP 可以将 Token 与特定的客户端实例绑定,防止 Token 被窃取后重放。CAS 后续版本有望原生支持 DPoP。
Token Binding: 通过 TLS 层的证书绑定,将 Token 与特定的 TLS 连接关联,进一步增强 Token 的安全性。
RAR(Rich Authorization Requests): 允许客户端以更精细的粒度请求权限,替代传统的 scope 机制。
JWT RT(RFC 9365): 将 Refresh Token 也格式化为 JWT,支持无状态的 Token 续期。
无论 CAS 如何演进,理解 OAuth 2.0 的核心安全机制——Token 的签名、加密、生命周期管理——始终是构建安全可靠的认证授权系统的基础。希望本文能够为你在 CAS OAuth 2.0 的实践中提供有价值的参考。
如果你正在实施 CAS OAuth 2.0 项目,或者对本文中的任何技术细节有疑问,欢迎访问 bima.cc 获取更多技术支持和完整项目代码。
版权声明: 本文为必码(bima.cc)原创技术文章,仅供学习交流。
本文内容基于实际项目源码解析整理,代码示例均为教学简化版本,仅供学习参考。
文档内容提取自项目源码与配置文件,如需获取完整项目代码,请访问 bima.cc。