Appearance
Apereo CAS OAuth 2.0 集成深度定制实战:从授权码到用户信息的多维扩展
作者: 必码 | bima.cc
一、CAS OAuth 2.0 架构概述
1.1 CAS 作为 OAuth 2.0 授权服务器的定位
Apereo CAS(Central Authentication Service)作为企业级单点登录(SSO)解决方案的事实标准,其 OAuth 2.0 模块为现代微服务架构提供了统一的身份认证与授权能力。在大型企业 IT 架构中,CAS 通常部署在身份认证层的核心位置,向下对接 LDAP、Active Directory、数据库等多种用户存储,向上为数十乃至数百个业务应用提供统一的认证与授权服务。
将 CAS 作为 OAuth 2.0 授权服务器使用,意味着你可以复用已有的 SSO 基础设施,避免引入额外的授权服务器组件(如 Keycloak、Auth0 等),从而降低系统复杂度和运维成本。这种"SSO + OAuth 2.0 一体化"的架构模式,在以下场景中尤为适用:
场景一:统一身份门户。 企业已经部署了 CAS 作为 SSO 解决方案,新建设的微服务应用需要通过 OAuth 2.0 协议获取用户身份信息。此时,直接在 CAS 上启用 OAuth 2.0 模块是最经济的选择。
场景二:API 网关统一鉴权。 在 API 网关层面,需要统一验证来自不同客户端的 Access Token。CAS 作为 Token 签发方,与 API 网关配合,形成完整的鉴权链路。
场景三:移动端与第三方应用接入。 移动 App 和第三方合作伙伴的应用需要通过 OAuth 2.0 协议安全地获取用户授权,访问受限资源。
从架构层面来看,CAS OAuth 2.0 的核心价值在于:
- 协议兼容性: 完整实现 OAuth 2.0 协议规范(RFC 6749),同时支持 OpenID Connect 1.0(通过 OIDC 模块)。
- 扩展性: 提供丰富的扩展点,允许开发者在不修改核心代码的前提下定制行为。
- 多协议融合: CAS 原生支持 CAS Protocol、SAML、OpenID Connect、OAuth 2.0 等多种协议,可以在同一套基础设施上同时服务不同协议的客户端。
- 企业级特性: 内置集群支持、会话管理、审计日志、速率限制等企业级功能。
1.2 OAuth 2.0 授权类型支持全景
CAS OAuth 2.0 模块支持 RFC 6749 中定义的全部四种授权类型,以及一些扩展授权类型。下面我们逐一分析每种授权类型的适用场景和 CAS 中的实现特点。
1.2.1 授权码模式(Authorization Code Grant)
授权码模式是 OAuth 2.0 中安全性最高的授权类型,也是最常用的模式。其核心流程如下:
┌──────────┐ ┌──────────┐ ┌──────────────┐
│ │ 1. │ │ 2. │ │
│ Client │──────>│ CAS │──────>│ Resource │
│ App │<──────│ OAuth │<──────│ Owner │
│ │ 3. │ Server │ 4. │ (User) │
│ │──────>│ │──────>│ │
└──────────┘ └──────────┘ └──────────────┘
│ 5. │
│<───────────────────────────────────────│
│ 6. │
│───────────────────────────────────────>│
│ 7. │
│<───────────────────────────────────────│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
步骤详解:
- 客户端将用户重定向到 CAS 的授权端点(
/oauth2.0/authorize),携带response_type=code、client_id、redirect_uri、scope等参数。 - CAS 验证用户会话,如果用户未登录,则展示 CAS 登录页面。
- 用户登录成功后,CAS 展示授权确认页面(可配置跳过)。
- 用户确认授权后,CAS 生成授权码,并通过重定向将授权码返回给客户端。
- 客户端后端使用授权码向 CAS 的 Token 端点(
/oauth2.0/accessToken)发起请求,换取 Access Token。 - CAS 验证授权码的有效性,签发 Access Token(可选签发 Refresh Token)。
- 客户端使用 Access Token 访问受保护资源。
在 CAS 中,授权码模式是默认启用且推荐使用的授权类型。它适用于有服务端的 Web 应用、移动应用等能够安全保存 client_secret 的场景。
1.2.2 隐式模式(Implicit Grant)
隐式模式主要用于纯前端应用(如单页应用 SPA),这些应用无法安全地保存 client_secret。在这种模式下,Access Token 直接通过 URL 片段(#)返回给客户端,无需经过授权码交换步骤。
Client → CAS /oauth2.0/authorize?response_type=token&client_id=xxx
CAS → User Login & Consent
CAS → Redirect to client#access_token=xxx&token_type=bearer&expires_in=xxx1
2
3
2
3
安全提示: 隐式模式由于 Access Token 暴露在浏览器 URL 中,存在 Token 泄露风险。在 CAS 7.x 中,建议使用 PKCE(Proof Key for Code Exchange)增强的授权码模式替代隐式模式。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&password=pass&client_id=xxx&client_secret=xxx1
2
3
4
2
3
4
适用场景:
- 企业内部系统之间的集成,双方由同一组织控制。
- 遗留系统的迁移过渡方案。
- 需要程序化获取 Token 的自动化脚本。
安全警告: 这种模式要求客户端必须高度可信,因为客户端会直接接触用户的凭据。在 CAS 7.3 中,可以通过 requireServiceHeader: true 配置来增强安全性,要求请求中必须携带特定的 Service Header 才能使用此模式。
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 不关联任何用户身份,仅代表客户端应用本身的身份。
1.3 模块依赖体系与架构分层
CAS OAuth 2.0 的功能由多个 Maven 模块协同实现,理解这些模块的职责和依赖关系,对于深度定制至关重要。
1.3.1 核心模块划分
cas-server-support-oauth
├── cas-server-support-oauth-core # 核心抽象与接口定义
│ ├── OAuth20AuthorizationCode # 授权码实体
│ ├── OAuth20AccessToken # 访问令牌实体
│ ├── OAuth20RefreshToken # 刷新令牌实体
│ ├── OAuth20ClientRegistration # 客户端注册
│ └── OAuth20UserProfileDataCreator # 用户资料创建接口
│
├── cas-server-support-oauth-webflow # Webflow 集成
│ ├── OAuth20AuthorizeAction # 授权动作
│ ├── OAuth20TokenGenerateAction # Token 生成动作
│ └── OAuth20ConsentView # 授权确认视图
│
├── cas-server-support-oauth-services # 服务管理集成
│ ├── OAuth20RegisteredService # OAuth 服务定义
│ ├── OAuth20ServiceRegistry # 服务注册表
│ └── OAuth20ClientRegistrationService # 客户端注册服务
│
├── cas-server-support-oauth-api # 对外 API
│ ├── OAuth20Constants # 常量定义
│ ├── OAuth20ResponseTypes # 响应类型枚举
│ └── OAuth20GrantTypes # 授权类型枚举
│
└── cas-server-support-oauth-webapp # Web 应用层
├── OAuth20Controller # 端点控制器
├── OAuth20AccessTokenController # Token 端点
└── OAuth20AuthorizeController # 授权端点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
1.3.2 模块间依赖关系
┌─────────────────────┐
│ oauth-api │
│ (常量/枚举/接口) │
└─────────┬───────────┘
│
┌─────────▼───────────┐
│ oauth-core │
│ (核心实体/逻辑) │
└─────────┬───────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌─────────▼──────┐ ┌─────▼──────┐ ┌──────▼──────────┐
│ oauth-services │ │ oauth- │ │ oauth-webapp │
│ (服务管理) │ │ webflow │ │ (Web端点) │
└────────────────┘ │ (流程集成) │ └─────────────────┘
└────────────┘1
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
模块职责说明:
oauth-api:定义了 OAuth 2.0 模块中所有公开的接口、常量和枚举类型。这是最底层的模块,不依赖任何其他 OAuth 模块。当你需要引用 OAuth 2.0 的类型定义时,只需要依赖此模块。
oauth-core:包含了 OAuth 2.0 的核心业务逻辑,包括 Token 的生成、验证、存储,以及授权码的管理等。此模块依赖 oauth-api。
oauth-webflow:将 OAuth 2.0 的授权流程集成到 CAS 的 Webflow 引擎中。CAS 使用 Spring Webflow 管理认证和授权流程的状态机,oauth-webflow 模块定义了 OAuth 2.0 相关的流程步骤和动作。
oauth-services:负责 OAuth 2.0 客户端(在 CAS 中称为"服务")的注册、管理和验证。每个 OAuth 2.0 客户端在 CAS 中都对应一个
OAuth20RegisteredService实例。oauth-webapp:提供了 OAuth 2.0 的 HTTP 端点实现,包括授权端点(
/oauth2.0/authorize)和 Token 端点(/oauth2.0/accessToken)。
1.3.3 在 CAS Overlay 项目中引入依赖
在 CAS Overlay 项目的 build.gradle 中,引入 OAuth 2.0 支持的典型配置如下:
groovy
dependencies {
// CAS OAuth 2.0 核心支持(会自动传递引入所有子模块)
implementation "org.apereo.cas:cas-server-support-oauth:${casServerVersion}"
// 如果只需要核心功能,可以精确引入子模块
// implementation "org.apereo.cas:cas-server-support-oauth-core:${casServerVersion}"
// implementation "org.apereo.cas:cas-server-support-oauth-webflow:${casServerVersion}"
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
需要注意的是,cas-server-support-oauth 是一个聚合模块,引入它会自动引入所有子模块。如果你对包体积有严格要求,可以只引入需要的子模块。
1.4 CAS Overlay 项目结构解析
CAS Overlay 是 Apereo 官方推荐的项目构建方式,它允许开发者在不修改 CAS 源码的情况下,通过覆盖和扩展的方式定制 CAS 的行为。一个典型的集成 OAuth 2.0 的 CAS Overlay 项目结构如下:
cas-overlay/
├── build.gradle # Gradle 构建配置
├── settings.gradle # Gradle 项目设置
├── gradle.properties # 版本属性配置
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/example/cas/
│ │ │ ├── config/ # 自定义配置类
│ │ │ │ ├── OAuthConfig.java
│ │ │ │ ├── TokenConfig.java
│ │ │ │ └── UserProfileConfig.java
│ │ │ ├── creator/ # 自定义组件
│ │ │ │ └── CustomUserProfileDataCreator.java
│ │ │ └── filter/ # 自定义过滤器
│ │ │ ├── CustomAuthorizeFilter.java
│ │ │ └── CustomAccessTokenFilter.java
│ │ └── resources/
│ │ ├── application.yml # 主配置文件
│ │ ├── application-oauth.yml # OAuth 专用配置
│ │ └── templates/ # 自定义模板
│ │ └── oauth/
│ │ └── consent.html # 授权确认页面
│ └── test/
│ └── java/
│ └── com/example/cas/
│ └── OAuthIntegrationTest.java
└── docs/
└── oauth-integration.md # 集成文档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
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
关键设计原则:
- 零侵入原则: 所有定制代码放在
src/main/java中,不修改 CAS 源码。 - 配置外部化: 所有可配置项通过
application.yml管理,避免硬编码。 - 模板可覆盖: CAS 的所有前端模板都可以通过在
src/main/resources/templates中放置同名文件来覆盖。 - Bean 覆盖机制: 通过 Spring 的
@Bean注解和@ConditionalOnMissingBean条件装配,优雅地替换 CAS 的默认实现。
1.5 OAuth 2.0 协议端点映射
CAS OAuth 2.0 模块在 CAS 的 Webflow 中注册了以下关键端点:
| 端点路径 | HTTP 方法 | 功能说明 | 对应控制器 |
|---|---|---|---|
/oauth2.0/authorize | GET | 授权端点,发起授权请求 | OAuth20AuthorizeController |
/oauth2.0/accessToken | POST | Token 端点,用授权码换取 Token | OAuth20AccessTokenController |
/oauth2.0/profile | GET | 用户信息端点,获取当前用户资料 | OAuth20ProfileController |
/oauth2.0/introspect | POST | Token 自省端点,验证 Token 有效性 | OAuth20IntrospectController |
/oauth2.0/revoke | POST | Token 撤销端点,吊销指定 Token | OAuth20RevokeController |
端点安全配置要点:
/oauth2.0/authorize:需要用户已登录(通过 CAS Session 验证)。/oauth2.0/accessToken:需要客户端认证(通过client_id+client_secret或 HTTP Basic Auth)。/oauth2.0/profile:需要有效的 Access Token(通过 Bearer Token 验证)。/oauth2.0/introspect:需要客户端认证,用于资源服务器验证 Token。/oauth2.0/revoke:需要客户端认证,用于主动撤销 Token。
在 CAS 7.3 中,这些端点的路径前缀可以通过配置进行自定义,以适应不同的部署环境:
yaml
cas:
server:
prefix: https://cas.example.org/cas
oauth:
endpoint:
url:
prefix: ${cas.server.prefix}/oauth2.01
2
3
4
5
6
7
2
3
4
5
6
7
1.6 CAS OAuth 2.0 与其他协议的协同
在实际的企业部署中,CAS 通常需要同时支持多种认证和授权协议。CAS OAuth 2.0 模块与 CAS 的其他协议模块(CAS Protocol、SAML、OpenID Connect)之间存在着密切的协同关系。
1.6.1 CAS Protocol 与 OAuth 2.0 的共存
CAS Protocol 是 CAS 的原生认证协议,它使用 TGT(Ticket Granting Ticket)和 ST(Service Ticket)实现单点登录。当 CAS 同时启用 CAS Protocol 和 OAuth 2.0 时,两者共享同一个认证会话(TGT)。这意味着:
- 用户通过 CAS Protocol 登录后,可以直接发起 OAuth 2.0 授权请求,无需重新登录。
- 用户通过 OAuth 2.0 授权码模式登录后,也可以直接访问 CAS Protocol 保护的资源,无需重新登录。
- TGT 的销毁(如用户主动登出)会同时影响 CAS Protocol 和 OAuth 2.0 的会话。
这种会话共享机制极大地简化了多协议环境下的用户体验管理。用户只需要登录一次,就可以通过不同的协议访问不同的应用。
1.6.2 OAuth 2.0 与 OpenID Connect 的关系
OpenID Connect(OIDC)是构建在 OAuth 2.0 之上的身份认证层。CAS 通过独立的 OIDC 模块(cas-server-support-oidc)提供 OIDC 支持。OIDC 在 OAuth 2.0 的基础上增加了以下能力:
- ID Token: 一个 JWT 格式的令牌,包含用户的身份断言信息。
- UserInfo 端点: 一个标准的端点,用于获取用户的身份信息。
- Discovery 端点: 一个自描述的配置端点,客户端可以通过它自动发现 OIDC 提供者的能力。
在 CAS 中,OAuth 2.0 和 OIDC 可以同时启用。当客户端请求 openid scope 时,CAS 会自动升级为 OIDC 流程,返回 ID Token。这种设计使得开发者可以根据客户端的能力灵活选择协议。
1.6.3 多协议统一认证架构
┌─────────────────────┐
│ 用户(浏览器/App) │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ CAS Server │
│ ┌───────────────┐ │
│ │ 认证引擎 │ │
│ │ (TGT/Session) │ │
│ └───────┬───────┘ │
│ │ │
│ ┌───────▼───────┐ │
│ │ 协议适配层 │ │
│ │ ┌───────────┐ │ │
│ │ │CAS Proto │ │ │
│ │ │OAuth 2.0 │ │ │
│ │ │OIDC │ │ │
│ │ │SAML │ │ │
│ │ └───────────┘ │ │
│ └───────────────┘ │
└─────────────────────┘
│
┌────────────────┼────────────────┐
│ │ │
┌─────────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ 传统 Web 应用 │ │ 现代SPA/ │ │ SAML SP │
│ (CAS Protocol)│ │ 移动应用 │ │ (SAML) │
│ │ │ (OAuth/OIDC)│ │ │
└────────────────┘ └─────────────┘ └─────────────┘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
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
这种多协议统一认证架构的核心价值在于:企业只需要维护一套身份基础设施,就可以服务不同技术栈、不同协议的应用。随着企业数字化转型的推进,新的应用可以使用 OAuth 2.0/OIDC,而遗留系统可以继续使用 CAS Protocol 或 SAML,两者无缝共存。
1.7 安全架构设计考量
在将 CAS 作为 OAuth 2.0 授权服务器部署到生产环境时,安全架构设计是不可忽视的关键环节。以下从多个维度分析 CAS OAuth 2.0 的安全考量。
1.7.1 传输层安全
CAS OAuth 2.0 的所有端点都必须通过 HTTPS 提供服务。授权码、Token 等敏感凭据在传输过程中如果使用 HTTP,将面临被中间人攻击截获的风险。CAS 提供了以下传输层安全配置:
yaml
cas:
server:
ssl:
enabled: true
# 强制 HTTPS 重定向
redirect-https: true1
2
3
4
5
6
2
3
4
5
6
此外,建议在反向代理层(如 Nginx、Apache)也配置 HTTPS 终止,并设置严格的安全头:
# Nginx 安全头配置示例
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Content-Security-Policy "default-src 'self'" always;1
2
3
4
5
6
2
3
4
5
6
1.7.2 客户端认证安全
OAuth 2.0 客户端的 client_secret 是保护客户端身份的关键凭据。在 CAS 中,client_secret 支持多种存储和验证方式:
- 明文存储(仅限开发环境):
{noop}secret - BCrypt 加密存储(推荐):
{bcrypt}$2a$10$... - SCrypt 加密存储:
{scrypt}$e0801$... - PBKDF2 加密存储:
{pbkdf2}... - Argon2 加密存储(CAS 7.x 新增):
{argon2}$...
在生产环境中,强烈建议使用 BCrypt 或 Argon2 加密存储 client_secret。即使数据库被泄露,攻击者也无法直接获取 client_secret 的明文值。
1.7.3 CSRF 防护
OAuth 2.0 授权端点是 CSRF 攻击的高风险目标。攻击者可以构造一个恶意页面,诱导已登录用户点击,从而在用户不知情的情况下获取授权码。CAS 通过以下机制防范 CSRF 攻击:
- state 参数验证: 客户端在授权请求中传入随机的
state参数,CAS 在回调时原样返回,客户端验证state的一致性。 - SameSite Cookie: CAS 的会话 Cookie 设置了
SameSite=Lax属性,防止跨站请求携带会话 Cookie。
客户端开发者必须实现 state 参数的生成和验证逻辑,这是防御 CSRF 攻击的第一道防线。
二、OAuth 2.0 核心配置
2.1 授权码(Authorization Code)配置
授权码是 OAuth 2.0 授权码模式中的核心临时凭据。它连接了前端授权流程和后端 Token 交换流程,是整个授权链路中承上启下的关键环节。
2.1.1 授权码生命周期参数
yaml
cas:
oauth:
authorization-code:
# 授权码有效期(秒)
# 授权码是临时凭据,有效期应设置得较短
# 默认值:300秒(5分钟)
# 推荐值:60-300秒
time-to-kill-in-seconds: 300
# 授权码使用次数
# OAuth 2.0 规范要求授权码只能使用一次
# 设置为1确保授权码被消费后立即失效
# 默认值:1
number-of-uses: 1
# 是否生成包含授权码的JSON响应
# 某些客户端可能需要JSON格式的响应
# 默认值:false
json: false1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
参数调优建议:
time-to-kill-in-seconds:如果网络环境较好(内网部署),可以适当缩短至 60 秒。如果网络延迟较高或客户端处理较慢,可以延长至 300 秒。切勿设置过长,因为授权码暴露在 URL 中,有效期越长,被截获的风险越大。number-of-uses:强烈建议保持为 1。OAuth 2.0 安全最佳实践要求授权码一次性使用。如果设置为大于 1 的值,可能导致授权码重放攻击。
2.1.2 授权码生成策略
CAS 默认使用 DefaultOAuth20AuthorizationCodeGenerator 生成授权码。授权码的格式为随机字符串,长度和字符集由加密配置决定。在 CAS 7.3 中,授权码的生成过程如下:
java
// 教学示意代码 - 展示授权码生成的核心逻辑
public class SimplifiedAuthorizationCodeGenerator {
private SecureRandom random = new SecureRandom();
public String generate() {
byte[] bytes = new byte[32]; // 256位随机数
random.nextBytes(bytes);
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(bytes);
}
}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
如果需要自定义授权码格式(例如,需要包含特定前缀以便于日志追踪),可以实现 OAuth20AuthorizationCodeGenerator 接口并通过 @Bean 注册。
2.2 访问令牌(Access Token)配置
访问令牌是客户端用来访问受保护资源的凭据。CAS 支持两种格式的 Access Token:不透明令牌(Opaque Token)和 JWT 令牌。
2.2.1 不透明令牌配置
yaml
cas:
oauth:
access-token:
# 访问令牌有效期(秒)
# 默认值:86400秒(24小时)
# 推荐值:根据业务场景调整
# - 高安全场景:3600秒(1小时)
# - 一般场景:86400秒(24小时)
# - 移动端场景:7200秒(2小时)
time-to-kill-in-seconds: 86400
# 访问令牌最大生存时间(秒)
# 用于限制通过刷新令牌获取的新访问令牌的有效期
# 默认值:43200秒(12小时)
# 注意:此值应小于或等于 time-to-kill-in-seconds
max-time-to-live-in-seconds: 43200
# 访问令牌格式
# 可选值:JWT, REFERENCE
# - JWT:自包含令牌,资源服务器可以本地验证
# - REFERENCE:不透明令牌,需要通过自省端点验证
# 默认值:REFERENCE
token-type: REFERENCE1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2.2.2 JWT 令牌配置
当需要资源服务器能够独立验证 Token 而不依赖 CAS 的自省端点时,可以使用 JWT 格式的 Access Token:
yaml
cas:
oauth:
access-token:
token-type: JWT
jwt:
# JWT 签名算法
# 推荐使用 RS256(RSA + SHA-256)
signing-alg: RS256
# JWT 主题(sub claim)
# 通常设置为用户标识符
subject-attribute: uid
# JWT 中包含的额外属性
claims:
# 将CAS属性映射到JWT claims
attribute-name: claim-name
email: email
displayName: name
# JWT ID 生成策略
jti-id: ${cas.oauth.access-token.jwt.jti-id:UUID}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
JWT vs 不透明令牌的选择策略:
| 维度 | JWT 令牌 | 不透明令牌 |
|---|---|---|
| 验证方式 | 本地验证(公钥) | 远程验证(自省端点) |
| 令牌大小 | 较大(包含 claims) | 较小(随机字符串) |
| 撤销能力 | 困难(需黑名单机制) | 简单(直接删除) |
| 性能 | 高(无网络调用) | 较低(每次验证需网络调用) |
| 安全性 | 需要管理密钥轮转 | 令牌本身无信息泄露风险 |
| 适用场景 | 微服务架构、第三方集成 | 单体应用、内网部署 |
2.3 刷新令牌(Refresh Token)配置
刷新令牌用于在访问令牌过期后获取新的访问令牌,无需用户重新登录和授权。
yaml
cas:
oauth:
refresh-token:
# 刷新令牌有效期(秒)
# 默认值:1209600秒(14天)
# 推荐值:根据安全策略调整
# - 高安全场景:259200秒(3天)
# - 一般场景:1209600秒(14天)
# - 低安全场景:2592000秒(30天)
time-to-kill-in-seconds: 1209600
# 刷新令牌是否一次性使用
# true:每次使用后生成新的刷新令牌(令牌轮转)
# false:刷新令牌可以重复使用
# 推荐值:true(令牌轮转更安全)
one-time-use: true
# 刷新令牌生成策略
# 可选值:DEFAULT, RANDOM, DETERMINISTIC
# - DEFAULT:使用默认策略
# - RANDOM:随机生成
# - DETERMINISTIC:基于客户端和用户信息确定性生成
generation-strategy: DEFAULT1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
刷新令牌轮转机制详解:
当 one-time-use 设置为 true 时,每次使用刷新令牌获取新的访问令牌时,CAS 会同时签发一个新的刷新令牌,并使旧的刷新令牌失效。这种机制被称为"令牌轮转"(Token Rotation),其安全价值在于:
- 降低令牌泄露风险: 即使刷新令牌被窃取,攻击者只能使用一次,之后令牌就会失效。
- 检测令牌重放: 如果一个已被消费的刷新令牌被再次使用,CAS 可以检测到异常行为,并撤销该令牌关联的所有令牌。
- 缩短暴露窗口: 令牌轮转使得每个刷新令牌的有效暴露窗口缩短为两次 Token 刷新之间的时间间隔。
2.4 用户资料视图类型
CAS OAuth 2.0 的 /oauth2.0/profile 端点返回的用户信息格式可以通过 userProfileViewType 配置控制。
yaml
cas:
oauth:
user-profile-view-type: NESTED1
2
3
2
3
可选值说明:
FLAT(扁平视图):
json
{
"id": "admin",
"attributes": {
"email": "admin@example.org",
"displayName": "System Administrator",
"phone": "13800138000"
}
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
NESTED(嵌套视图):
json
{
"id": "admin",
"attributes": [
{"name": "email", "values": ["admin@example.org"]},
{"name": "displayName", "values": ["System Administrator"]},
{"name": "phone", "values": ["13800138000"]}
]
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
选择建议:
FLAT:适用于简单的客户端,解析方便。大多数标准 OAuth 2.0 客户端库默认期望扁平格式。NESTED:适用于属性值可能为多值的场景。CAS 的属性模型天然支持多值属性(一个属性名对应多个值),嵌套格式能够完整表达这种多值关系。
2.5 资源所有者密码模式配置
资源所有者密码模式(ROPC)是一种便捷但安全性较低的授权方式。在 CAS 7.3 中,对 ROPC 模式增加了额外的安全控制。
yaml
cas:
oauth:
resource-owner:
# 是否要求请求中携带特定的 Service Header
# 设置为 true 时,客户端必须在请求头中携带
# CAS 协议的 Service 参数,增强安全性
# 默认值:false
require-service-header: true
# 允许使用密码模式的客户端列表
# 如果设置,只有列表中的 client_id 可以使用此模式
# 如果不设置,所有已注册客户端都可以使用此模式
allowed-clients:
- trusted-client-1
- trusted-client-21
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
requireServiceHeader 的安全机制解析:
当 require-service-header: true 时,CAS 要求密码模式的请求必须携带一个有效的 Service Header。这个 Header 的值必须对应一个在 CAS 服务注册表中已注册的服务。这种机制的作用是:
- 限制调用来源: 只有知道有效 Service 名称的客户端才能使用密码模式。
- 审计追踪: 每次密码模式的调用都关联到一个具体的服务,便于审计。
- 防止滥用: 即使攻击者获取了
client_id和client_secret,如果没有有效的 Service Header,仍然无法使用密码模式。
2.6 7.3 版本新增:AES 加密配置
CAS 7.3 对加密配置进行了重大调整,引入了更严格的密钥管理策略。所有涉及敏感数据(如 Token、授权码、客户端密钥等)的加密操作,都需要配置加密密钥和签名密钥。
2.6.1 全局加密配置
yaml
cas:
crypto:
# 加密密钥配置
# 用于对称加密(AES)
encryption:
# AES 加密密钥
# 必须是16、24或32字节的Base64编码字符串
# 分别对应AES-128、AES-192、AES-256
key: "YOUR_BASE64_ENCODED_AES_KEY_HERE"
# 签名密钥配置
# 用于数字签名(HMAC)
signing:
# HMAC 签名密钥
# 建议使用至少256位的密钥
key: "YOUR_BASE64_ENCODED_SIGNING_KEY_HERE"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
2.6.2 密钥生成方法
在 CAS Overlay 项目中,可以使用 CAS 提供的 Shell 脚本生成加密和签名密钥:
bash
# 生成 AES 加密密钥
./gradlew generateKeys -PkeyType=ENCRYPTION
# 生成 HMAC 签名密钥
./gradlew generateKeys -PkeyType=SIGNING1
2
3
4
5
2
3
4
5
生成的密钥会输出到控制台,你需要将其复制到配置文件中。
教学示例 -- 密钥生成核心逻辑:
java
// 教学示意代码 - 展示密钥生成的核心逻辑
public class KeyGenerationExample {
public static String generateEncryptionKey() {
KeyGenerator keyGen;
try {
keyGen = KeyGenerator.getInstance("AES");
keyGen.init(256, new SecureRandom());
byte[] keyBytes = keyGen.generateKey().getEncoded();
return Base64.getEncoder().encodeToString(keyBytes);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("AES algorithm not available", e);
}
}
public static String generateSigningKey() {
byte[] keyBytes = new byte[64]; // 512位
new SecureRandom().nextBytes(keyBytes);
return Base64.getEncoder().encodeToString(keyBytes);
}
}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
2.6.3 OAuth 2.0 专用加密配置
除了全局加密配置外,OAuth 2.0 模块还可以使用专用的加密配置:
yaml
cas:
oauth:
crypto:
encryption:
key: "OAUTH_SPECIFIC_AES_KEY"
signing:
key: "OAUTH_SPECIFIC_SIGNING_KEY"1
2
3
4
5
6
7
2
3
4
5
6
7
当 OAuth 2.0 专用加密配置存在时,OAuth 模块会优先使用专用配置;否则回退到全局加密配置。这种分层设计允许你为不同的模块使用不同的密钥,实现密钥隔离。
2.7 7.3 版本新增:会话复制加密
在 CAS 集群部署场景中,会话数据需要在多个节点之间复制。CAS 7.3 引入了会话复制加密功能,确保会话数据在传输和存储过程中的安全性。
yaml
cas:
session-replication:
cookie:
crypto:
# 会话复制 Cookie 的加密配置
encryption:
key: "SESSION_REPLICATION_AES_KEY"
signing:
key: "SESSION_REPLICATION_SIGNING_KEY"
# 加密算法
alg: "AES/CBC/PKCS5Padding"
# 签名算法
signing-alg: "HMACSHA512"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
会话复制加密的工作原理:
- 当用户完成认证后,CAS 创建一个认证后的会话对象(
Authentication)。 - 在集群环境中,这个会话对象需要被序列化并复制到其他节点。
- 启用会话复制加密后,CAS 在序列化之前对会话数据进行加密,在反序列化之后进行解密。
- 即使攻击者能够访问会话存储(如 Redis、Memcached),也无法读取会话内容。
2.8 完整配置参考
以下是一个生产环境推荐的 CAS OAuth 2.0 完整配置示例(教学简化版):
yaml
# ============================================
# CAS OAuth 2.0 生产环境配置参考
# ============================================
cas:
# 服务器基础配置
server:
name: https://cas.example.org
prefix: ${cas.server.name}/cas
# 全局加密配置
crypto:
encryption:
key: "${CAS_ENCRYPTION_KEY}"
signing:
key: "${CAS_SIGNING_KEY}"
# OAuth 2.0 模块配置
oauth:
# 端点 URL 前缀
endpoint:
url:
prefix: ${cas.server.prefix}/oauth2.0
# 用户资料视图类型
user-profile-view-type: FLAT
# 授权码配置
authorization-code:
time-to-kill-in-seconds: 300
number-of-uses: 1
# 访问令牌配置
access-token:
time-to-kill-in-seconds: 7200
max-time-to-live-in-seconds: 7200
token-type: REFERENCE
# 刷新令牌配置
refresh-token:
time-to-kill-in-seconds: 1209600
one-time-use: true
# 资源所有者密码模式配置
resource-owner:
require-service-header: true
# OAuth 2.0 专用加密配置(可选,不设置则使用全局配置)
crypto:
enabled: true
encryption:
key: "${CAS_OAUTH_ENCRYPTION_KEY:}"
signing:
key: "${CAS_OAUTH_SIGNING_KEY:}"
# 会话复制加密
session-replication:
cookie:
crypto:
enabled: true
encryption:
key: "${CAS_SESSION_ENCRYPTION_KEY:}"
signing:
key: "${CAS_SESSION_SIGNING_KEY:}"
# Token 存储配置(Redis)
ticket:
registry:
redis:
host: redis.example.org
port: 6379
password: "${REDIS_PASSWORD}"
database: 01
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
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
配置管理最佳实践:
- 密钥外部化: 所有密钥通过环境变量注入,不要硬编码在配置文件中。
- 环境区分: 使用 Spring Profile 为不同环境(dev、test、prod)提供不同的配置。
- 配置加密: 使用 Jasypt 等工具对配置文件中的敏感信息进行加密。
- 配置中心集成: 在微服务架构中,建议将 CAS 配置迁移到配置中心(如 Spring Cloud Config、Nacos、Apollo)。
三、自定义用户信息返回(5.3 方案)
3.1 需求背景与痛点分析
在企业级 OAuth 2.0 集成中,CAS 默认返回的用户信息往往无法满足业务需求。CAS 的 /oauth2.0/profile 端点默认返回的是用户在 CAS 认证过程中解析出的属性(通常来自 LDAP 或数据库),但这些属性可能存在以下不足:
痛点一:属性不完整。 CAS 认证时解析的属性通常是基础身份信息(如 uid、cn、mail),而业务系统可能需要更多维度的用户信息(如部门、职位、手机号、头像 URL 等)。
痛点二:属性格式不匹配。 CAS 返回的属性值格式可能与业务系统期望的格式不一致。例如,CAS 返回的部门 DN(ou=Engineering,dc=example,dc=org),而业务系统期望的是部门名称(Engineering)。
痛点三:需要关联查询。 某些用户信息存储在独立的业务数据库中,不在 CAS 的用户存储中。例如,用户的积分、等级、标签等信息。
痛点四:动态属性计算。 某些属性需要实时计算,如用户的权限列表、角色信息等,这些信息可能需要从多个数据源聚合。
典型需求场景:
某大型制造企业部署了 CAS 作为统一认证平台,其 OA 系统、ERP 系统、MES 系统均通过 OAuth 2.0 对接 CAS。各系统对用户信息的需求如下:
| 系统 | 需要的用户属性 | 数据来源 |
|---|---|---|
| OA 系统 | 工号、姓名、部门、职位、手机号、邮箱 | LDAP + 人事数据库 |
| ERP 系统 | 工号、姓名、成本中心、采购权限 | LDAP + ERP 数据库 |
| MES 系统 | 工号、姓名、车间、工位、班次 | LDAP + MES 数据库 |
显然,CAS 默认的属性返回机制无法满足这些差异化需求。我们需要一种灵活的机制,能够根据不同的客户端(client_id)返回不同的用户属性集。
3.2 OAuth20UserProfileDataCreator 定制
OAuth20UserProfileDataCreator 是 CAS OAuth 2.0 模块中负责构建用户资料的核心接口。通过自定义此接口的实现,我们可以在用户资料返回之前注入自定义逻辑。
3.2.1 接口定义分析
java
// CAS 源码中的接口定义(简化版)
public interface OAuth20UserProfileDataCreator {
/**
* 根据认证信息和授权请求构建用户资料
*
* @param authentication CAS 认证信息
* @param service 已注册的 OAuth 服务
* @param context 当前请求上下文
* @return 用户资料 Map
*/
Map<String, Object> create(Authentication authentication,
RegisteredService service,
HttpContext context);
}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
3.2.2 自定义实现示例
以下是一个教学用途的简化版自定义实现,展示了如何根据不同的客户端返回不同的用户属性:
java
// 教学示意代码 - 自定义用户资料创建器
public class CustomUserProfileDataCreator implements OAuth20UserProfileDataCreator {
// 用户详情服务接口(需自行定义)
private UserDetailsProvider userDetailsProvider;
// 默认创建器(用于回退)
private OAuth20UserProfileDataCreator defaultCreator;
@Override
public Map<String, Object> create(Authentication authentication,
RegisteredService service,
HttpContext context) {
// 1. 先使用默认创建器获取基础属性
Map<String, Object> profile = defaultCreator.create(
authentication, service, context);
// 2. 获取当前客户端标识
String clientId = extractClientId(service);
// 3. 获取用户标识
String userId = authentication.getPrincipal().getId();
// 4. 根据客户端类型补充不同的用户属性
switch (clientId) {
case "oa-system":
enrichForOA(profile, userId);
break;
case "erp-system":
enrichForERP(profile, userId);
break;
case "mes-system":
enrichForMES(profile, userId);
break;
default:
enrichDefault(profile, userId);
break;
}
return profile;
}
/**
* 为 OA 系统补充用户属性
*/
private void enrichForOA(Map<String, Object> profile, String userId) {
// 从人事数据库查询用户详情
UserDetail detail = userDetailsProvider.findByUserId(userId);
if (detail != null) {
profile.put("department", detail.getDepartment());
profile.put("position", detail.getPosition());
profile.put("mobile", detail.getMobile());
profile.put("employeeId", detail.getEmployeeId());
}
}
/**
* 为 ERP 系统补充用户属性
*/
private void enrichForERP(Map<String, Object> profile, String userId) {
UserDetail detail = userDetailsProvider.findByUserId(userId);
if (detail != null) {
profile.put("costCenter", detail.getCostCenter());
profile.put("purchaseAuth", detail.hasPurchaseAuth());
}
}
// ... 其他 enrich 方法类似
}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
3.2.3 注册自定义创建器
在 CAS 5.3 中,注册自定义组件通常需要通过 Spring 的 @Bean 注解或 XML 配置:
java
// 教学示意代码 - 注册自定义用户资料创建器
@Configuration
public class CustomOAuthConfig {
@Bean
@ConditionalOnMissingBean(name = "oauthUserProfileDataCreator")
public OAuth20UserProfileDataCreator oauthUserProfileDataCreator(
UserDetailsProvider userDetailsProvider,
@Qualifier("defaultOAuth20UserProfileDataCreator")
OAuth20UserProfileDataCreator defaultCreator) {
CustomUserProfileDataCreator creator = new CustomUserProfileDataCreator();
creator.setUserDetailsProvider(userDetailsProvider);
creator.setDefaultCreator(defaultCreator);
return creator;
}
}1
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
3.3 OAuth20UserProfileViewRenderer 定制
OAuth20UserProfileViewRenderer 负责将用户资料对象渲染为 HTTP 响应。CAS 默认使用 JSON 格式渲染,但某些场景下可能需要自定义渲染逻辑。
3.3.1 接口定义分析
java
// CAS 源码中的接口定义(简化版)
public interface OAuth20UserProfileViewRenderer {
/**
* 渲染用户资料为 HTTP 响应
*
* @param profile 用户资料
* @param response HTTP 响应对象
* @throws Exception 渲染异常
*/
void render(Map<String, Object> profile,
HttpServletResponse response) throws Exception;
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
3.3.2 自定义渲染器示例
以下示例展示了如何自定义响应格式,添加额外的响应头和自定义字段:
java
// 教学示意代码 - 自定义用户资料视图渲染器
public class CustomUserProfileViewRenderer implements OAuth20UserProfileViewRenderer {
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public void render(Map<String, Object> profile,
HttpServletResponse response) throws Exception {
// 1. 构建增强的响应体
Map<String, Object> enhancedResponse = new LinkedHashMap<>();
enhancedResponse.put("code", 200);
enhancedResponse.put("message", "success");
enhancedResponse.put("data", profile);
enhancedResponse.put("timestamp", System.currentTimeMillis());
// 2. 设置响应头
response.setContentType("application/json;charset=UTF-8");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Cache-Control", "no-store, no-cache");
// 3. 写入响应
String json = objectMapper.writeValueAsString(enhancedResponse);
response.getWriter().write(json);
response.getWriter().flush();
}
}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
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
3.4 Filter 拦截方案设计
在 CAS 5.3 时代,由于 CAS 的扩展点不够丰富,很多定制需求需要通过 Servlet Filter 来实现。Filter 方案的核心思想是在 CAS 的 Controller 处理请求之前和之后,通过 Filter 拦截请求和响应,注入自定义逻辑。
3.4.1 Filter 方案的架构
HTTP Request
│
▼
┌─────────────────────────────────────┐
│ CustomAuthorizeFilter │
│ ├─ 拦截 /oauth2.0/authorize 请求 │
│ ├─ 包装 Request 对象 │
│ └─ 在 Response 中注入自定义数据 │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ CAS OAuth20AuthorizeController │
│ ├─ 标准授权流程处理 │
│ └─ 生成授权码 │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ CustomAccessTokenFilter │
│ ├─ 拦截 /oauth2.0/accessToken 请求 │
│ ├─ 包装 Request 对象 │
│ └─ 增强 Token 响应 │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ CAS OAuth20AccessTokenController │
│ ├─ 验证授权码 │
│ └─ 签发 Access 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
3.4.2 Filter 注册方式
在 CAS 5.3 中,Filter 的注册通常通过 FilterRegistrationBean 实现:
java
// 教学示意代码 - Filter 注册
@Configuration
public class CustomFilterConfig {
@Bean
public FilterRegistrationBean<CustomAuthorizeFilter> authorizeFilterRegistration() {
FilterRegistrationBean<CustomAuthorizeFilter> registration =
new FilterRegistrationBean<>();
registration.setFilter(new CustomAuthorizeFilter());
registration.addUrlPatterns("/oauth2.0/authorize");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
return registration;
}
@Bean
public FilterRegistrationBean<CustomAccessTokenFilter> accessTokenFilterRegistration() {
FilterRegistrationBean<CustomAccessTokenFilter> registration =
new FilterRegistrationBean<>();
registration.setFilter(new CustomAccessTokenFilter());
registration.addUrlPatterns("/oauth2.0/accessToken");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
3.5 Oauth2AuthorizeRequestWrapper 与 Oauth2AuthorizeFilter
3.5.1 设计思路
Oauth2AuthorizeRequestWrapper 是一个 HttpServletRequestWrapper 的子类,用于在授权请求处理过程中修改请求参数或注入额外信息。Oauth2AuthorizeFilter 则是使用这个 Wrapper 的 Servlet Filter。
典型使用场景:
- 动态 Scope 注入: 根据客户端类型或用户角色,自动添加额外的 Scope。
- 自定义参数传递: 在授权请求中注入自定义参数,这些参数可以在后续的 Token 生成过程中使用。
- 请求日志记录: 记录授权请求的关键信息,用于审计和分析。
3.5.2 实现示例
java
// 教学示意代码 - 授权请求包装器
public class Oauth2AuthorizeRequestWrapper extends HttpServletRequestWrapper {
// 存储需要注入的额外参数
private Map<String, String> extraParams = new HashMap<>();
public Oauth2AuthorizeRequestWrapper(HttpServletRequest request) {
super(request);
}
@Override
public String getParameter(String name) {
// 优先从额外参数中查找
String value = extraParams.get(name);
if (value != null) {
return value;
}
return super.getParameter(name);
}
@Override
public Map<String, String[]> getParameterMap() {
Map<String, String[]> original = super.getParameterMap();
Map<String, String[]> merged = new HashMap<>(original);
extraParams.forEach((key, value) ->
merged.put(key, new String[]{value}));
return Collections.unmodifiableMap(merged);
}
public void addExtraParam(String name, String value) {
extraParams.put(name, value);
}
}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
java
// 教学示意代码 - 授权过滤器
public class Oauth2AuthorizeFilter extends OncePerRequestFilter {
private static final Logger LOGGER =
LoggerFactory.getLogger(Oauth2AuthorizeFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String requestURI = request.getRequestURI();
// 只拦截授权端点
if (!requestURI.contains("/oauth2.0/authorize")) {
filterChain.doFilter(request, response);
return;
}
LOGGER.info("OAuth2 authorize request intercepted: {}",
request.getParameter("client_id"));
// 创建包装器
Oauth2AuthorizeRequestWrapper wrapper =
new Oauth2AuthorizeRequestWrapper(request);
// 注入自定义参数
String clientId = request.getParameter("client_id");
wrapper.addExtraParam("_custom_timestamp",
String.valueOf(System.currentTimeMillis()));
wrapper.addExtraParam("_request_id",
UUID.randomUUID().toString());
// 继续过滤链
filterChain.doFilter(wrapper, 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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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
3.6 Oauth2AccessTokenRequestWrapper 与 Oauth2AccessTokenFilter
3.6.1 设计思路
与授权端点的 Filter 类似,Token 端点的 Filter 用于在 Access Token 签发过程中注入自定义逻辑。但 Token 端点的定制需求通常更加复杂,因为:
- Token 端点处理的是 POST 请求,参数在请求体中。
- Token 端点的响应是 JSON 格式的 Access Token 信息。
- 需要在 Token 生成后、响应返回前进行拦截。
3.6.2 实现示例
java
// 教学示意代码 - Token 请求包装器
public class Oauth2AccessTokenRequestWrapper
extends HttpServletRequestWrapper {
private byte[] cachedBody;
public Oauth2AccessTokenRequestWrapper(HttpServletRequest request)
throws IOException {
super(request);
// 缓存请求体,因为 InputStream 只能读取一次
cachedBody = StreamUtils.copyToByteArray(
request.getInputStream());
}
@Override
public ServletInputStream getInputStream() {
return new CachedBodyServletInputStream(cachedBody);
}
@Override
public BufferedReader getReader() {
return new BufferedReader(
new InputStreamReader(
new ByteArrayInputStream(cachedBody),
StandardCharsets.UTF_8));
}
/**
* 获取请求体中的参数
*/
public Map<String, String> getBodyParams() {
String body = new String(cachedBody, StandardCharsets.UTF_8);
Map<String, String> params = new HashMap<>();
if (StringUtils.hasText(body)) {
String[] pairs = body.split("&");
for (String pair : pairs) {
String[] kv = pair.split("=", 2);
if (kv.length == 2) {
params.put(
URLDecoder.decode(kv[0], StandardCharsets.UTF_8),
URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
}
}
}
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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
java
// 教学示意代码 - Token 响应包装器
public class Oauth2AccessTokenResponseWrapper
extends HttpServletResponseWrapper {
private StringWriter responseWriter = new StringWriter();
public Oauth2AccessTokenResponseWrapper(
HttpServletResponse response) {
super(response);
}
@Override
public PrintWriter getWriter() {
return new PrintWriter(responseWriter);
}
/**
* 获取原始响应内容
*/
public String getResponseBody() {
return responseWriter.toString();
}
/**
* 修改响应内容并写入原始输出流
*/
public void modifyAndCommit(String modifiedBody) throws IOException {
PrintWriter writer = super.getWriter();
writer.write(modifiedBody);
writer.flush();
}
}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
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
java
// 教学示意代码 - Token 过滤器
public class Oauth2AccessTokenFilter extends OncePerRequestFilter {
private static final Logger LOGGER =
LoggerFactory.getLogger(Oauth2AccessTokenFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String requestURI = request.getRequestURI();
// 只拦截 Token 端点
if (!requestURI.contains("/oauth2.0/accessToken")) {
filterChain.doFilter(request, response);
return;
}
// 1. 包装请求,缓存请求体
Oauth2AccessTokenRequestWrapper requestWrapper =
new Oauth2AccessTokenRequestWrapper(request);
// 2. 记录请求参数
Map<String, String> params = requestWrapper.getBodyParams();
LOGGER.info("OAuth2 token request - grant_type: {}, client_id: {}",
params.get("grant_type"), params.get("client_id"));
// 3. 包装响应
Oauth2AccessTokenResponseWrapper responseWrapper =
new Oauth2AccessTokenResponseWrapper(response);
// 4. 执行过滤链
filterChain.doFilter(requestWrapper, responseWrapper);
// 5. 获取并增强响应
String originalBody = responseWrapper.getResponseBody();
enhanceTokenResponse(originalBody, responseWrapper);
}
private void enhanceTokenResponse(String originalBody,
Oauth2AccessTokenResponseWrapper responseWrapper)
throws IOException {
try {
// 解析原始响应
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> tokenResponse =
mapper.readValue(originalBody, Map.class);
// 注入自定义字段
tokenResponse.put("issued_at", System.currentTimeMillis() / 1000);
tokenResponse.put("token_source", "cas-oauth2");
// 重新序列化并写入响应
String enhancedBody = mapper.writeValueAsString(tokenResponse);
responseWrapper.modifyAndCommit(enhancedBody);
} catch (Exception e) {
LOGGER.error("Failed to enhance token response", e);
// 如果增强失败,写入原始响应
responseWrapper.modifyAndCommit(originalBody);
}
}
}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
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
3.7 从数据库补充用户详情
在自定义用户信息返回方案中,从数据库查询用户详情是最常见的需求。以下是数据访问层的设计。
3.7.1 数据访问接口设计
java
// 教学示意代码 - 用户详情提供者接口
public interface UserDetailsProvider {
/**
* 根据用户ID查询用户详情
*/
UserDetail findByUserId(String userId);
/**
* 根据用户ID和客户端ID查询定制化的用户属性
*/
Map<String, Object> findCustomAttributes(String userId, String clientId);
}
// 教学示意代码 - 用户详情实体
public class UserDetail {
private String userId;
private String employeeId;
private String department;
private String position;
private String mobile;
private String email;
private String costCenter;
private List<String> roles;
private List<String> permissions;
// getters and setters...
}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
3.7.2 JDBC 实现
java
// 教学示意代码 - 基于JDBC的用户详情查询实现
public class JdbcUserDetailsProvider implements UserDetailsProvider {
private JdbcTemplate jdbcTemplate;
// SQL 查询语句(简化版)
private static final String FIND_BY_USER_ID =
"SELECT user_id, employee_id, department, position, " +
"mobile, email, cost_center FROM user_details WHERE user_id = ?";
private static final String FIND_ROLES =
"SELECT role_name FROM user_roles WHERE user_id = ?";
private static final String FIND_ATTRIBUTES_BY_CLIENT =
"SELECT attr_name, attr_value FROM client_user_attributes " +
"WHERE user_id = ? AND client_id = ?";
@Override
public UserDetail findByUserId(String userId) {
try {
return jdbcTemplate.queryForObject(
FIND_BY_USER_ID,
new Object[]{userId},
(rs, rowNum) -> {
UserDetail detail = new UserDetail();
detail.setUserId(rs.getString("user_id"));
detail.setEmployeeId(rs.getString("employee_id"));
detail.setDepartment(rs.getString("department"));
detail.setPosition(rs.getString("position"));
detail.setMobile(rs.getString("mobile"));
detail.setEmail(rs.getString("email"));
detail.setCostCenter(rs.getString("cost_center"));
return detail;
});
} catch (EmptyResultDataAccessException e) {
return null;
}
}
@Override
public Map<String, Object> findCustomAttributes(
String userId, String clientId) {
Map<String, Object> attributes = new HashMap<>();
jdbcTemplate.query(
FIND_ATTRIBUTES_BY_CLIENT,
new Object[]{userId, clientId},
(rs) -> {
attributes.put(
rs.getString("attr_name"),
rs.getString("attr_value"));
});
return 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
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
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
3.7.3 缓存层设计
由于数据库查询可能成为性能瓶颈,建议在数据访问层之上添加缓存:
java
// 教学示意代码 - 带缓存的用户详情提供者
public class CachedUserDetailsProvider implements UserDetailsProvider {
private UserDetailsProvider delegate;
private Cache<String, UserDetail> cache;
@Override
public UserDetail findByUserId(String userId) {
// 先查缓存
UserDetail cached = cache.getIfPresent(userId);
if (cached != null) {
return cached;
}
// 缓存未命中,查询数据库
UserDetail detail = delegate.findByUserId(userId);
if (detail != null) {
// 写入缓存,设置5分钟过期
cache.put(userId, detail);
}
return detail;
}
@Override
public Map<String, Object> findCustomAttributes(
String userId, String clientId) {
// 使用组合键作为缓存键
String cacheKey = userId + ":" + clientId;
// ... 类似的缓存逻辑
return delegate.findCustomAttributes(userId, clientId);
}
}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
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
3.8 5.3 方案的局限性分析
虽然 Filter 方案在 CAS 5.3 中能够实现自定义用户信息返回的需求,但它存在以下明显的局限性:
局限性一:与 CAS 内部机制耦合度高。 Filter 方案需要深入了解 CAS 的内部处理流程,包括 Controller 的执行顺序、请求参数的解析方式、响应的生成格式等。这些内部细节在不同 CAS 版本之间可能发生变化,导致升级时需要大量适配工作。
局限性二:调试困难。 Filter 的执行顺序和 CAS 内部组件的交互关系复杂,当出现问题时,排查链路长,定位困难。特别是在 CAS 的 Webflow 中,请求可能经过多个 Action 和 Decision State,Filter 的拦截点可能不在预期位置。
局限性三:性能开销。 Filter 方案需要对请求和响应进行包装(Wrapper),这会带来额外的内存分配和对象拷贝开销。在高并发场景下,这种开销可能变得不可忽视。
局限性四:维护成本高。 每次升级 CAS 版本时,都需要验证 Filter 方案的兼容性。如果 CAS 的内部 API 发生变化(这在 CAS 的快速迭代中很常见),可能需要修改 Filter 的实现。
局限性五:功能覆盖不全。 Filter 只能拦截 HTTP 请求和响应层面,无法深入到 CAS 的业务逻辑层面。例如,无法在 Token 生成过程中修改 Token 的 claims,无法在授权决策过程中注入自定义逻辑。
正是由于这些局限性,CAS 7.3 引入了更加优雅的条件装配方案,通过 Spring Boot 的条件注解和 CAS 的原生扩展点,实现了更加灵活、可维护的定制方式。
四、条件装配方案(7.3 方案)
4.1 从 Filter 到 Bean 的范式转变
CAS 从 6.x 开始全面拥抱 Spring Boot 的自动配置机制,到 7.3 版本时,几乎所有核心组件都支持通过 @ConditionalOnMissingBean 进行优雅替换。这种范式转变意味着:
旧范式(Filter 方案):
HTTP Request → Filter(拦截/修改)→ CAS Controller → Filter(拦截/修改)→ HTTP Response1
新范式(Bean 替换方案):
HTTP Request → CAS Controller → CAS 内部调用自定义 Bean → HTTP Response1
这种转变的核心优势在于:
- 侵入性降低: 不需要修改 HTTP 请求/响应的流转过程,而是在 CAS 内部的业务逻辑层面进行扩展。
- 生命周期管理: 自定义 Bean 由 Spring 容器管理,享受依赖注入、AOP、生命周期回调等 Spring 特性。
- 类型安全: 通过接口和泛型约束,确保自定义实现的类型正确性。
- 可测试性: 自定义 Bean 可以脱离 HTTP 环境进行单元测试。
4.2 @ConditionalOnMissingBean 优雅替换
4.2.1 工作原理
@ConditionalOnMissingBean 是 Spring Boot 提供的条件注解,它的含义是"当容器中不存在指定类型的 Bean 时,才注册当前 Bean"。CAS 利用这个注解实现了"默认实现 + 自定义替换"的模式:
java
// CAS 内部的自动配置类(简化示意)
@Configuration
public class CasOAuthAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public OAuth20UserProfileDataCreator oauthUserProfileDataCreator() {
return new DefaultOAuth20UserProfileDataCreator();
}
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
当开发者提供一个自定义的 OAuth20UserProfileDataCreator Bean 时,CAS 的默认实现就不会被注册。这样,自定义实现就会自动被 CAS 的所有内部组件使用。
4.2.2 自定义用户资料创建器(7.3 方案)
java
// 教学示意代码 - 7.3 版本的自定义用户资料创建器
@Configuration(value = "CustomOAuthUserProfileConfiguration",
proxyBeanMethods = false)
public class CustomOAuthUserProfileConfiguration {
/**
* 自定义用户资料创建器
* 使用 @ConditionalOnMissingBean 确保只在默认 Bean 不存在时注册
*/
@Bean
@ConditionalOnMissingBean(
name = "oauthUserProfileDataCreator"
)
@RefreshScope
public OAuth20UserProfileDataCreator oauthUserProfileDataCreator(
@Qualifier("attributeRepository")
AttributeRepository attributeRepository,
@Qualifier("personAttributeDao")
IPersonAttributeDao personAttributeDao) {
return new CustomUserProfileDataCreator(
attributeRepository, personAttributeDao);
}
}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
关键注解说明:
@Configuration(proxyBeanMethods = false):使用轻量级配置模式,Spring 不会为配置类创建 CGLIB 代理。这在 CAS 的自动配置中是推荐的做法,可以减少启动时的开销。@ConditionalOnMissingBean(name = "..."):通过 Bean 名称进行条件判断。CAS 的默认 Bean 通常有固定的名称,确保自定义 Bean 的名称与默认名称一致即可实现替换。@RefreshScope:支持 Spring Cloud Config 的配置热刷新。当配置中心的配置发生变化时,带有@RefreshScope的 Bean 会被重新创建。
4.2.3 自定义 Token 生成器
除了用户资料创建器,CAS 7.3 还支持自定义 Token 生成器:
java
// 教学示意代码 - 自定义访问令牌生成器
@Configuration(value = "CustomOAuthTokenConfiguration",
proxyBeanMethods = false)
public class CustomOAuthTokenConfiguration {
@Bean
@ConditionalOnMissingBean(
name = "oauth20AccessTokenGenerator"
)
@RefreshScope
public OAuth20TokenGenerator oauth20AccessTokenGenerator(
@Qualifier("ticketRegistry")
TicketRegistry ticketRegistry,
@Qualifier("oauth20CipherExecutor")
CipherExecutor cipherExecutor) {
return new CustomAccessTokenGenerator(
ticketRegistry, cipherExecutor);
}
}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
4.3 @RefreshScope 配置热刷新
4.3.1 热刷新的价值
在生产环境中,某些配置变更(如新增客户端、修改 Token 有效期、调整用户属性映射等)通常需要重启 CAS 服务才能生效。对于 7x24 小时运行的企业级认证平台,重启意味着服务中断,影响所有正在使用 CAS 的应用。
@RefreshScope 注解配合 Spring Cloud Config,可以在不重启 CAS 的情况下,使配置变更生效。当配置中心的配置发生变化时,通过发送 /actuator/refresh 请求,Spring 会销毁并重新创建带有 @RefreshScope 的 Bean。
4.3.2 配置热刷新的使用
java
// 教学示意代码 - 支持热刷新的配置类
@Configuration
@RefreshScope
public class DynamicOAuthProperties {
@Value("${cas.oauth.access-token.time-to-kill-in-seconds:7200}")
private int accessTokenTimeToKill;
@Value("${cas.oauth.refresh-token.time-to-kill-in-seconds:1209600}")
private int refreshTokenTimeToKill;
@Value("${custom.oauth.attribute-mappings:}")
private Map<String, String> attributeMappings;
// getters...
}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
java
// 教学示意代码 - 在自定义组件中使用动态配置
public class CustomUserProfileDataCreator implements OAuth20UserProfileDataCreator {
private DynamicOAuthProperties properties;
@Override
public Map<String, Object> create(Authentication authentication,
RegisteredService service,
HttpContext context) {
Map<String, Object> profile = new LinkedHashMap<>();
// 使用动态配置中的属性映射
Map<String, String> mappings = properties.getAttributeMappings();
Map<String, Object> attributes = authentication.getPrincipal().getAttributes();
mappings.forEach((sourceAttr, targetAttr) -> {
if (attributes.containsKey(sourceAttr)) {
profile.put(targetAttr, attributes.get(sourceAttr));
}
});
return profile;
}
}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
4.3.3 触发配置刷新
bash
# 通过 HTTP 端点触发配置刷新
curl -X POST http://cas.example.org/cas/actuator/refresh1
2
2
注意事项:
@RefreshScope只对通过@Value注入的配置有效,对@ConfigurationProperties的支持需要额外配置。- 刷新操作会销毁并重新创建 Bean,在此过程中的请求可能会遇到短暂的服务不可用。
- 并非所有 Bean 都适合添加
@RefreshScope。对于有状态的 Bean(如缓存管理器),刷新可能导致状态丢失。
4.4 CAS 原生扩展点详解
CAS 7.3 提供了丰富的原生扩展点,开发者可以在不使用 Filter 的情况下实现大部分定制需求。以下是常用的扩展点:
4.4.1 OAuth20UserProfileDataCreator
功能: 构建返回给客户端的用户资料。
使用场景:
- 添加自定义用户属性
- 根据客户端类型返回不同的属性集
- 属性值格式转换
- 属性脱敏处理
配置键: cas.oauth.user-profile-view-type
4.4.2 OAuth20TokenGenerator
功能: 生成 OAuth 2.0 Token(Access Token / Refresh Token)。
使用场景:
- 自定义 Token 格式
- 在 Token 中注入自定义 Claims
- Token 生命周期管理策略定制
4.4.3 OAuth20AuthorizationCodeGenerator
功能: 生成授权码。
使用场景:
- 自定义授权码格式(如添加前缀便于日志追踪)
- 授权码中嵌入自定义元数据
4.4.4 OAuth20ResponseFactory
功能: 构建 OAuth 2.0 端点的 HTTP 响应。
使用场景:
- 自定义响应格式
- 添加自定义响应头
- 统一错误响应格式
4.4.5 OAuth20ClientRegistrationService
功能: 管理 OAuth 2.0 客户端注册信息。
使用场景:
- 从外部数据源加载客户端信息
- 动态客户端注册
- 客户端信息缓存
4.4.6 扩展点使用示例
以下是一个综合使用多个扩展点的配置类示例:
java
// 教学示意代码 - 综合使用多个扩展点
@Configuration(value = "CustomOAuthExtensionsConfiguration",
proxyBeanMethods = false)
public class CustomOAuthExtensionsConfiguration {
/**
* 自定义用户资料创建器
*/
@Bean
@ConditionalOnMissingBean(
name = "oauthUserProfileDataCreator"
)
@RefreshScope
public OAuth20UserProfileDataCreator oauthUserProfileDataCreator(
@Qualifier("attributeRepository")
AttributeRepository attributeRepository) {
return new CustomUserProfileDataCreator(attributeRepository);
}
/**
* 自定义 Token 生成器
*/
@Bean
@ConditionalOnMissingBean(
name = "oauth20AccessTokenGenerator"
)
@RefreshScope
public OAuth20TokenGenerator oauth20AccessTokenGenerator(
@Qualifier("ticketRegistry")
TicketRegistry ticketRegistry,
@Qualifier("oauth20CipherExecutor")
CipherExecutor cipherExecutor) {
return new CustomAccessTokenGenerator(
ticketRegistry, cipherExecutor);
}
/**
* 自定义授权码生成器
*/
@Bean
@ConditionalOnMissingBean(
name = "oauth20AuthorizationCodeGenerator"
)
public OAuth20AuthorizationCodeGenerator oauth20AuthorizationCodeGenerator(
@Qualifier("oauth20CipherExecutor")
CipherExecutor cipherExecutor) {
return new CustomAuthorizationCodeGenerator(cipherExecutor);
}
/**
* 自定义响应工厂
*/
@Bean
@ConditionalOnMissingBean(
name = "oauth20ResponseFactory"
)
public OAuth20ResponseFactory oauth20ResponseFactory() {
return new CustomOAuth20ResponseFactory();
}
}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
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
4.5 UMA(User Managed Access)支持
CAS 7.3 引入了对 UMA 2.0(User Managed Access)规范的支持。UMA 是 OAuth 2.0 的一个扩展,允许资源所有者(用户)精细地控制谁可以访问其资源,以及访问的条件。
4.5.1 UMA 核心概念
UMA 与标准 OAuth 2.0 的区别:
| 维度 | OAuth 2.0 | UMA 2.0 |
|---|---|---|
| 授权主体 | 资源所有者(用户) | 资源所有者 + 授权服务器 |
| 权限粒度 | 粗粒度(Scope) | 细粒度(Permission Ticket) |
| 资源描述 | 无 | 有(Resource Registration) |
| 策略管理 | 无 | 有(Policy-based Access Control) |
| 适用场景 | API 访问授权 | 数据共享、隐私控制 |
UMA 2.0 核心流程:
1. 资源注册:资源服务器在授权服务器注册受保护的资源
2. 权限请求:客户端请求访问某个资源
3. 权限票据:授权服务器返回权限票据(Permission Ticket)
4. Token 请求:客户端使用权限票据请求 RPT(Requesting Party Token)
5. 授权决策:授权服务器根据策略引擎做出授权决策
6. 资源访问:客户端使用 RPT 访问受保护资源1
2
3
4
5
6
2
3
4
5
6
4.5.2 UMA 配置
yaml
cas:
uma:
# 启用 UMA 支持
enabled: true
# UMA 策略引擎配置
policy:
# 策略存储方式
# 可选值:DEFAULT, GROOVY, REST
type: DEFAULT
# UMA 资源注册配置
resource-registration:
# 是否允许动态资源注册
enabled: true
# UMA 权限票据配置
permission-ticket:
# 权限票据有效期(秒)
time-to-kill-in-seconds: 3001
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
4.5.3 UMA 与 OAuth 2.0 的协同
UMA 构建在 OAuth 2.0 之上,两者可以共存于同一个 CAS 实例中。标准 OAuth 2.0 客户端继续使用原有的授权码流程,而需要细粒度权限控制的客户端则使用 UMA 流程。
yaml
cas:
oauth:
# 标准 OAuth 2.0 配置保持不变
access-token:
time-to-kill-in-seconds: 7200
uma:
# UMA 配置
enabled: true
# UMA 可以复用 OAuth 2.0 的 Token
rpt:
time-to-kill-in-seconds: 36001
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
4.6 7.3 方案最佳实践
4.6.1 配置类组织
建议将自定义配置类按照功能模块进行组织:
src/main/java/com/example/cas/config/
├── CustomOAuthUserProfileConfiguration.java # 用户资料相关
├── CustomOAuthTokenConfiguration.java # Token 相关
├── CustomOAuthSecurityConfiguration.java # 安全相关
├── CustomOAuthUmaConfiguration.java # UMA 相关
└── CustomOAuthWebConfiguration.java # Web 端点相关1
2
3
4
5
6
2
3
4
5
6
4.6.2 Bean 命名规范
为了确保 @ConditionalOnMissingBean 能够正确工作,自定义 Bean 的名称必须与 CAS 默认 Bean 的名称一致。以下是 CAS 7.3 中常用的 Bean 名称:
| Bean 名称 | 类型 | 功能 |
|---|---|---|
oauthUserProfileDataCreator | OAuth20UserProfileDataCreator | 用户资料创建 |
oauth20AccessTokenGenerator | OAuth20TokenGenerator | Token 生成 |
oauth20AuthorizationCodeGenerator | OAuth20AuthorizationCodeGenerator | 授权码生成 |
oauth20ResponseFactory | OAuth20ResponseFactory | 响应构建 |
oauth20ClientRegistrationService | OAuth20ClientRegistrationService | 客户端注册 |
4.6.3 日志与监控
在自定义组件中添加适当的日志和监控:
java
// 教学示意代码 - 带日志和监控的用户资料创建器
public class CustomUserProfileDataCreator
implements OAuth20UserProfileDataCreator {
private static final Logger LOGGER =
LoggerFactory.getLogger(CustomUserProfileDataCreator.class);
private MeterRegistry meterRegistry;
@Override
public Map<String, Object> create(Authentication authentication,
RegisteredService service,
HttpContext context) {
long startTime = System.nanoTime();
String clientId = extractClientId(service);
String userId = authentication.getPrincipal().getId();
try {
LOGGER.debug("Creating user profile for user: {}, client: {}",
userId, clientId);
Map<String, Object> profile = doCreate(
authentication, service, context);
LOGGER.info("User profile created successfully for user: {}, " +
"client: {}, attributes: {}",
userId, clientId, profile.size());
// 记录监控指标
meterRegistry.counter("oauth.user_profile.created",
"client_id", clientId).increment();
return profile;
} catch (Exception e) {
LOGGER.error("Failed to create user profile for user: {}, " +
"client: {}", userId, clientId, e);
meterRegistry.counter("oauth.user_profile.failed",
"client_id", clientId).increment();
throw e;
} finally {
long duration = System.nanoTime() - startTime;
meterRegistry.timer("oauth.user_profile.duration",
"client_id", clientId)
.record(duration, TimeUnit.NANOSECONDS);
}
}
}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
五、Token 生命周期管理
5.1 授权码的生成与消费
授权码是 OAuth 2.0 授权码模式中的第一步凭据,它连接了用户的前端授权交互和客户端的后端 Token 获取。理解授权码的生成和消费过程,对于排查授权流程中的问题至关重要。
5.1.1 授权码生成流程
用户点击"授权"按钮
│
▼
CAS Webflow: OAuth20AuthorizeAction
│
├─ 1. 验证客户端(client_id 是否已注册)
├─ 2. 验证重定向 URI(redirect_uri 是否匹配)
├─ 3. 验证 Scope(是否在允许范围内)
├─ 4. 验证用户会话(用户是否已登录)
├─ 5. 生成授权码
│ ├─ 使用 OAuth20AuthorizationCodeGenerator 生成随机码
│ ├─ 使用 CipherExecutor 加密授权码
│ └─ 将授权码存储到 TicketRegistry
└─ 6. 重定向到客户端回调地址
└─ URL 参数: ?code=ENCRYPTED_AUTH_CODE&state=xxx1
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
5.1.2 授权码消费流程
客户端后端发送 POST /oauth2.0/accessToken
│
▼
CAS OAuth20AccessTokenController
│
├─ 1. 验证客户端认证(client_id + client_secret)
├─ 2. 从请求中提取授权码
├─ 3. 从 TicketRegistry 中查找授权码
├─ 4. 验证授权码有效性
│ ├─ 是否存在
│ ├─ 是否过期(timeToKillInSeconds)
│ ├─ 使用次数是否超限(numberOfUses)
│ └─ redirect_uri 是否匹配
├─ 5. 消费授权码(从 TicketRegistry 中删除)
├─ 6. 生成 Access Token
├─ 7. 生成 Refresh Token(如果请求的 scope 包含 offline_access)
└─ 8. 返回 Token 响应1
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
5.1.3 授权码存储结构
授权码在 CAS 中以 OAuth20AuthorizationCode 对象的形式存储在 TicketRegistry 中。其核心属性包括:
java
// 教学示意代码 - 授权码核心属性
public class OAuth20AuthorizationCode {
private String id; // 授权码唯一标识
private String code; // 加密后的授权码字符串
private Authentication authentication; // 关联的认证信息
private RegisteredService service; // 关联的 OAuth 服务
private String redirectUri; // 重定向 URI
private Set<String> scopes; // 授权的 Scope 集合
private Date creationTime; // 创建时间
private Date expirationTime; // 过期时间
private int countOfUses; // 已使用次数
private int numberOfUses; // 最大使用次数
}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
5.2 访问令牌的签发与验证
5.2.1 Access Token 签发流程
java
// 教学示意代码 - Access Token 签发核心逻辑
public class SimplifiedAccessTokenGenerator {
private TicketRegistry ticketRegistry;
private CipherExecutor cipherExecutor;
private ExpirationPolicy expirationPolicy;
public OAuth20AccessToken generate(
OAuth20AuthorizationCode authCode,
Set<String> scopes) {
// 1. 生成 Token ID
String tokenId = generateTokenId();
// 2. 构建 Token 对象
OAuth20AccessToken token = new OAuth20AccessTokenImpl(tokenId);
token.setAuthentication(authCode.getAuthentication());
token.setService(authCode.getService());
token.setScopes(scopes);
token.setExpirationPolicy(expirationPolicy);
// 3. 加密 Token
String encryptedToken = cipherExecutor.encode(tokenId);
// 4. 存储 Token
ticketRegistry.addTicket(token);
return token;
}
private String generateTokenId() {
byte[] bytes = new byte[48]; // 384位随机数
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(bytes);
}
}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
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
5.2.2 Access Token 验证流程
Access Token 的验证有两种方式,取决于 Token 的格式:
不透明令牌验证(通过自省端点):
资源服务器 → POST /oauth2.0/introspect
Content-Type: application/x-www-form-urlencoded
token=ACCESS_TOKEN
CAS → 查找 TicketRegistry
├─ Token 是否存在
├─ Token 是否过期
├─ Token 是否被撤销
└─ 返回 Token 信息1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
JWT 令牌验证(本地验证):
资源服务器 → 本地验证
├─ 1. 验证签名(使用 CAS 的公钥)
├─ 2. 验证过期时间(exp claim)
├─ 3. 验证签发者(iss claim)
├─ 4. 验证受众(aud claim)
└─ 5. 提取用户信息(sub claim + attributes)1
2
3
4
5
6
2
3
4
5
6
5.3 刷新令牌的轮转策略
5.3.1 令牌轮转流程
客户端 → POST /oauth2.0/accessToken
grant_type=refresh_token
&refresh_token=OLD_REFRESH_TOKEN
&client_id=xxx
&client_secret=xxx
CAS → 验证旧刷新令牌
├─ 1. 查找 TicketRegistry
├─ 2. 验证有效性
├─ 3. 检查是否已被使用(令牌轮转场景)
│
├─ [令牌轮转]
│ ├─ 4. 使旧刷新令牌失效
│ ├─ 5. 生成新的 Access Token
│ ├─ 6. 生成新的 Refresh Token
│ └─ 7. 返回新的 Token 对
│
└─ [非令牌轮转]
├─ 4. 生成新的 Access Token
└─ 5. 返回新 Access Token(刷新令牌不变)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
5.3.2 令牌轮转的安全检测
当启用令牌轮转时,CAS 可以检测到刷新令牌的重放攻击:
java
// 教学示意代码 - 刷新令牌重放检测
public class RefreshTokenReuseDetector {
/**
* 检测刷新令牌是否被重放
* 如果一个已被消费的刷新令牌被再次使用,
* 说明可能发生了令牌泄露
*/
public boolean detectReuse(String refreshTokenId) {
// 1. 查找刷新令牌
OAuth20RefreshToken token = ticketRegistry.getTicket(
refreshTokenId, OAuth20RefreshToken.class);
if (token == null) {
// 令牌不存在,可能已被撤销
return true;
}
if (token.isExpired()) {
// 令牌已过期
return false;
}
if (token.getCountOfUses() > 0 && token.isOneTimeUse()) {
// 令牌已被使用过,且设置为一次性使用
// 这是重放攻击的强烈信号
revokeAllTokensForUser(token.getAuthentication());
return true;
}
return false;
}
/**
* 撤销用户的所有令牌
* 作为安全措施,当检测到重放攻击时执行
*/
private void revokeAllTokensForUser(Authentication authentication) {
String userId = authentication.getPrincipal().getId();
// 撤销该用户的所有 Access Token 和 Refresh 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
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
5.4 Token 存储与 Redis 集成
5.4.1 存储架构选择
CAS 默认使用内存存储 Token(DefaultTicketRegistry),这在单节点部署时没有问题。但在集群部署时,需要使用分布式存储来共享 Token 状态。CAS 支持以下分布式存储后端:
| 存储后端 | 适用场景 | 性能 | 可靠性 | 运维复杂度 |
|---|---|---|---|---|
| Redis | 中大型集群 | 高 | 高(支持持久化) | 中 |
| Hazelcast | 中型集群 | 高 | 中 | 低 |
| Memcached | 大型集群 | 很高 | 中 | 中 |
| JDBC | 小型集群 | 低 | 高 | 低 |
| Cassandra | 超大型集群 | 高 | 很高 | 高 |
5.4.2 Redis 集成配置
yaml
cas:
ticket:
registry:
# 使用 Redis 作为 Ticket 注册表
redis:
enabled: true
host: redis-cluster.example.org
port: 6379
password: "${REDIS_PASSWORD}"
database: 0
# 连接池配置
pool:
max-active: 20
max-idle: 10
min-idle: 5
# 超时配置
timeout: 5000
# SSL 配置
ssl: true
# Sentinel 配置(高可用)
sentinel:
master: cas-master
nodes:
- sentinel1.example.org:26379
- sentinel2.example.org:26379
- sentinel3.example.org:263791
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
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
5.4.3 Redis 中的 Token 存储结构
CAS 在 Redis 中使用 Hash 结构存储 Token,Key 的格式为:
CAS_TICKET:{tokenType}:{tokenId}1
例如:
CAS_TICKET:OAUTH20_ACCESS_TOKEN:abc123def456...
CAS_TICKET:OAUTH20_REFRESH_TOKEN:xyz789ghi012...
CAS_TICKET:OAUTH20_AUTHORIZATION_CODE:mno345pqr678...1
2
3
2
3
Hash 中的字段包括 Token 的序列化数据、创建时间、过期时间等。
5.4.4 Token 过期与 Redis TTL
CAS 会根据 Token 的 timeToKillInSeconds 配置,为 Redis 中的 Token Key 设置对应的 TTL(Time To Live)。当 Token 过期时,Redis 会自动删除对应的 Key,无需手动清理。
Access Token TTL: 7200 秒(2小时)
Refresh Token TTL: 1209600 秒(14天)
Authorization Code TTL: 300 秒(5分钟)1
2
3
2
3
5.5 Token 加密与签名机制
5.5.1 加密与签名的区别
在 CAS OAuth 2.0 中,"加密"和"签名"是两个不同的安全操作:
- 加密(Encryption): 使用对称加密算法(如 AES)对 Token 进行加密,确保只有持有密钥的 CAS 实例才能解密和读取 Token 内容。加密解决的是机密性问题。
- 签名(Signing): 使用 HMAC 算法对 Token 进行签名,确保 Token 在传输过程中不被篡改。任何对 Token 内容的修改都会导致签名验证失败。签名解决的是完整性问题。
CAS 的 CipherExecutor 接口封装了加密和签名的组合操作:
java
// 教学示意代码 - CipherExecutor 的核心逻辑
public class SimplifiedCipherExecutor implements CipherExecutor<String, String> {
private SecretKey encryptionKey;
private SecretKey signingKey;
@Override
public String encode(String value) {
// 1. 先签名
byte[] signature = sign(value.getBytes(StandardCharsets.UTF_8));
// 2. 拼接原文和签名
byte[] combined = concat(value.getBytes(StandardCharsets.UTF_8), signature);
// 3. 加密
byte[] encrypted = encrypt(combined);
// 4. Base64 编码
return Base64.getUrlEncoder().withoutPadding().encodeToString(encrypted);
}
@Override
public String decode(String encoded) {
// 1. Base64 解码
byte[] encrypted = Base64.getUrlDecoder().decode(encoded);
// 2. 解密
byte[] combined = decrypt(encrypted);
// 3. 分离原文和签名
int sigLength = getSignatureLength();
byte[] valueBytes = Arrays.copyOfRange(combined, 0, combined.length - sigLength);
byte[] signature = Arrays.copyOfRange(combined, combined.length - sigLength, combined.length);
// 4. 验证签名
if (!verifySignature(valueBytes, signature)) {
throw new SecurityException("Token signature verification failed");
}
return new String(valueBytes, StandardCharsets.UTF_8);
}
}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
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
5.5.2 密钥轮转策略
在生产环境中,定期轮转加密和签名密钥是必要的安全措施。CAS 支持密钥轮转,但需要注意以下事项:
- 轮转期间兼容性: 轮转密钥后,使用旧密钥签发的 Token 可能无法被验证。建议在轮转期间同时支持新旧密钥。
- Token 生命周期: 如果 Access Token 的有效期较短(如 2 小时),可以在 Access Token 有效期内完成密钥轮转。但如果 Refresh Token 的有效期较长(如 14 天),则需要更长的过渡期。
- 集群一致性: 在集群环境中,所有节点必须同时更新密钥配置。
5.6 Token 撤销与主动失效
5.6.1 Token 撤销端点
CAS OAuth 2.0 提供了标准的 Token 撤销端点(RFC 7009):
bash
POST /oauth2.0/revoke
Content-Type: application/x-www-form-urlencoded
token=ACCESS_TOKEN_OR_REFRESH_TOKEN
&token_type_hint=access_token1
2
3
4
5
2
3
4
5
5.6.2 批量撤销
在某些场景下,需要批量撤销某个用户或某个客户端的所有 Token。CAS 提供了相应的管理接口:
java
// 教学示意代码 - Token 批量撤销
public class TokenRevocationService {
private TicketRegistry ticketRegistry;
/**
* 撤销用户的所有 Token
*/
public int revokeAllTokensForUser(String userId) {
int count = 0;
// 遍历 TicketRegistry,查找并删除该用户的所有 Token
// 注意:实际实现需要考虑性能,避免全表扫描
Collection<Ticket> tickets = ticketRegistry.getTickets();
for (Ticket ticket : tickets) {
if (ticket instanceof OAuth20AccessToken
|| ticket instanceof OAuth20RefreshToken) {
Authentication auth = ((OAuth20Token) ticket).getAuthentication();
if (userId.equals(auth.getPrincipal().getId())) {
ticketRegistry.deleteTicket(ticket.getId());
count++;
}
}
}
return count;
}
/**
* 撤销客户端的所有 Token
*/
public int revokeAllTokensForClient(String clientId) {
// 类似实现...
}
}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
5.6.3 Token 撤销事件通知
在集群环境中,当一个节点撤销了 Token 后,需要通知其他节点。CAS 通过 TicketRegistry 的集群感知机制实现这一点。当使用 Redis 作为 TicketRegistry 时,Token 的删除操作会自动同步到所有节点。
5.7 高可用部署下的 Token 管理策略
在企业级生产环境中,CAS 通常以集群方式部署,这给 Token 管理带来了额外的复杂性。本节讨论在高可用部署场景下的 Token 管理策略。
5.7.1 集群部署架构
典型的 CAS 集群部署架构如下:
┌──────────────┐
│ 负载均衡器 │
│ (Nginx/LB) │
└──────┬───────┘
│
┌────────────┼────────────┐
│ │ │
┌─────────▼──┐ ┌─────▼─────┐ ┌──▼─────────┐
│ CAS 节点1 │ │ CAS 节点2 │ │ CAS 节点3 │
│ │ │ │ │ │
└─────┬──────┘ └─────┬─────┘ └─────┬──────┘
│ │ │
└───────────────┼───────────────┘
│
┌──────▼──────┐
│ Redis │
│ (Ticket │
│ Registry) │
└─────────────┘1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在这种架构下,Token 的管理需要解决以下问题:
- 状态一致性: 所有 CAS 节点必须能够访问和修改同一份 Token 状态。
- 会话亲和性: 用户在授权流程中的多次请求可能被分配到不同的节点。
- 故障转移: 当某个节点故障时,其他节点必须能够继续处理该节点的 Token 请求。
5.7.2 Token 存储选型分析
在高可用部署中,Token 存储的选型直接影响系统的性能和可靠性。以下是几种常见方案的对比分析:
方案一:Redis 集群
Redis 集群是 CAS 官方推荐的高可用 Token 存储方案。它提供了以下优势:
- 高性能: Redis 的内存存储特性使得 Token 的读写延迟在毫秒级。
- 数据持久化: 通过 RDB 或 AOF 持久化,Redis 可以在重启后恢复 Token 数据。
- 自动故障转移: Redis Sentinel 或 Redis Cluster 提供了自动故障转移能力。
- 丰富的数据结构: Redis 的 Hash、Set 等数据结构非常适合存储 Token 及其关联数据。
方案二:Hazelcast
Hazelcast 是一个内存数据网格,CAS 内置了对 Hazelcast 的支持。它的优势在于:
- 嵌入模式: Hazelcast 可以嵌入到 CAS 应用中,无需独立部署。
- 零配置集群发现: 通过组播或 TCP 发现机制,自动组建集群。
- 分布式数据结构: 提供分布式 Map、Queue、Topic 等数据结构。
但 Hazelcast 的缺点是数据持久化能力较弱,不适合需要长期保存 Token 的场景。
方案三:数据库(JDBC)
对于 Token 数量较少、对性能要求不高的场景,可以使用关系型数据库存储 Token。CAS 支持通过 JDBC 连接 MySQL、PostgreSQL、Oracle 等数据库。
数据库方案的优势是运维简单、数据可靠性高,但性能远不如 Redis 和 Hazelcast,不适合高并发场景。
5.7.3 Token 过期清理策略
无论使用哪种存储后端,Token 的过期清理都是必要的运维工作。以下是不同存储后端的清理策略:
| 存储后端 | 过期清理机制 | 运维操作 |
|---|---|---|
| Redis | Redis TTL 自动过期 | 无需手动清理 |
| Hazelcast | 定时任务扫描清理 | 需配置清理间隔 |
| JDBC | 定时任务扫描清理 | 需配置清理间隔 + 数据库表维护 |
| 内存 | 引用计数 + GC | 无需手动清理(仅限单节点) |
对于 Redis 方案,建议配置合理的 TTL 值,避免大量过期 Key 同时被删除导致 Redis 性能抖动。可以通过以下配置优化:
yaml
cas:
ticket:
registry:
redis:
# Token 过期清理策略
expiration-policy:
# 使用懒过期策略,避免主动扫描
lazy-expiration: true1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
5.8 Token 性能优化实践
在高并发场景下,Token 的生成、存储和验证可能成为性能瓶颈。以下是几个经过实战验证的性能优化策略。
5.8.1 Token 生成优化
Token 生成过程中的主要性能开销来自随机数生成和加密操作。优化建议:
- 使用 SecureRandom 实例池:
SecureRandom在 Linux 上默认使用/dev/random,在高并发下可能阻塞。建议使用SecureRandom.getInstanceStrong()并维护实例池。 - 预生成 Token 池: 在系统启动时预生成一批 Token,运行时直接从池中获取,避免实时生成的开销。
- 选择合适的加密算法: AES-GCM 比 AES-CBC 性能更好,且提供了认证加密能力。
5.8.2 Token 验证优化
Token 验证是每个 API 请求都需要执行的操作,其性能直接影响系统的吞吐量。优化建议:
- 使用 JWT 格式的 Access Token: JWT 可以在资源服务器本地验证,避免每次验证都调用 CAS 的自省端点。
- 本地缓存自省结果: 对于不透明令牌,可以在资源服务器上缓存自省结果,设置较短的缓存过期时间(如 30 秒)。
- 批量自省: 如果资源服务器需要同时验证多个 Token,可以使用批量自省接口减少网络往返。
5.8.3 Redis 连接优化
当使用 Redis 作为 Token 存储时,连接池的配置对性能有显著影响:
yaml
cas:
ticket:
registry:
redis:
# 连接池配置
pool:
max-active: 50 # 最大连接数
max-idle: 25 # 最大空闲连接数
min-idle: 10 # 最小空闲连接数
max-wait: 3000 # 获取连接最大等待时间(毫秒)
test-on-borrow: true # 借出连接时测试有效性
test-while-idle: true # 空闲时测试有效性
# 超时配置
connect-timeout: 5000 # 连接超时(毫秒)
read-timeout: 3000 # 读取超时(毫秒)
# 命令超时
command-timeout: 3000 # 命令执行超时(毫秒)1
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
六、OAuth 2.0 客户端集成
6.1 第三方应用对接概述
第三方应用对接 CAS OAuth 2.0 的前提条件:
- 客户端注册: 在 CAS 的服务管理中注册 OAuth 2.0 客户端,获取
client_id和client_secret。 - 回调地址配置: 在客户端注册时配置
redirect_uri,CAS 会严格验证回调地址。 - Scope 配置: 根据业务需求配置允许的 Scope 列表。
6.1.1 客户端注册配置
在 CAS 中,OAuth 2.0 客户端通过 JSON 格式的服务定义文件进行注册:
json
{
"@class": "org.apereo.cas.services.OAuth20RegisteredService",
"serviceId": "^https://app\\.example\\.org/callback/?.*",
"name": "Example Application",
"id": 10001,
"description": "示例应用 - OAuth 2.0 客户端",
"clientId": "example-app-client-id",
"clientSecret": "{noop}example-app-client-secret",
"supportedGrantTypes": [
"java.util.HashSet",
["authorization_code", "refresh_token"]
],
"supportedResponseTypes": [
"java.util.HashSet",
["code"]
],
"scopes": [
"java.util.HashSet",
["openid", "profile", "email"]
],
"bypassApprovalPrompt": true,
"attributeReleasePolicy": {
"@class": "org.apereo.cas.services.ReturnAllAttributeReleasePolicy"
}
}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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
关键配置项说明:
serviceId:正则表达式,用于匹配redirect_uri。只有匹配此正则的回调地址才会被 CAS 接受。clientId/clientSecret:客户端凭据。clientSecret支持多种编码方式:{noop}明文、{bcrypt}BCrypt 加密、{hex}十六进制编码。supportedGrantTypes:允许的授权类型列表。supportedResponseTypes:允许的响应类型列表。scopes:允许的 Scope 列表。bypassApprovalPrompt:是否跳过授权确认页面。对于内部可信应用,可以设置为true。attributeReleasePolicy:属性释放策略,控制哪些用户属性可以返回给客户端。
6.2 授权码模式完整流程
以下以一个 Java Spring Boot 应用为例,展示完整的授权码模式集成流程。
6.2.1 第一步:引导用户到 CAS 授权端点
java
// 教学示意代码 - 构建授权 URL
public String buildAuthorizationUrl(String state) {
return UriComponentsBuilder
.fromHttpUrl("https://cas.example.org/cas/oauth2.0/authorize")
.queryParam("response_type", "code")
.queryParam("client_id", "example-app-client-id")
.queryParam("redirect_uri", "https://app.example.org/callback")
.queryParam("scope", "openid profile email")
.queryParam("state", state) // 防 CSRF
.toUriString();
}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
参数说明:
| 参数 | 必填 | 说明 |
|---|---|---|
response_type | 是 | 固定为 code |
client_id | 是 | 客户端标识 |
redirect_uri | 是 | 回调地址,必须与注册时一致 |
scope | 否 | 请求的权限范围,空格分隔 |
state | 推荐 | 随机字符串,用于防止 CSRF 攻击 |
6.2.2 第二步:处理授权回调
java
// 教学示意代码 - 处理授权回调
@RestController
@RequestMapping("/callback")
public class OAuthCallbackController {
@GetMapping
public ResponseEntity<?> handleCallback(
@RequestParam("code") String code,
@RequestParam("state") String state,
HttpSession session) {
// 1. 验证 state 参数(防 CSRF)
String savedState = (String) session.getAttribute("oauth_state");
if (!state.equals(savedState)) {
return ResponseEntity.status(400)
.body("Invalid state parameter");
}
// 2. 使用授权码换取 Token
OAuthTokenResponse tokenResponse = exchangeCodeForToken(code);
// 3. 使用 Access Token 获取用户信息
Map<String, Object> userProfile = fetchUserProfile(
tokenResponse.getAccessToken());
// 4. 创建本地会话
session.setAttribute("user", userProfile);
return ResponseEntity.ok(userProfile);
}
}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.2.3 第三步:用授权码换取 Token
java
// 教学示意代码 - 授权码换取 Token
public OAuthTokenResponse exchangeCodeForToken(String code) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 使用 HTTP Basic Auth 传递 client_id 和 client_secret
headers.setBasicAuth(
"example-app-client-id",
"example-app-client-secret");
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("code", code);
params.add("redirect_uri", "https://app.example.org/callback");
HttpEntity<MultiValueMap<String, String>> request =
new HttpEntity<>(params, headers);
ResponseEntity<OAuthTokenResponse> response = restTemplate.postForEntity(
"https://cas.example.org/cas/oauth2.0/accessToken",
request,
OAuthTokenResponse.class);
return response.getBody();
}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
CAS 返回的 Token 响应格式:
json
{
"access_token": "AT-1-xxxxxxxxxxxxxxxx",
"token_type": "bearer",
"expires_in": 7200,
"refresh_token": "RT-1-yyyyyyyyyyyyyyyy",
"scope": "openid profile email"
}1
2
3
4
5
6
7
2
3
4
5
6
7
6.2.4 第四步:获取用户信息
java
// 教学示意代码 - 获取用户信息
public Map<String, Object> fetchUserProfile(String accessToken) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<Void> request = new HttpEntity<>(headers);
ResponseEntity<Map> response = restTemplate.exchange(
"https://cas.example.org/cas/oauth2.0/profile",
HttpMethod.GET,
request,
Map.class);
return response.getBody();
}1
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
CAS 返回的用户信息格式(FLAT 模式):
json
{
"id": "admin",
"attributes": {
"email": "admin@example.org",
"displayName": "System Administrator",
"department": "Engineering",
"phone": "13800138000"
}
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
6.3 Token 刷新机制
6.3.1 刷新 Token 的使用
当 Access Token 过期后,客户端可以使用 Refresh Token 获取新的 Access Token:
java
// 教学示意代码 - Token 刷新
public OAuthTokenResponse refreshAccessToken(String refreshToken) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setBasicAuth(
"example-app-client-id",
"example-app-client-secret");
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "refresh_token");
params.add("refresh_token", refreshToken);
HttpEntity<MultiValueMap<String, String>> request =
new HttpEntity<>(params, headers);
ResponseEntity<OAuthTokenResponse> response = restTemplate.postForEntity(
"https://cas.example.org/cas/oauth2.0/accessToken",
request,
OAuthTokenResponse.class);
return response.getBody();
}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.2 Token 刷新策略
在实际应用中,Token 刷新策略的设计需要考虑以下因素:
策略一:被动刷新。 当 API 调用返回 401 错误时,自动使用 Refresh Token 获取新的 Access Token,然后重试失败的请求。
java
// 教学示意代码 - 被动刷新拦截器
public class TokenRefreshInterceptor implements ClientHttpRequestInterceptor {
private String refreshToken;
private String accessToken;
private OAuthTokenService tokenService;
@Override
public ClientHttpResponse intercept(HttpRequest request,
byte[] body,
ClientHttpRequestExecution execution)
throws IOException {
// 设置当前 Access Token
request.getHeaders().setBearerAuth(accessToken);
// 执行请求
ClientHttpResponse response = execution.execute(request, body);
// 如果返回 401,尝试刷新 Token
if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) {
response.close();
// 刷新 Token
OAuthTokenResponse newTokens =
tokenService.refreshAccessToken(refreshToken);
this.accessToken = newTokens.getAccessToken();
// 如果启用了令牌轮转,更新 Refresh Token
if (newTokens.getRefreshToken() != null) {
this.refreshToken = newTokens.getRefreshToken();
}
// 重试请求
request.getHeaders().setBearerAuth(accessToken);
return execution.execute(request, body);
}
return 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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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
策略二:主动刷新。 在 Access Token 过期之前,主动使用 Refresh Token 获取新的 Access Token。这种策略可以避免用户感知到 Token 过期。
java
// 教学示意代码 - 主动刷新调度器
@Component
public class ProactiveTokenRefresher {
private String refreshToken;
private String accessToken;
private long expiresAt;
private OAuthTokenService tokenService;
/**
* 定期检查并刷新 Token
* 在 Token 过期前 5 分钟触发刷新
*/
@Scheduled(fixedRate = 60000) // 每分钟检查一次
public void checkAndRefresh() {
if (shouldRefresh()) {
try {
OAuthTokenResponse newTokens =
tokenService.refreshAccessToken(refreshToken);
this.accessToken = newTokens.getAccessToken();
this.expiresAt = System.currentTimeMillis()
+ newTokens.getExpiresIn() * 1000;
if (newTokens.getRefreshToken() != null) {
this.refreshToken = newTokens.getRefreshToken();
}
} catch (Exception e) {
// 刷新失败,可能 Refresh Token 也过期了
// 需要引导用户重新登录
}
}
}
private boolean shouldRefresh() {
// 在过期前 5 分钟刷新
long refreshThreshold = 5 * 60 * 1000;
return System.currentTimeMillis() + refreshThreshold >= expiresAt;
}
}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
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
6.4 常见集成问题排查
6.4.1 授权码无效
错误信息: invalid_grant: Authorization code expired
可能原因:
- 授权码已过期(超过
timeToKillInSeconds配置的时间)。 - 授权码已被使用过(
numberOfUses设置为 1,且授权码已被消费)。 - 授权码的
redirect_uri与 Token 请求中的redirect_uri不一致。
排查步骤:
- 检查 CAS 日志,确认授权码的创建时间和消费时间。
- 确认客户端在收到授权码后及时发起了 Token 请求(建议在 30 秒内)。
- 确保 Token 请求中的
redirect_uri与授权请求中的完全一致。
6.4.2 Token 自省返回 inactive
错误信息: Token 自省端点返回 "active": false
可能原因:
- Access Token 已过期。
- Access Token 已被撤销。
- CAS 集群中 TicketRegistry 同步延迟。
排查步骤:
- 检查 Token 的签发时间和当前时间,确认是否已过期。
- 检查 CAS 日志,确认是否有 Token 撤销操作。
- 如果使用 Redis 集群,检查 Redis 节点间的数据同步状态。
6.4.3 用户信息缺失
错误信息: /oauth2.0/profile 返回的用户信息中缺少某些属性。
可能原因:
- CAS 认证过程中未解析到该属性(LDAP/数据库中不存在)。
- 属性释放策略限制了属性返回。
- 自定义
OAuth20UserProfileDataCreator中的逻辑有误。
排查步骤:
- 在 CAS 日志中查看认证后的属性集合。
- 检查服务定义中的
attributeReleasePolicy配置。 - 在自定义
OAuth20UserProfileDataCreator中添加调试日志。
6.4.4 回调地址不匹配
错误信息: invalid_request: Redirect URI mismatch
可能原因:
- 回调地址与客户端注册时的
serviceId正则表达式不匹配。 - 回调地址的协议、端口、路径与注册时不一致。
排查步骤:
- 对比实际回调地址和注册的
serviceId正则表达式。 - 注意正则表达式中的特殊字符需要转义(如
.需要写成\.)。 - 确保
http和https协议的区别。
6.5 前端应用集成指南
现代前端应用(单页应用 SPA)对接 CAS OAuth 2.0 有其特殊的挑战。由于 SPA 运行在浏览器中,无法安全地存储 client_secret,因此需要采用特殊的集成策略。
6.5.1 SPA 集成方案选择
方案一:授权码模式 + PKCE(推荐)
PKCE(Proof Key for Code Exchange,RFC 7636)是专为公开客户端(如 SPA)设计的安全增强机制。它通过在授权请求中添加 code_verifier 和 code_challenge 参数,防止授权码被截获后滥用。
1. SPA 生成随机 code_verifier(43-128 个字符的随机字符串)
2. SPA 计算 code_challenge = BASE64URL(SHA256(code_verifier))
3. SPA 重定向到 CAS 授权端点,携带 code_challenge
4. CAS 存储 code_challenge
5. SPA 收到授权码后,后端使用 code_verifier 换取 Token
6. CAS 验证 code_verifier 与之前存储的 code_challenge 是否匹配1
2
3
4
5
6
2
3
4
5
6
方案二:隐式模式(不推荐,但兼容旧系统)
隐式模式将 Access Token 直接返回给 SPA,无需后端参与。但由于 Token 暴露在浏览器 URL 中,存在安全风险。CAS 7.x 建议使用 PKCE 替代隐式模式。
6.5.2 SPA 集成示例
以下是一个基于 PKCE 的 SPA 集成示例:
javascript
// 教学示意代码 - SPA OAuth 2.0 + PKCE 集成
// 1. 生成 PKCE code_verifier 和 code_challenge
async function generatePKCE() {
// 生成随机 code_verifier
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const codeVerifier = base64UrlEncode(array);
// 计算 code_challenge
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const hash = await crypto.subtle.digest('SHA-256', data);
const codeChallenge = base64UrlEncode(new Uint8Array(hash));
return { codeVerifier, codeChallenge };
}
// 2. 发起授权请求
function redirectToAuthorize(codeChallenge, state) {
const params = new URLSearchParams({
response_type: 'code',
client_id: 'spa-client-id',
redirect_uri: window.location.origin + '/callback',
scope: 'openid profile email',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href =
`https://cas.example.org/cas/oauth2.0/authorize?${params}`;
}
// 3. 处理回调
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
// 验证 state
const savedState = sessionStorage.getItem('oauth_state');
if (state !== savedState) {
throw new Error('State validation failed');
}
// 使用授权码和 code_verifier 换取 Token
// 注意:此步骤应通过后端代理完成,避免暴露 client_secret
const tokenResponse = await fetch('/api/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: code,
codeVerifier: sessionStorage.getItem('code_verifier'),
redirectUri: window.location.origin + '/callback'
})
});
return await tokenResponse.json();
}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
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
6.5.3 SPA Token 安全存储
在 SPA 中存储 Access Token 需要权衡安全性和便利性:
| 存储方式 | 安全性 | 持久性 | XSS 风险 | CSRF 风险 |
|---|---|---|---|---|
localStorage | 低 | 永久 | 高 | 低 |
sessionStorage | 中 | 会话 | 高 | 低 |
| 内存(JS 变量) | 高 | 页面刷新丢失 | 低 | 低 |
| HttpOnly Cookie | 高 | 可配置 | 无 | 中 |
推荐方案: 将 Access Token 存储在内存中,Refresh Token 存储在 HttpOnly Cookie 中。这样即使发生 XSS 攻击,攻击者也只能获取短期有效的 Access Token。
6.6 移动端应用集成指南
移动端应用(iOS/Android)对接 CAS OAuth 2.0 的集成策略与 Web 应用有所不同,主要体现在以下几个方面:
6.6.1 移动端安全考量
- 证书固定(Certificate Pinning): 移动端应用应使用证书固定技术,防止中间人攻击。在 CAS 的 HTTPS 证书更新时,需要同步更新应用的证书固定配置。
- Deep Link / Universal Link: 使用 Deep Link(Android)或 Universal Link(iOS)作为 OAuth 2.0 的回调地址,确保授权码能够正确返回到移动应用。
- 生物识别集成: 将 OAuth 2.0 的 Refresh Token 与设备的生物识别(指纹/面部识别)绑定,增强安全性。
- 安全存储: 使用 iOS Keychain 或 Android Keystore 安全存储 Token。
6.6.2 移动端集成流程
移动端应用推荐使用授权码模式 + PKCE 的集成方式,流程与 SPA 类似,但回调方式不同:
1. 移动应用打开系统浏览器或 WebView
2. 用户在 CAS 登录页面完成认证
3. CAS 通过 Deep Link / Universal Link 回调到移动应用
4. 移动应用提取授权码
5. 移动应用使用授权码 + code_verifier 换取 Token1
2
3
4
5
2
3
4
5
重要提示: 不建议在移动应用中使用 WebView 进行 OAuth 2.0 授权。WebView 存在 Cookie 共享、证书验证绕过等安全问题。推荐使用系统浏览器(ASWebAuthenticationSession / Custom Tabs)进行授权。
6.7 微服务架构中的 OAuth 2.0 集成
在微服务架构中,OAuth 2.0 的集成模式与单体应用有显著不同。以下是微服务场景下的集成策略。
6.7.1 API 网关统一鉴权
在微服务架构中,通常通过 API 网关统一处理 OAuth 2.0 的 Token 验证:
┌──────────┐ ┌──────────┐ ┌──────────────┐
│ Client │────>│ API 网关 │────>│ 微服务 A │
│ App │ │ │ │ │
└──────────┘ │ Token │ ├──────────────┤
│ 验证 │────>│ 微服务 B │
┌──────────┐ │ │ │ │
│ Client │────>│ 请求 │ ├──────────────┤
│ App │ │ 转发 │────>│ 微服务 C │
└──────────┘ └──────────┘ │ │
│ │ └──────────────┘
│ │
▼ ▼
┌─────────────────────────┐
│ CAS OAuth 2.0 Server │
│ (Token 签发 + 自省) │
└─────────────────────────┘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
API 网关的 Token 验证策略:
- 本地验证(JWT Token): 如果使用 JWT 格式的 Access Token,API 网关可以使用 CAS 的公钥本地验证 Token,无需调用 CAS 的自省端点。这是性能最优的方案。
- 远程验证(不透明 Token): 如果使用不透明格式的 Access Token,API 网关需要调用 CAS 的自省端点验证 Token。为了减少网络开销,可以在网关层缓存自省结果。
- 混合验证: API 网关先尝试本地验证(检查 Token 格式是否为 JWT),如果不是 JWT 则回退到远程验证。
6.7.2 服务间调用的身份传递
在微服务架构中,服务 A 调用服务 B 时,需要传递用户身份信息。常见的方案包括:
- Token 传递: 服务 A 将收到的 Access Token 透传给服务 B。服务 B 可以直接使用该 Token,也可以通过 API 网关验证。
- 服务间 Token: 服务 A 使用自己的客户端凭证获取一个新的 Access Token,代表服务身份调用服务 B。这种方案适用于服务间调用不需要用户身份的场景。
- 上下文传递: 服务 A 从 Token 中提取用户身份信息,通过 HTTP Header(如
X-User-Id、X-User-Roles)传递给服务 B。这种方案需要在内部网络中确保 Header 的可信度。
七、5.3 vs 7.3 两种定制方案对比分析
7.1 架构设计理念对比
CAS 5.3 和 7.3 在 OAuth 2.0 定制方案上的差异,反映了 CAS 项目在架构设计理念上的重大演进。
CAS 5.3 的设计理念:
CAS 5.3 的定制方案以"外部拦截"为核心思想。由于当时的 Spring Boot 自动配置机制还不够成熟,CAS 的扩展点相对有限,开发者需要通过 Servlet Filter 在 HTTP 层面拦截请求和响应,实现定制逻辑。这种方案的本质是"在 CAS 的边界之外进行修改"。
CAS 5.3 定制模型:
┌─────────────────────────────────────────────┐
│ HTTP 请求层 │
│ ┌──────────────────────────────────────┐ │
│ │ Custom Filter(开发者实现) │ │
│ │ ├─ 拦截请求 │ │
│ │ ├─ 修改请求参数 │ │
│ │ └─ 修改响应内容 │ │
│ └──────────────────────────────────────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ CAS 内部逻辑(黑盒) │ │
│ │ ├─ Controller │ │
│ │ ├─ Service │ │
│ │ └─ Repository │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────┘1
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
CAS 7.3 的设计理念:
CAS 7.3 的定制方案以"内部扩展"为核心思想。通过 Spring Boot 的条件装配机制,开发者可以直接替换 CAS 内部的核心组件。这种方案的本质是"在 CAS 的内部进行扩展"。
CAS 7.3 定制模型:
┌─────────────────────────────────────────────┐
│ Spring 容器层 │
│ ┌──────────────────────────────────────┐ │
│ │ CAS 自动配置 │ │
│ │ ├─ @ConditionalOnMissingBean │ │
│ │ └─ 默认实现(可被替换) │ │
│ └──────────────────────────────────────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ 自定义配置(开发者实现) │ │
│ │ ├─ @Bean 自定义组件 │ │
│ │ ├─ @RefreshScope 热刷新 │ │
│ │ └─ 依赖注入 CAS 内部服务 │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────┘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
7.2 代码复杂度对比
7.2.1 实现相同功能所需的代码量
以"根据客户端类型返回不同的用户属性"这一需求为例:
CAS 5.3 方案需要的组件:
| 组件 | 代码行数(估算) | 说明 |
|---|---|---|
CustomUserProfileDataCreator | ~80 行 | 用户资料创建逻辑 |
CustomUserProfileViewRenderer | ~40 行 | 响应格式定制 |
Oauth2AuthorizeRequestWrapper | ~60 行 | 请求包装器 |
Oauth2AuthorizeFilter | ~50 行 | 授权端点过滤器 |
Oauth2AccessTokenRequestWrapper | ~70 行 | Token 请求包装器 |
Oauth2AccessTokenResponseWrapper | ~40 行 | Token 响应包装器 |
Oauth2AccessTokenFilter | ~80 行 | Token 端点过滤器 |
FilterRegistrationBean 配置 | ~30 行 | Filter 注册 |
| 合计 | ~450 行 |
CAS 7.3 方案需要的组件:
| 组件 | 代码行数(估算) | 说明 |
|---|---|---|
CustomUserProfileDataCreator | ~80 行 | 用户资料创建逻辑 |
@Configuration 配置类 | ~30 行 | Bean 注册 |
| 合计 | ~110 行 |
代码量对比: CAS 7.3 方案的代码量约为 5.3 方案的 1/4。这主要得益于:
- 不需要 Filter 和 Wrapper 的样板代码。
- 不需要手动注册 Filter。
- Spring 的依赖注入机制减少了手动获取依赖的代码。
7.2.2 代码质量维度对比
| 维度 | CAS 5.3 | CAS 7.3 |
|---|---|---|
| 可读性 | 中(Filter 链路复杂) | 高(直截了当的 Bean 替换) |
| 可测试性 | 低(依赖 HTTP 环境) | 高(纯 Java 单元测试) |
| 类型安全 | 中(大量字符串操作) | 高(接口约束) |
| 依赖管理 | 手动(Filter 中获取 Bean) | 自动(Spring 注入) |
| 异常处理 | 复杂(Filter 异常传播受限) | 简单(标准 Java 异常处理) |
7.3 可维护性对比
7.3.1 版本升级影响
CAS 5.3 升级风险:
- 高风险: Filter 方案与 CAS 的内部 HTTP 处理流程紧密耦合。CAS 版本升级可能改变 Controller 的 URL 映射、请求参数解析方式、响应格式等,导致 Filter 方案失效。
- 排查困难: 升级后如果 Filter 方案出现问题,需要在 HTTP 层面逐步排查,定位问题耗时。
- 回归测试量大: 每次升级都需要全面测试所有 Filter 的拦截逻辑。
CAS 7.3 升级风险:
- 低风险: Bean 替换方案基于 CAS 的公开接口。CAS 在版本升级时通常会保持接口的向后兼容性。
- 编译期检查: 如果 CAS 修改了接口定义,编译时就会报错,而不是运行时才发现。
- 渐进式迁移: 可以逐步替换默认 Bean,不需要一次性完成所有定制。
7.3.2 运维友好度
| 运维维度 | CAS 5.3 | CAS 7.3 |
|---|---|---|
| 配置热更新 | 不支持 | 支持(@RefreshScope) |
| 监控集成 | 需要手动埋点 | 原生支持 Micrometer |
| 日志追踪 | 需要手动添加 MDC | 集成 SLF4J MDC |
| 健康检查 | 不支持 | 支持 Spring Actuator |
| 指标暴露 | 不支持 | 支持 /actuator/metrics |
7.4 升级路径建议
对于正在使用 CAS 5.3 Filter 方案的项目,升级到 7.3 条件装配方案的推荐路径如下:
7.4.1 升级前置条件
- Java 版本升级: CAS 7.3 要求 Java 17+,需要先完成 JDK 升级。
- Spring Boot 版本升级: CAS 7.3 基于 Spring Boot 3.x,需要熟悉 Spring Boot 3 的新特性(如 Jakarta EE 命名空间变更)。
- 依赖审查: 检查所有第三方依赖是否兼容 Spring Boot 3.x。
7.4.2 渐进式迁移步骤
第一阶段:双轨运行(1-2 周)
┌─────────────────────────────────────────┐
│ CAS 5.3 实例(生产环境) │
│ ├─ Filter 方案继续运行 │
│ └─ 逐步添加 @ConditionalOnMissingBean │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ CAS 7.3 实例(测试环境) │
│ ├─ 条件装配方案 │
│ └─ 功能验证与性能测试 │
└─────────────────────────────────────────┘1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
第二阶段:功能对齐(2-4 周)
- 将 Filter 中的业务逻辑迁移到自定义 Bean 中。
- 使用
@ConditionalOnMissingBean替换 CAS 默认组件。 - 添加
@RefreshScope支持配置热更新。 - 集成 Micrometer 监控。
- 编写单元测试和集成测试。
第三阶段:灰度切换(1-2 周)
- 在测试环境完成全部功能验证。
- 在预发布环境进行灰度测试。
- 选择低峰时段进行生产环境切换。
- 切换后保留 5.3 实例作为回退方案。
第四阶段:清理收尾(1 周)
- 移除所有 Filter 相关代码。
- 更新部署文档和运维手册。
- 通知所有下游客户端。
7.4.3 常见升级陷阱
陷阱一:javax 到 jakarta 的包名变更。
Spring Boot 3.x 将 Java EE 的包名从 javax.* 变更为 jakarta.*。如果你的自定义代码中使用了 javax.servlet.*、javax.validation.* 等包,需要全部替换为 jakarta.servlet.*、jakarta.validation.*。
java
// CAS 5.3
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
// CAS 7.3
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;1
2
3
4
5
6
7
2
3
4
5
6
7
陷阱二:Spring Security 配置变更。
CAS 7.3 使用 Spring Security 6.x,其配置 API 发生了重大变化。如果你有自定义的 Spring Security 配置,需要适配新的 API。
陷阱三:CAS 配置属性变更。
CAS 7.3 对部分配置属性进行了重命名或调整。升级时需要仔细对比新旧版本的配置文档,确保所有配置项正确映射。
7.5 选型决策树
根据项目的实际情况,使用以下决策树选择定制方案:
开始
│
├─ 是否使用 CAS 7.x 及以上版本?
│ ├─ 是 → 使用条件装配方案(7.3 方案)
│ └─ 否 ↓
│
├─ 是否计划在 6 个月内升级到 CAS 7.x?
│ ├─ 是 → 直接使用条件装配方案
│ │ (在 5.3/6.x 中也可以使用 @ConditionalOnMissingBean)
│ └─ 否 ↓
│
├─ 定制需求是否仅限于用户信息返回?
│ ├─ 是 → 使用条件装配方案
│ │ (OAuth20UserProfileDataCreator 在 5.3 中也可替换)
│ └─ 否 ↓
│
├─ 是否需要修改 HTTP 请求/响应层面?
│ ├─ 是 → 使用 Filter 方案(5.3 方案)
│ └─ 否 → 使用条件装配方案
│
└─ 是否需要深度定制 Token 生成逻辑?
├─ 是 → 评估是否可以通过 CAS 7.3 的扩展点实现
│ 如果不能,考虑 Filter 方案
└─ 否 → 使用条件装配方案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
7.6 实战案例:某大型银行从 5.3 迁移到 7.3 的经验总结
为了更直观地展示两种方案的差异和迁移路径,以下分享一个真实的案例经验(已脱敏处理)。
7.6.1 项目背景
某大型商业银行的统一认证平台基于 CAS 5.3 构建,为全行 200+ 个应用提供 SSO 和 OAuth 2.0 服务。随着业务的发展,面临以下挑战:
- 性能瓶颈: 高峰期每秒 Token 请求量超过 5000 次,内存存储的 Token Registry 成为瓶颈。
- 定制复杂: 不同业务线对用户信息的需求差异巨大,Filter 方案的维护成本越来越高。
- 安全合规: 监管要求 Token 必须加密存储,且需要支持密钥轮转。
- 运维困难: 每次配置变更都需要重启 CAS,影响业务连续性。
7.6.2 迁移方案
经过评估,团队决定将 CAS 从 5.3 升级到 7.3,同时将定制方案从 Filter 迁移到条件装配。
迁移前(5.3 + Filter):
- 7 个自定义 Filter
- 约 2000 行定制代码
- 配置变更需要重启(平均停机时间 15 分钟)
- Token 存储使用内存,不支持集群共享
迁移后(7.3 + 条件装配):
- 0 个 Filter
- 约 500 行定制代码(减少 75%)
- 支持配置热刷新(零停机)
- Token 存储使用 Redis 集群,支持水平扩展
7.6.3 迁移收益
| 维度 | 迁移前 | 迁移后 | 改善幅度 |
|---|---|---|---|
| 定制代码量 | ~2000 行 | ~500 行 | 减少 75% |
| 配置变更生效时间 | 15 分钟(重启) | < 10 秒(热刷新) | 提升 90 倍 |
| Token 存储容量 | 受限于 JVM 内存 | Redis 集群,理论上无限 | 显著提升 |
| 版本升级适配工作量 | 2-3 人周 | 0.5 人周 | 减少 80% |
| 线上故障排查时间 | 平均 2 小时 | 平均 30 分钟 | 减少 75% |
7.6.4 经验教训
- 不要跳版本升级: 从 5.3 直接升级到 7.3 跨越了多个大版本,中间的 API 变更和配置调整非常多。建议逐步升级(5.3 -> 6.x -> 7.3),每个版本都进行充分测试。
- 先迁移数据,再迁移代码: Token 存储从内存迁移到 Redis 时,需要确保已有的 Token 数据能够正确迁移。建议在低峰期进行切换,并保留内存存储作为回退方案。
- 充分测试 Filter 的边界情况: Filter 方案中可能存在一些"隐式依赖"——某些业务逻辑依赖于 Filter 的执行顺序或特定行为,这些在迁移到 Bean 方案后可能丢失。建议编写全面的集成测试覆盖所有边界情况。
- 密钥管理要提前规划: 7.3 版本强制要求配置加密和签名密钥,且密钥格式与 5.3 不同。需要提前规划密钥的生成、分发和轮转策略。
7.7 属性释放策略深度解析
在 OAuth 2.0 集成中,属性释放策略(Attribute Release Policy)是控制用户信息返回给客户端的核心机制。CAS 提供了多种属性释放策略,可以根据不同的需求灵活配置。
7.7.1 常用属性释放策略
策略一:释放所有属性(ReturnAllAttributeReleasePolicy)
将 CAS 认证过程中解析到的所有用户属性都返回给客户端。这是最简单的策略,但安全性最低,仅适用于高度可信的内部应用。
json
{
"attributeReleasePolicy": {
"@class": "org.apereo.cas.services.ReturnAllAttributeReleasePolicy"
}
}1
2
3
4
5
2
3
4
5
策略二:允许列表策略(AllowedAttributesAttributeReleasePolicy)
只释放白名单中列出的属性。这是生产环境中最常用的策略,兼顾了灵活性和安全性。
json
{
"attributeReleasePolicy": {
"@class": "org.apereo.cas.services.AllowedAttributesAttributeReleasePolicy",
"allowedAttributes": [
"java.util.HashSet",
["uid", "mail", "displayName", "department"]
]
}
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
策略三:拒绝列表策略(DeniedAttributesAttributeReleasePolicy)
释放除黑名单中列出的属性之外的所有属性。适用于"默认允许,特定拒绝"的场景。
json
{
"attributeReleasePolicy": {
"@class": "org.apereo.cas.services.DeniedAttributesAttributeReleasePolicy",
"deniedAttributes": [
"java.util.HashSet",
["userPassword", "ssn", "salary"]
]
}
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
策略四:基于规则策略(PatternMatchingAttributeReleasePolicy)
使用正则表达式匹配属性名,灵活控制属性释放。适用于属性命名有规律的场景。
json
{
"attributeReleasePolicy": {
"@class": "org.apereo.cas.services.PatternMatchingAttributeReleasePolicy",
"allowedAttributes": [
"java.util.HashSet",
["uid", "mail", "department.*", "role_.*"]
],
"excludedAttributes": [
"java.util.HashSet",
[".*password.*", ".*secret.*"]
]
}
}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
策略五:基于脚本策略(GroovyAttributeReleasePolicy)
使用 Groovy 脚本动态决定释放哪些属性。这是最灵活的策略,适用于复杂的业务规则。
json
{
"attributeReleasePolicy": {
"@class": "org.apereo.cas.services.GroovyAttributeReleasePolicy",
"groovyScript": "file:/etc/cas/attribute-release.groovy"
}
}1
2
3
4
5
6
2
3
4
5
6
groovy
// attribute-release.groovy 示例
// 根据客户端类型和用户角色动态决定释放的属性
def releaseAttributes = [:]
// 基础属性,所有客户端都可以获取
releaseAttributes['uid'] = attributes['uid']
releaseAttributes['mail'] = attributes['mail']
// 根据客户端类型释放不同的属性
if (service.id.contains('hr-system')) {
releaseAttributes['employeeId'] = attributes['employeeId']
releaseAttributes['department'] = attributes['department']
releaseAttributes['position'] = attributes['position']
}
if (service.id.contains('finance-system')) {
releaseAttributes['costCenter'] = attributes['costCenter']
releaseAttributes['salaryLevel'] = attributes['salaryLevel']
}
// 根据用户角色决定是否释放敏感属性
if (attributes['roles']?.contains('ADMIN')) {
releaseAttributes['adminLevel'] = attributes['adminLevel']
}
return releaseAttributes1
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
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
7.7.2 属性释放策略的最佳实践
- 最小权限原则: 只释放客户端需要的最小属性集。避免使用
ReturnAllAttributeReleasePolicy。 - 敏感属性保护: 密码、身份证号、薪资等敏感属性不应通过 OAuth 2.0 返回。如果业务确实需要,应使用加密或脱敏处理。
- 定期审查: 定期审查每个客户端的属性释放策略,移除不再需要的属性。
- 审计日志: 启用属性释放的审计日志,记录每次属性释放的详细信息,便于安全审计。
总结与展望
本文核心要点回顾
本文从架构概述、核心配置、用户信息定制、条件装配、Token 生命周期、客户端集成以及方案对比七个维度,全面剖析了 Apereo CAS OAuth 2.0 的深度定制方案。以下是各章节的核心要点:
一、架构概述: CAS 作为 OAuth 2.0 授权服务器,支持全部四种标准授权类型,其模块化设计(oauth-core、oauth-webflow、oauth-services、oauth-api、oauth-webapp)为深度定制提供了良好的基础。CAS Overlay 项目结构使得开发者可以在不修改 CAS 源码的情况下进行定制。
二、核心配置: 授权码、访问令牌、刷新令牌的生命周期参数需要根据业务场景和安全策略进行调优。CAS 7.3 新增的 AES 加密配置和会话复制加密为集群部署提供了更强的安全保障。
三、5.3 定制方案: 通过自定义 OAuth20UserProfileDataCreator、OAuth20UserProfileViewRenderer 以及 Filter 拦截方案,可以实现灵活的用户信息返回定制。但 Filter 方案存在耦合度高、调试困难、维护成本高等局限性。
四、7.3 定制方案: 基于 @ConditionalOnMissingBean 的条件装配方案,通过替换 CAS 内部的核心 Bean 实现定制,代码量减少约 75%,可维护性显著提升。@RefreshScope 支持配置热刷新,UMA 支持提供了细粒度的权限控制能力。
五、Token 生命周期: 从授权码的生成与消费,到访问令牌的签发与验证,再到刷新令牌的轮转策略,Token 的完整生命周期管理是 OAuth 2.0 安全的核心。Redis 集成解决了集群部署中的 Token 共享问题。
六、客户端集成: 授权码模式的完整流程包括四个步骤:引导授权、处理回调、换取 Token、获取用户信息。Token 刷新机制(被动刷新和主动刷新)确保了用户体验的连续性。
七、方案对比: 7.3 条件装配方案在代码复杂度、可维护性、版本升级风险等维度全面优于 5.3 Filter 方案。对于新项目,强烈建议直接采用 7.3 方案;对于已有 5.3 项目,建议制定渐进式迁移计划。
未来展望
随着身份认证与授权领域的持续演进,CAS OAuth 2.0 的定制方案也将不断进化。以下是一些值得关注的技术趋势:
趋势一:OAuth 2.1 规范的落地。 OAuth 2.1 草案对 OAuth 2.0 进行了多项安全增强,包括强制使用 PKCE、禁止隐式模式、限制刷新令牌轮转等。CAS 未来版本将逐步支持 OAuth 2.1 规范,开发者需要关注相关的配置变更。
趋势二:无密码认证的普及。 FIDO2/WebAuthn 等无密码认证技术正在快速普及。CAS 已经支持 WebAuthn 作为认证方式,未来与 OAuth 2.0 的集成将更加紧密。
趋势三:持续授权评估(CARE)。 传统的 OAuth 2.0 授权是一次性的——用户授权后,客户端可以在 Token 有效期内持续访问资源。CARE 模式引入了持续授权评估机制,允许在用户上下文变化时(如角色变更、权限撤销)动态调整授权范围。
趋势四:AI 驱动的权限管理。 随着大语言模型和 AI 技术的发展,基于 AI 的权限管理(如自动化的权限策略生成、异常访问检测、自适应权限调整)将成为可能。CAS 的可扩展架构为集成 AI 能力提供了良好的基础。
版权声明: 本文为必码(bima.cc)原创技术文章,仅供学习交流。
本文内容基于实际项目源码解析整理,代码示例均为教学简化版本,仅供学习参考。
文档内容提取自项目源码与配置文件,如需获取完整项目代码,请访问 bima.cc。