Skip to content

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 的核心价值在于:

  1. 协议兼容性: 完整实现 OAuth 2.0 协议规范(RFC 6749),同时支持 OpenID Connect 1.0(通过 OIDC 模块)。
  2. 扩展性: 提供丰富的扩展点,允许开发者在不修改核心代码的前提下定制行为。
  3. 多协议融合: CAS 原生支持 CAS Protocol、SAML、OpenID Connect、OAuth 2.0 等多种协议,可以在同一套基础设施上同时服务不同协议的客户端。
  4. 企业级特性: 内置集群支持、会话管理、审计日志、速率限制等企业级功能。

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. 客户端将用户重定向到 CAS 的授权端点(/oauth2.0/authorize),携带 response_type=codeclient_idredirect_uriscope 等参数。
  2. CAS 验证用户会话,如果用户未登录,则展示 CAS 登录页面。
  3. 用户登录成功后,CAS 展示授权确认页面(可配置跳过)。
  4. 用户确认授权后,CAS 生成授权码,并通过重定向将授权码返回给客户端。
  5. 客户端后端使用授权码向 CAS 的 Token 端点(/oauth2.0/accessToken)发起请求,换取 Access Token。
  6. CAS 验证授权码的有效性,签发 Access Token(可选签发 Refresh Token)。
  7. 客户端使用 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=xxx

安全提示: 隐式模式由于 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=xxx

适用场景:

  • 企业内部系统之间的集成,双方由同一组织控制。
  • 遗留系统的迁移过渡方案。
  • 需要程序化获取 Token 的自动化脚本。

安全警告: 这种模式要求客户端必须高度可信,因为客户端会直接接触用户的凭据。在 CAS 7.3 中,可以通过 requireServiceHeader: true 配置来增强安全性,要求请求中必须携带特定的 Service Header 才能使用此模式。

1.2.4 客户端凭证模式(Client Credentials Grant)

客户端凭证模式适用于服务器对服务器(M2M)的通信场景,不涉及用户参与。客户端使用自己的 client_idclient_secret 获取 Access Token。

POST /oauth2.0/accessToken
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=xxx&client_secret=xxx

在 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.3.2 模块间依赖关系

                    ┌─────────────────────┐
                    │  oauth-api          │
                    │  (常量/枚举/接口)    │
                    └─────────┬───────────┘

                    ┌─────────▼───────────┐
                    │  oauth-core         │
                    │  (核心实体/逻辑)     │
                    └─────────┬───────────┘

              ┌───────────────┼───────────────┐
              │               │               │
    ┌─────────▼──────┐ ┌─────▼──────┐ ┌──────▼──────────┐
    │ oauth-services │ │ oauth-     │ │ oauth-webapp    │
    │ (服务管理)      │ │ webflow    │ │ (Web端点)        │
    └────────────────┘ │ (流程集成)  │ └─────────────────┘
                       └────────────┘

模块职责说明:

  • 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}"
}

需要注意的是,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. 零侵入原则: 所有定制代码放在 src/main/java 中,不修改 CAS 源码。
  2. 配置外部化: 所有可配置项通过 application.yml 管理,避免硬编码。
  3. 模板可覆盖: CAS 的所有前端模板都可以通过在 src/main/resources/templates 中放置同名文件来覆盖。
  4. Bean 覆盖机制: 通过 Spring 的 @Bean 注解和 @ConditionalOnMissingBean 条件装配,优雅地替换 CAS 的默认实现。

1.5 OAuth 2.0 协议端点映射

CAS OAuth 2.0 模块在 CAS 的 Webflow 中注册了以下关键端点:

端点路径HTTP 方法功能说明对应控制器
/oauth2.0/authorizeGET授权端点,发起授权请求OAuth20AuthorizeController
/oauth2.0/accessTokenPOSTToken 端点,用授权码换取 TokenOAuth20AccessTokenController
/oauth2.0/profileGET用户信息端点,获取当前用户资料OAuth20ProfileController
/oauth2.0/introspectPOSTToken 自省端点,验证 Token 有效性OAuth20IntrospectController
/oauth2.0/revokePOSTToken 撤销端点,吊销指定 TokenOAuth20RevokeController

端点安全配置要点:

  • /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.0

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)。这意味着:

  1. 用户通过 CAS Protocol 登录后,可以直接发起 OAuth 2.0 授权请求,无需重新登录。
  2. 用户通过 OAuth 2.0 授权码模式登录后,也可以直接访问 CAS Protocol 保护的资源,无需重新登录。
  3. 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)│ │             │
    └────────────────┘ └─────────────┘ └─────────────┘

这种多协议统一认证架构的核心价值在于:企业只需要维护一套身份基础设施,就可以服务不同技术栈、不同协议的应用。随着企业数字化转型的推进,新的应用可以使用 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: true

此外,建议在反向代理层(如 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.7.2 客户端认证安全

OAuth 2.0 客户端的 client_secret 是保护客户端身份的关键凭据。在 CAS 中,client_secret 支持多种存储和验证方式:

  1. 明文存储(仅限开发环境): {noop}secret
  2. BCrypt 加密存储(推荐): {bcrypt}$2a$10$...
  3. SCrypt 加密存储: {scrypt}$e0801$...
  4. PBKDF2 加密存储: {pbkdf2}...
  5. Argon2 加密存储(CAS 7.x 新增): {argon2}$...

在生产环境中,强烈建议使用 BCrypt 或 Argon2 加密存储 client_secret。即使数据库被泄露,攻击者也无法直接获取 client_secret 的明文值。

1.7.3 CSRF 防护

OAuth 2.0 授权端点是 CSRF 攻击的高风险目标。攻击者可以构造一个恶意页面,诱导已登录用户点击,从而在用户不知情的情况下获取授权码。CAS 通过以下机制防范 CSRF 攻击:

  1. state 参数验证: 客户端在授权请求中传入随机的 state 参数,CAS 在回调时原样返回,客户端验证 state 的一致性。
  2. 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: false

参数调优建议:

  • 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);
    }
}

如果需要自定义授权码格式(例如,需要包含特定前缀以便于日志追踪),可以实现 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: REFERENCE

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}

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: DEFAULT

刷新令牌轮转机制详解:

one-time-use 设置为 true 时,每次使用刷新令牌获取新的访问令牌时,CAS 会同时签发一个新的刷新令牌,并使旧的刷新令牌失效。这种机制被称为"令牌轮转"(Token Rotation),其安全价值在于:

  1. 降低令牌泄露风险: 即使刷新令牌被窃取,攻击者只能使用一次,之后令牌就会失效。
  2. 检测令牌重放: 如果一个已被消费的刷新令牌被再次使用,CAS 可以检测到异常行为,并撤销该令牌关联的所有令牌。
  3. 缩短暴露窗口: 令牌轮转使得每个刷新令牌的有效暴露窗口缩短为两次 Token 刷新之间的时间间隔。

2.4 用户资料视图类型

CAS OAuth 2.0 的 /oauth2.0/profile 端点返回的用户信息格式可以通过 userProfileViewType 配置控制。

yaml
cas:
  oauth:
    user-profile-view-type: NESTED

可选值说明:

FLAT(扁平视图):

json
{
  "id": "admin",
  "attributes": {
    "email": "admin@example.org",
    "displayName": "System Administrator",
    "phone": "13800138000"
  }
}

NESTED(嵌套视图):

json
{
  "id": "admin",
  "attributes": [
    {"name": "email", "values": ["admin@example.org"]},
    {"name": "displayName", "values": ["System Administrator"]},
    {"name": "phone", "values": ["13800138000"]}
  ]
}

选择建议:

  • 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-2

requireServiceHeader 的安全机制解析:

require-service-header: true 时,CAS 要求密码模式的请求必须携带一个有效的 Service Header。这个 Header 的值必须对应一个在 CAS 服务注册表中已注册的服务。这种机制的作用是:

  1. 限制调用来源: 只有知道有效 Service 名称的客户端才能使用密码模式。
  2. 审计追踪: 每次密码模式的调用都关联到一个具体的服务,便于审计。
  3. 防止滥用: 即使攻击者获取了 client_idclient_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"

2.6.2 密钥生成方法

在 CAS Overlay 项目中,可以使用 CAS 提供的 Shell 脚本生成加密和签名密钥:

bash
# 生成 AES 加密密钥
./gradlew generateKeys -PkeyType=ENCRYPTION

# 生成 HMAC 签名密钥
./gradlew generateKeys -PkeyType=SIGNING

生成的密钥会输出到控制台,你需要将其复制到配置文件中。

教学示例 -- 密钥生成核心逻辑:

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);
    }
}

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"

当 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. 当用户完成认证后,CAS 创建一个认证后的会话对象(Authentication)。
  2. 在集群环境中,这个会话对象需要被序列化并复制到其他节点。
  3. 启用会话复制加密后,CAS 在序列化之前对会话数据进行加密,在反序列化之后进行解密。
  4. 即使攻击者能够访问会话存储(如 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: 0

配置管理最佳实践:

  1. 密钥外部化: 所有密钥通过环境变量注入,不要硬编码在配置文件中。
  2. 环境区分: 使用 Spring Profile 为不同环境(dev、test、prod)提供不同的配置。
  3. 配置加密: 使用 Jasypt 等工具对配置文件中的敏感信息进行加密。
  4. 配置中心集成: 在微服务架构中,建议将 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);
}

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 方法类似
}

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;
    }
}

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;
}

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();
    }
}

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               │
└─────────────────────────────────────┘

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;
    }
}

3.5 Oauth2AuthorizeRequestWrapper 与 Oauth2AuthorizeFilter

3.5.1 设计思路

Oauth2AuthorizeRequestWrapper 是一个 HttpServletRequestWrapper 的子类,用于在授权请求处理过程中修改请求参数或注入额外信息。Oauth2AuthorizeFilter 则是使用这个 Wrapper 的 Servlet Filter。

典型使用场景:

  1. 动态 Scope 注入: 根据客户端类型或用户角色,自动添加额外的 Scope。
  2. 自定义参数传递: 在授权请求中注入自定义参数,这些参数可以在后续的 Token 生成过程中使用。
  3. 请求日志记录: 记录授权请求的关键信息,用于审计和分析。

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);
    }
}
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);
    }
}

3.6 Oauth2AccessTokenRequestWrapper 与 Oauth2AccessTokenFilter

3.6.1 设计思路

与授权端点的 Filter 类似,Token 端点的 Filter 用于在 Access Token 签发过程中注入自定义逻辑。但 Token 端点的定制需求通常更加复杂,因为:

  1. Token 端点处理的是 POST 请求,参数在请求体中。
  2. Token 端点的响应是 JSON 格式的 Access Token 信息。
  3. 需要在 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;
    }
}
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();
    }
}
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);
        }
    }
}

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...
}

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;
    }
}

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);
    }
}

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 Response

新范式(Bean 替换方案):

HTTP Request → CAS Controller → CAS 内部调用自定义 Bean → HTTP Response

这种转变的核心优势在于:

  1. 侵入性降低: 不需要修改 HTTP 请求/响应的流转过程,而是在 CAS 内部的业务逻辑层面进行扩展。
  2. 生命周期管理: 自定义 Bean 由 Spring 容器管理,享受依赖注入、AOP、生命周期回调等 Spring 特性。
  3. 类型安全: 通过接口和泛型约束,确保自定义实现的类型正确性。
  4. 可测试性: 自定义 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();
    }
}

当开发者提供一个自定义的 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);
    }
}

关键注解说明:

  • @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);
    }
}

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...
}
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;
    }
}

4.3.3 触发配置刷新

bash
# 通过 HTTP 端点触发配置刷新
curl -X POST http://cas.example.org/cas/actuator/refresh

注意事项:

  1. @RefreshScope 只对通过 @Value 注入的配置有效,对 @ConfigurationProperties 的支持需要额外配置。
  2. 刷新操作会销毁并重新创建 Bean,在此过程中的请求可能会遇到短暂的服务不可用。
  3. 并非所有 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();
    }
}

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.0UMA 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 访问受保护资源

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: 300

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: 3600

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 端点相关

4.6.2 Bean 命名规范

为了确保 @ConditionalOnMissingBean 能够正确工作,自定义 Bean 的名称必须与 CAS 默认 Bean 的名称一致。以下是 CAS 7.3 中常用的 Bean 名称:

Bean 名称类型功能
oauthUserProfileDataCreatorOAuth20UserProfileDataCreator用户资料创建
oauth20AccessTokenGeneratorOAuth20TokenGeneratorToken 生成
oauth20AuthorizationCodeGeneratorOAuth20AuthorizationCodeGenerator授权码生成
oauth20ResponseFactoryOAuth20ResponseFactory响应构建
oauth20ClientRegistrationServiceOAuth20ClientRegistrationService客户端注册

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);
        }
    }
}

五、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=xxx

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 响应

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;             // 最大使用次数
}

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);
    }
}

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 信息

JWT 令牌验证(本地验证):

资源服务器 → 本地验证
    ├─ 1. 验证签名(使用 CAS 的公钥)
    ├─ 2. 验证过期时间(exp claim)
    ├─ 3. 验证签发者(iss claim)
    ├─ 4. 验证受众(aud claim)
    └─ 5. 提取用户信息(sub claim + attributes)

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(刷新令牌不变)

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
        // ...
    }
}

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:26379

5.4.3 Redis 中的 Token 存储结构

CAS 在 Redis 中使用 Hash 结构存储 Token,Key 的格式为:

CAS_TICKET:{tokenType}:{tokenId}

例如:

CAS_TICKET:OAUTH20_ACCESS_TOKEN:abc123def456...
CAS_TICKET:OAUTH20_REFRESH_TOKEN:xyz789ghi012...
CAS_TICKET:OAUTH20_AUTHORIZATION_CODE:mno345pqr678...

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分钟)

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);
    }
}

5.5.2 密钥轮转策略

在生产环境中,定期轮转加密和签名密钥是必要的安全措施。CAS 支持密钥轮转,但需要注意以下事项:

  1. 轮转期间兼容性: 轮转密钥后,使用旧密钥签发的 Token 可能无法被验证。建议在轮转期间同时支持新旧密钥。
  2. Token 生命周期: 如果 Access Token 的有效期较短(如 2 小时),可以在 Access Token 有效期内完成密钥轮转。但如果 Refresh Token 的有效期较长(如 14 天),则需要更长的过渡期。
  3. 集群一致性: 在集群环境中,所有节点必须同时更新密钥配置。

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_token

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) {
        // 类似实现...
    }
}

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)  │
                   └─────────────┘

在这种架构下,Token 的管理需要解决以下问题:

  1. 状态一致性: 所有 CAS 节点必须能够访问和修改同一份 Token 状态。
  2. 会话亲和性: 用户在授权流程中的多次请求可能被分配到不同的节点。
  3. 故障转移: 当某个节点故障时,其他节点必须能够继续处理该节点的 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 的过期清理都是必要的运维工作。以下是不同存储后端的清理策略:

存储后端过期清理机制运维操作
RedisRedis TTL 自动过期无需手动清理
Hazelcast定时任务扫描清理需配置清理间隔
JDBC定时任务扫描清理需配置清理间隔 + 数据库表维护
内存引用计数 + GC无需手动清理(仅限单节点)

对于 Redis 方案,建议配置合理的 TTL 值,避免大量过期 Key 同时被删除导致 Redis 性能抖动。可以通过以下配置优化:

yaml
cas:
  ticket:
    registry:
      redis:
        # Token 过期清理策略
        expiration-policy:
          # 使用懒过期策略,避免主动扫描
          lazy-expiration: true

5.8 Token 性能优化实践

在高并发场景下,Token 的生成、存储和验证可能成为性能瓶颈。以下是几个经过实战验证的性能优化策略。

5.8.1 Token 生成优化

Token 生成过程中的主要性能开销来自随机数生成和加密操作。优化建议:

  1. 使用 SecureRandom 实例池: SecureRandom 在 Linux 上默认使用 /dev/random,在高并发下可能阻塞。建议使用 SecureRandom.getInstanceStrong() 并维护实例池。
  2. 预生成 Token 池: 在系统启动时预生成一批 Token,运行时直接从池中获取,避免实时生成的开销。
  3. 选择合适的加密算法: AES-GCM 比 AES-CBC 性能更好,且提供了认证加密能力。

5.8.2 Token 验证优化

Token 验证是每个 API 请求都需要执行的操作,其性能直接影响系统的吞吐量。优化建议:

  1. 使用 JWT 格式的 Access Token: JWT 可以在资源服务器本地验证,避免每次验证都调用 CAS 的自省端点。
  2. 本地缓存自省结果: 对于不透明令牌,可以在资源服务器上缓存自省结果,设置较短的缓存过期时间(如 30 秒)。
  3. 批量自省: 如果资源服务器需要同时验证多个 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   # 命令执行超时(毫秒)

六、OAuth 2.0 客户端集成

6.1 第三方应用对接概述

第三方应用对接 CAS OAuth 2.0 的前提条件:

  1. 客户端注册: 在 CAS 的服务管理中注册 OAuth 2.0 客户端,获取 client_idclient_secret
  2. 回调地址配置: 在客户端注册时配置 redirect_uri,CAS 会严格验证回调地址。
  3. 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"
  }
}

关键配置项说明:

  • 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();
}

参数说明:

参数必填说明
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);
    }
}

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();
}

CAS 返回的 Token 响应格式:

json
{
  "access_token": "AT-1-xxxxxxxxxxxxxxxx",
  "token_type": "bearer",
  "expires_in": 7200,
  "refresh_token": "RT-1-yyyyyyyyyyyyyyyy",
  "scope": "openid profile email"
}

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();
}

CAS 返回的用户信息格式(FLAT 模式):

json
{
  "id": "admin",
  "attributes": {
    "email": "admin@example.org",
    "displayName": "System Administrator",
    "department": "Engineering",
    "phone": "13800138000"
  }
}

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();
}

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;
    }
}

策略二:主动刷新。 在 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;
    }
}

6.4 常见集成问题排查

6.4.1 授权码无效

错误信息: invalid_grant: Authorization code expired

可能原因:

  1. 授权码已过期(超过 timeToKillInSeconds 配置的时间)。
  2. 授权码已被使用过(numberOfUses 设置为 1,且授权码已被消费)。
  3. 授权码的 redirect_uri 与 Token 请求中的 redirect_uri 不一致。

排查步骤:

  1. 检查 CAS 日志,确认授权码的创建时间和消费时间。
  2. 确认客户端在收到授权码后及时发起了 Token 请求(建议在 30 秒内)。
  3. 确保 Token 请求中的 redirect_uri 与授权请求中的完全一致。

6.4.2 Token 自省返回 inactive

错误信息: Token 自省端点返回 "active": false

可能原因:

  1. Access Token 已过期。
  2. Access Token 已被撤销。
  3. CAS 集群中 TicketRegistry 同步延迟。

排查步骤:

  1. 检查 Token 的签发时间和当前时间,确认是否已过期。
  2. 检查 CAS 日志,确认是否有 Token 撤销操作。
  3. 如果使用 Redis 集群,检查 Redis 节点间的数据同步状态。

6.4.3 用户信息缺失

错误信息: /oauth2.0/profile 返回的用户信息中缺少某些属性。

可能原因:

  1. CAS 认证过程中未解析到该属性(LDAP/数据库中不存在)。
  2. 属性释放策略限制了属性返回。
  3. 自定义 OAuth20UserProfileDataCreator 中的逻辑有误。

排查步骤:

  1. 在 CAS 日志中查看认证后的属性集合。
  2. 检查服务定义中的 attributeReleasePolicy 配置。
  3. 在自定义 OAuth20UserProfileDataCreator 中添加调试日志。

6.4.4 回调地址不匹配

错误信息: invalid_request: Redirect URI mismatch

可能原因:

  1. 回调地址与客户端注册时的 serviceId 正则表达式不匹配。
  2. 回调地址的协议、端口、路径与注册时不一致。

排查步骤:

  1. 对比实际回调地址和注册的 serviceId 正则表达式。
  2. 注意正则表达式中的特殊字符需要转义(如 . 需要写成 \.)。
  3. 确保 httphttps 协议的区别。

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_verifiercode_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 是否匹配

方案二:隐式模式(不推荐,但兼容旧系统)

隐式模式将 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();
}

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 移动端安全考量

  1. 证书固定(Certificate Pinning): 移动端应用应使用证书固定技术,防止中间人攻击。在 CAS 的 HTTPS 证书更新时,需要同步更新应用的证书固定配置。
  2. Deep Link / Universal Link: 使用 Deep Link(Android)或 Universal Link(iOS)作为 OAuth 2.0 的回调地址,确保授权码能够正确返回到移动应用。
  3. 生物识别集成: 将 OAuth 2.0 的 Refresh Token 与设备的生物识别(指纹/面部识别)绑定,增强安全性。
  4. 安全存储: 使用 iOS Keychain 或 Android Keystore 安全存储 Token。

6.6.2 移动端集成流程

移动端应用推荐使用授权码模式 + PKCE 的集成方式,流程与 SPA 类似,但回调方式不同:

1. 移动应用打开系统浏览器或 WebView
2. 用户在 CAS 登录页面完成认证
3. CAS 通过 Deep Link / Universal Link 回调到移动应用
4. 移动应用提取授权码
5. 移动应用使用授权码 + code_verifier 换取 Token

重要提示: 不建议在移动应用中使用 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 签发 + 自省)     │
└─────────────────────────┘

API 网关的 Token 验证策略:

  1. 本地验证(JWT Token): 如果使用 JWT 格式的 Access Token,API 网关可以使用 CAS 的公钥本地验证 Token,无需调用 CAS 的自省端点。这是性能最优的方案。
  2. 远程验证(不透明 Token): 如果使用不透明格式的 Access Token,API 网关需要调用 CAS 的自省端点验证 Token。为了减少网络开销,可以在网关层缓存自省结果。
  3. 混合验证: API 网关先尝试本地验证(检查 Token 格式是否为 JWT),如果不是 JWT 则回退到远程验证。

6.7.2 服务间调用的身份传递

在微服务架构中,服务 A 调用服务 B 时,需要传递用户身份信息。常见的方案包括:

  1. Token 传递: 服务 A 将收到的 Access Token 透传给服务 B。服务 B 可以直接使用该 Token,也可以通过 API 网关验证。
  2. 服务间 Token: 服务 A 使用自己的客户端凭证获取一个新的 Access Token,代表服务身份调用服务 B。这种方案适用于服务间调用不需要用户身份的场景。
  3. 上下文传递: 服务 A 从 Token 中提取用户身份信息,通过 HTTP Header(如 X-User-IdX-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                       │   │
│  └──────────────────────────────────────┘   │
└─────────────────────────────────────────────┘

CAS 7.3 的设计理念:

CAS 7.3 的定制方案以"内部扩展"为核心思想。通过 Spring Boot 的条件装配机制,开发者可以直接替换 CAS 内部的核心组件。这种方案的本质是"在 CAS 的内部进行扩展"。

CAS 7.3 定制模型:

┌─────────────────────────────────────────────┐
│             Spring 容器层                    │
│  ┌──────────────────────────────────────┐   │
│  │  CAS 自动配置                        │   │
│  │  ├─ @ConditionalOnMissingBean        │   │
│  │  └─ 默认实现(可被替换)              │   │
│  └──────────────────────────────────────┘   │
│  ┌──────────────────────────────────────┐   │
│  │  自定义配置(开发者实现)              │   │
│  │  ├─ @Bean 自定义组件                 │   │
│  │  ├─ @RefreshScope 热刷新             │   │
│  │  └─ 依赖注入 CAS 内部服务             │   │
│  └──────────────────────────────────────┘   │
└─────────────────────────────────────────────┘

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。这主要得益于:

  1. 不需要 Filter 和 Wrapper 的样板代码。
  2. 不需要手动注册 Filter。
  3. Spring 的依赖注入机制减少了手动获取依赖的代码。

7.2.2 代码质量维度对比

维度CAS 5.3CAS 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.3CAS 7.3
配置热更新不支持支持(@RefreshScope)
监控集成需要手动埋点原生支持 Micrometer
日志追踪需要手动添加 MDC集成 SLF4J MDC
健康检查不支持支持 Spring Actuator
指标暴露不支持支持 /actuator/metrics

7.4 升级路径建议

对于正在使用 CAS 5.3 Filter 方案的项目,升级到 7.3 条件装配方案的推荐路径如下:

7.4.1 升级前置条件

  1. Java 版本升级: CAS 7.3 要求 Java 17+,需要先完成 JDK 升级。
  2. Spring Boot 版本升级: CAS 7.3 基于 Spring Boot 3.x,需要熟悉 Spring Boot 3 的新特性(如 Jakarta EE 命名空间变更)。
  3. 依赖审查: 检查所有第三方依赖是否兼容 Spring Boot 3.x。

7.4.2 渐进式迁移步骤

第一阶段:双轨运行(1-2 周)

┌─────────────────────────────────────────┐
│ CAS 5.3 实例(生产环境)                 │
│  ├─ Filter 方案继续运行                  │
│  └─ 逐步添加 @ConditionalOnMissingBean  │
└─────────────────────────────────────────┘


┌─────────────────────────────────────────┐
│ CAS 7.3 实例(测试环境)                 │
│  ├─ 条件装配方案                        │
│  └─ 功能验证与性能测试                   │
└─────────────────────────────────────────┘

第二阶段:功能对齐(2-4 周)

  1. 将 Filter 中的业务逻辑迁移到自定义 Bean 中。
  2. 使用 @ConditionalOnMissingBean 替换 CAS 默认组件。
  3. 添加 @RefreshScope 支持配置热更新。
  4. 集成 Micrometer 监控。
  5. 编写单元测试和集成测试。

第三阶段:灰度切换(1-2 周)

  1. 在测试环境完成全部功能验证。
  2. 在预发布环境进行灰度测试。
  3. 选择低峰时段进行生产环境切换。
  4. 切换后保留 5.3 实例作为回退方案。

第四阶段:清理收尾(1 周)

  1. 移除所有 Filter 相关代码。
  2. 更新部署文档和运维手册。
  3. 通知所有下游客户端。

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;

陷阱二: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 方案
      └─ 否 → 使用条件装配方案

7.6 实战案例:某大型银行从 5.3 迁移到 7.3 的经验总结

为了更直观地展示两种方案的差异和迁移路径,以下分享一个真实的案例经验(已脱敏处理)。

7.6.1 项目背景

某大型商业银行的统一认证平台基于 CAS 5.3 构建,为全行 200+ 个应用提供 SSO 和 OAuth 2.0 服务。随着业务的发展,面临以下挑战:

  1. 性能瓶颈: 高峰期每秒 Token 请求量超过 5000 次,内存存储的 Token Registry 成为瓶颈。
  2. 定制复杂: 不同业务线对用户信息的需求差异巨大,Filter 方案的维护成本越来越高。
  3. 安全合规: 监管要求 Token 必须加密存储,且需要支持密钥轮转。
  4. 运维困难: 每次配置变更都需要重启 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 经验教训

  1. 不要跳版本升级: 从 5.3 直接升级到 7.3 跨越了多个大版本,中间的 API 变更和配置调整非常多。建议逐步升级(5.3 -> 6.x -> 7.3),每个版本都进行充分测试。
  2. 先迁移数据,再迁移代码: Token 存储从内存迁移到 Redis 时,需要确保已有的 Token 数据能够正确迁移。建议在低峰期进行切换,并保留内存存储作为回退方案。
  3. 充分测试 Filter 的边界情况: Filter 方案中可能存在一些"隐式依赖"——某些业务逻辑依赖于 Filter 的执行顺序或特定行为,这些在迁移到 Bean 方案后可能丢失。建议编写全面的集成测试覆盖所有边界情况。
  4. 密钥管理要提前规划: 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"
  }
}

策略二:允许列表策略(AllowedAttributesAttributeReleasePolicy)

只释放白名单中列出的属性。这是生产环境中最常用的策略,兼顾了灵活性和安全性。

json
{
  "attributeReleasePolicy": {
    "@class": "org.apereo.cas.services.AllowedAttributesAttributeReleasePolicy",
    "allowedAttributes": [
      "java.util.HashSet",
      ["uid", "mail", "displayName", "department"]
    ]
  }
}

策略三:拒绝列表策略(DeniedAttributesAttributeReleasePolicy)

释放除黑名单中列出的属性之外的所有属性。适用于"默认允许,特定拒绝"的场景。

json
{
  "attributeReleasePolicy": {
    "@class": "org.apereo.cas.services.DeniedAttributesAttributeReleasePolicy",
    "deniedAttributes": [
      "java.util.HashSet",
      ["userPassword", "ssn", "salary"]
    ]
  }
}

策略四:基于规则策略(PatternMatchingAttributeReleasePolicy)

使用正则表达式匹配属性名,灵活控制属性释放。适用于属性命名有规律的场景。

json
{
  "attributeReleasePolicy": {
    "@class": "org.apereo.cas.services.PatternMatchingAttributeReleasePolicy",
    "allowedAttributes": [
      "java.util.HashSet",
      ["uid", "mail", "department.*", "role_.*"]
    ],
    "excludedAttributes": [
      "java.util.HashSet",
      [".*password.*", ".*secret.*"]
    ]
  }
}

策略五:基于脚本策略(GroovyAttributeReleasePolicy)

使用 Groovy 脚本动态决定释放哪些属性。这是最灵活的策略,适用于复杂的业务规则。

json
{
  "attributeReleasePolicy": {
    "@class": "org.apereo.cas.services.GroovyAttributeReleasePolicy",
    "groovyScript": "file:/etc/cas/attribute-release.groovy"
  }
}
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 releaseAttributes

7.7.2 属性释放策略的最佳实践

  1. 最小权限原则: 只释放客户端需要的最小属性集。避免使用 ReturnAllAttributeReleasePolicy
  2. 敏感属性保护: 密码、身份证号、薪资等敏感属性不应通过 OAuth 2.0 返回。如果业务确实需要,应使用加密或脱敏处理。
  3. 定期审查: 定期审查每个客户端的属性释放策略,移除不再需要的属性。
  4. 审计日志: 启用属性释放的审计日志,记录每次属性释放的详细信息,便于安全审计。

总结与展望

本文核心要点回顾

本文从架构概述、核心配置、用户信息定制、条件装配、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 定制方案: 通过自定义 OAuth20UserProfileDataCreatorOAuth20UserProfileViewRenderer 以及 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