Skip to content

CAS JWT AccessToken 自定义属性注入:从继承式到组合式的三代架构演进

作者: 必码 | bima.cc


前言

在 OAuth 2.0 生态体系中,AccessToken 是客户端访问受保护资源的核心凭证。传统的 OAuth 2.0 规范定义的 AccessToken 是一种不透明令牌(Opaque Token),客户端无法直接解析其内容,必须通过令牌内省(Token Introspection)端点才能获取令牌的元数据信息。随着微服务架构的普及和 JWT(JSON Web Token)技术的成熟,越来越多的系统选择将 AccessToken 直接编码为 JWT 格式,使资源服务器能够通过本地验证令牌签名来确认其有效性,而无需每次都回调授权服务器。这种模式极大地降低了系统间的耦合度,提升了整体吞吐能力。

Apereo CAS(Central Authentication Service)作为企业级单点登录和身份认证平台,从 5.x 版本开始逐步引入了对 OAuth 2.0 和 OpenID Connect 的支持。在 JWT AccessToken 的实现上,CAS 经历了从完全不支持到基于继承式扩展,再到组合式重构的三代架构演进。每一代架构都反映了 CAS 框架设计哲学的深刻变化,也直接影响着开发者进行自定义属性注入的方式和复杂度。

本文基于实际 cas-overlay 项目中 5.3、6.6、7.3 三个版本的源码,深入分析 JWT AccessToken 自定义属性注入的实现机制。我们将从架构演进的角度出发,逐一拆解每个版本的核心设计、配置体系和扩展模式,帮助读者理解 CAS 框架的底层原理,并掌握在不同版本中实现自定义属性注入的最佳实践。

读者受众说明: 本文面向具有一定 CAS 使用经验的中高级 Java 开发者,要求读者具备以下基础知识:

  • 熟悉 OAuth 2.0 协议基本流程(授权码模式、客户端凭证模式等)
  • 了解 JWT 的结构和签名验证机制
  • 掌握 Spring Boot 自动配置和 Bean 注册原理
  • 有 CAS Overlay 项目的实际部署和定制经验

第一章 JWT AccessToken 在 CAS 中的定位与演进

导读: 本章从宏观视角梳理 JWT AccessToken 在 OAuth 2.0 协议中的技术定位,并分析 CAS 三个版本对 JWT AccessToken 的支持现状,帮助读者建立全局认知。

1.1 不透明 Token vs JWT Token

在 OAuth 2.0 协议(RFC 6749)的原始设计中,AccessToken 被定义为一种不透明字符串。授权服务器生成一个随机字符串作为令牌,将其与关联的授权信息(如用户标识、权限范围、过期时间等)一起存储在服务端。资源服务器在收到 AccessToken 后,需要通过令牌内省端点(RFC 7662)或 UserInfo 端点(OpenID Connect)向授权服务器验证令牌的有效性并获取关联信息。

这种不透明 Token 模式具有以下特征:

+------------------+         +------------------+         +------------------+
|   Client App     |         |  Auth Server     |         | Resource Server  |
|                  |         |                  |         |                  |
| 1. 请求授权      | ------> | 2. 生成随机Token  |         |                  |
|                  |         |    AT-8f3a2b...   |         |                  |
|                  | <------ | 3. 返回Token     |         |                  |
|                  |         |                  |         |                  |
| 4. 携带Token     | ----------------------------> | 5. 验证Token     |
|    访问资源      |         |                  | <------ |    (回调Auth     |
|                  |         | 6. 返回Token信息  | ------> |     Server)      |
|                  |         |                  |         |                  |
+------------------+         +------------------+         +------------------+

图 1-1:不透明 Token 模式的请求流程

不透明 Token 的优势在于令牌本身不携带任何敏感信息,即使被截获也无法直接获取用户数据。但其劣势也很明显:每次资源请求都需要一次网络回调来验证令牌,在高并发场景下这会成为系统瓶颈。

JWT Token 模式则采用了完全不同的思路。AccessToken 本身就是一个经过签名的 JSON 数据结构,包含了所有必要的声明信息(Claims)。资源服务器只需通过本地验证 JWT 的签名和过期时间,即可确认令牌的有效性,无需网络回调。

+------------------+         +------------------+         +------------------+
|   Client App     |         |  Auth Server     |         | Resource Server  |
|                  |         |                  |         |                  |
| 1. 请求授权      | ------> | 2. 生成JWT Token  |         |                  |
|                  |         |    eyJhbGciOi...  |         |                  |
|                  | <------ | 3. 返回JWT       |         |                  |
|                  |         |                  |         |                  |
| 4. 携带JWT       | ----------------------------> | 5. 本地验证签名   |
|    访问资源      |         |                  |         |    和过期时间     |
|                  |         |                  |         | 6. 直接返回资源   |
|                  | <------ |                  | <------ |    (无需回调)     |
+------------------+         +------------------+         +------------------+

图 1-2:JWT Token 模式的请求流程

两种模式的对比如下:

对比维度不透明 TokenJWT Token
令牌内容随机字符串,无可读信息自包含 JSON,包含声明信息
验证方式需回调授权服务器本地验证签名即可
网络开销每次请求需额外回调无需额外网络请求
令牌撤销服务端直接失效即可需配合黑名单或短有效期
令牌大小较小(通常 20-40 字节)较大(通常 500-2000 字节)
安全性令牌本身无信息泄露风险需注意声明中的敏感信息
适用场景传统单体/集中式架构微服务/分布式架构

1.2 CAS 三个版本的 JWT 支持现状

CAS 框架对 JWT AccessToken 的支持经历了从无到有、从简单到完善的过程。本文所分析的三个版本分别代表了三个不同的技术阶段:

CAS 5.3.x(2019-2020)

CAS 5.3 是一个相对成熟的版本,提供了完整的 OAuth 2.0 和 OpenID Connect 支持。但在 AccessToken 的处理上,5.3 版本仅支持不透明 Token 模式。虽然框架内部已经存在 OAuth20JwtAccessTokenEncoder 类的基础结构,但该版本并未对外暴露 createAsJwt 配置项,无法将 AccessToken 编码为 JWT 格式。对于需要 JWT 格式 AccessToken 的场景,开发者只能通过深度定制的方式自行实现。

CAS 6.6.x(2022-2023)

CAS 6.6 是一个承上启下的关键版本。该版本正式引入了 createAsJwt 配置项,允许开发者通过简单的配置即可启用 JWT 格式的 AccessToken。框架内部提供了 OAuth20JwtAccessTokenEncoder 基类,支持通过继承扩展的方式注入自定义属性。这一版本的架构设计以继承为核心,开发者需要继承 CAS 内部的编码器类并使用 Lombok 的 @SuperBuilder 注解来构建自定义实现。

CAS 7.3.x(2024-2025)

CAS 7.3 是目前最新的稳定版本,在架构上进行了全面重构。最显著的变化是从继承式设计转向了组合式设计:JWT 编码器不再继承 CAS 内部基类,而是实现了通用的 EncodableCipher<String, String> 接口。同时引入了 OAuth20ConfigurationContext 作为核心上下文对象,统一管理所有配置和依赖。此外,7.3 版本还新增了对 DPoP(Demonstrating Proof of Possession)、X.509 证书摘要、Token Exchange 等高级特性的支持。

三个版本的核心差异可以通过下表快速了解:

特性CAS 5.3CAS 6.6CAS 7.3
JWT AccessToken 支持不支持支持(继承式)支持(组合式)
配置项名称createAsJwtcreate-as-jwt
Claims 包含控制include-claims-in-jwt
编码器设计模式N/A继承 OAuth20JwtAccessTokenEncoder实现 EncodableCipher 接口
Bean 注册方式N/A@ConditionalOnMissingBean@Primary
依赖注入方式N/A直接构造注入ObjectProvider 延迟注入
DPoP 支持N/A基础支持完整支持
X.509 证书摘要N/A不支持支持
Token ExchangeN/A不支持支持
RefreshToken JWTN/A不支持支持
UserProfile 视图自定义N/A无需需自定义 Renderer
配置命名风格camelCasecamelCasekebab-case
Spring Boot 版本2.x2.x3.x
Java 版本要求11+11+17+

1.3 自定义属性注入的业务价值

在实际的企业应用场景中,标准的 OAuth 2.0 AccessToken 所携带的声明信息(如 subaudexpscope 等)往往无法满足业务需求。资源服务器通常需要获取更多的用户属性信息来执行业务逻辑,例如:

  • 用户唯一标识(userId): 业务系统内部的数据库主键,与 CAS 中的用户名(username)可能不同
  • 用户真实姓名(userName): 用于显示在界面上的用户姓名
  • 用户昵称(userNickName): 用户在系统中的显示名称
  • 用户手机号(userTelphone): 用于短信通知等业务场景
  • 用户邮箱(userEmail): 用于邮件通知等业务场景

这些自定义属性如果通过 UserInfo 端点获取,每次资源请求都需要额外的网络调用。而将其直接注入到 JWT AccessToken 中,资源服务器只需解析 JWT 即可获取所有必要的用户信息,大大简化了系统架构。

标准 JWT AccessToken Claims:
{
  "sub": "zhangsan",
  "aud": "client-app-001",
  "exp": 1715000000,
  "iat": 1714913600,
  "iss": "https://cas.bima.cc/cas/oidc",
  "scope": "openid profile"
}

注入自定义属性后的 JWT AccessToken Claims:
{
  "sub": "zhangsan",
  "aud": "client-app-001",
  "exp": 1715000000,
  "iat": 1714913600,
  "iss": "https://cas.bima.cc/cas/oidc",
  "scope": "openid profile",
  "userId": ["10086"],
  "userName": ["张三"],
  "userNickName": ["小张"],
  "userTelphone": ["138****1234"],
  "userEmail": ["zhangsan@example.com"]
}

图 1-3:标准 Claims 与注入自定义属性后的 Claims 对比

值得注意的是,CAS 框架中所有 Principal 的属性值都以 List<Object> 的形式存储,因此自定义属性的值也统一使用 List.of(...) 进行包装。这一设计虽然增加了复杂度,但保证了属性值的一致性和可扩展性。


第二章 CAS 5.3:不透明 Token 时代

导读: 本章分析 CAS 5.3 版本的 Token 机制,解释为什么该版本不支持 JWT AccessToken,并探讨在该版本中实现类似功能的替代方案。

2.1 5.3 版本的 Token 机制

CAS 5.3 的 OAuth 2.0 实现基于成熟的令牌管理架构,其核心流程如下:

+------------------------------------------------------------------+
|                    CAS 5.3 Token 管理架构                          |
+------------------------------------------------------------------+
|                                                                    |
|  OAuth20AccessTokenResponseGenerator                               |
|  +------------------------------------------------------------+  |
|  |  generate(OAuth20AccessTokenResponseResult result)           |  |
|  |    1. 检查是否为 Device Code 流程                             |  |
|  |    2. 生成 AccessToken 响应模型                               |  |
|  |    3. AccessToken 直接使用 ticket.getId() 作为令牌值           |  |
|  |    4. 返回 JSON 响应                                          |  |
|  +------------------------------------------------------------+  |
|                                                                    |
|  OAuth20DefaultAccessTokenResponseGenerator                        |
|  +------------------------------------------------------------+  |
|  |  encodeAccessToken(token, result)                            |  |
|  |    return token.getId()   // 直接返回不透明令牌               |  |
|  +------------------------------------------------------------+  |
|                                                                    |
+------------------------------------------------------------------+

图 2-1:CAS 5.3 的 Token 生成流程

在 CAS 5.3 中,当客户端通过授权码换取 AccessToken 时,OAuth20DefaultAccessTokenResponseGenerator 会直接调用 token.getId() 获取票据的唯一标识作为 AccessToken 的值。这个值是一个类似 AT-1-xxxxxxxxxxxxxxxxxxxxxx 的随机字符串,存储在 Ticket Registry(如 Redis、内存等)中。

CAS 5.3 的 application.yml 中关于 OAuth AccessToken 的配置如下:

yaml
cas:
  authn:
    oauth:
      refreshToken:
        timeToKillInSeconds: 1209600    # 刷新Token有效期:14天
      code:
        timeToKillInSeconds: 300         # 授权码有效期:5分钟
        numberOfUses: 1                  # 授权码仅使用一次
      accessToken:
        releaseProtocolAttributes: true  # 释放协议属性
        timeToKillInSeconds: 86400       # AccessToken有效期:1天
        maxTimeToLiveInSeconds: 43200    # 最大有效期:12小时
      grants:
        resourceOwner:
          requireServiceHeader: true     # 资源所有者模式需要服务头
      userProfileViewType: NESTED        # 用户信息嵌套格式

注意,在上述配置中没有任何与 JWT 相关的配置项。CAS 5.3 的 OAuthAccessTokenProperties 配置类中确实不存在 createAsJwt 属性,这意味着框架层面就没有提供将 AccessToken 编码为 JWT 的能力。

2.2 为什么 5.3 不支持 JWT AccessToken

CAS 5.3 不支持 JWT AccessToken 的原因是多方面的:

第一,框架设计阶段的定位差异。 CAS 5.3 的 OAuth 模块主要目标是提供基本的 OAuth 2.0 协议支持,而非完整的 OAuth 2.0 授权服务器实现。在 5.3 的设计理念中,CAS 的核心价值在于单点登录(SSO),OAuth 只是 SSO 的扩展能力。JWT AccessToken 涉及到签名密钥管理、令牌格式标准化等复杂问题,与 CAS 的核心定位存在一定偏差。

第二,内部 API 尚未成熟。 虽然 CAS 5.3 内部已经存在 OAuth20JwtAccessTokenEncoder 类,但该类的方法签名和依赖关系尚未稳定。在 5.3 版本中,这个类更多是一个内部实现细节,而非面向扩展的公开 API。直接继承或覆盖该类存在较大的版本升级风险。

第三,配置体系不完善。 JWT AccessToken 的启用需要一系列配套配置,包括签名密钥、加密密钥、Claims 包含策略等。CAS 5.3 的配置体系尚未为这些配置项预留位置,强行添加会导致配置结构不一致。

CAS 5.3 内部类结构(简化示意):

OAuth20JwtAccessTokenEncoder (内部类,非公开API)
  +-- getJwtRequestBuilder()        // 构建 JWT 请求
  +-- encode()                       // 编码方法
  +-- buildPrincipalForAttributeFilter()  // 属性过滤

但该类在 5.3 中:
  - 没有 createAsJwt 配置开关
  - 没有独立的签名密钥配置
  - 没有与 OAuth20ConfigurationContext 集成
  - 没有公开的扩展点

图 2-2:CAS 5.3 内部 JWT 编码器的局限性

2.3 5.3 的替代方案

在 CAS 5.3 中,如果业务确实需要获取用户的自定义属性,可以通过以下替代方案实现:

方案一:通过 UserInfo 端点获取

这是最标准的做法。资源服务器在收到不透明 AccessToken 后,调用 CAS 的 /oauth2.0/profile 或 OIDC 的 /oidc/profile 端点获取用户信息。CAS 会返回包含用户属性的 JSON 响应。

http
GET /cas/oauth2.0/profile?access_token=AT-1-xxxxxxxx HTTP/1.1
Host: cas.bima.cc

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": "zhangsan",
  "attributes": {
    "userId": ["10086"],
    "userName": ["张三"],
    "userNickName": ["小张"],
    "userTelphone": ["138****1234"],
    "userEmail": ["zhangsan@example.com"]
  }
}

方案二:通过 Token Introspection 端点获取

OAuth 2.0 Token Introspection(RFC 7662)允许资源服务器查询 AccessToken 的元数据。CAS 5.3 支持该端点,但返回的信息相对有限,主要包含令牌的基本状态信息。

方案三:自定义 AccessTokenResponseGenerator

如果必须在不透明 Token 中携带自定义信息,可以通过自定义 OAuth20AccessTokenResponseGenerator 来实现。但这种做法并不推荐,因为不透明 Token 的设计初衷就是不携带信息,强行嵌入自定义数据会破坏协议语义。

java
// CAS 5.3 替代方案示意(简化版)
public class CustomAccessTokenResponseGenerator
        implements OAuth20AccessTokenResponseGenerator {

    @Override
    public ModelAndView generate(OAuth20AccessTokenResponseResult result) {
        // 在响应中附加自定义属性
        Map<String, Object> model = new LinkedHashMap<>();
        result.getGeneratedToken().getAccessToken().ifPresent(token -> {
            model.put("access_token", token.getId());
            // 将自定义属性放入响应体(非标准做法)
            model.put("custom_claims", buildCustomClaims(token));
        });
        return new ModelAndView(new MappingJackson2JsonView(mapper), model);
    }
}

方案四:升级到 6.6 或 7.3

如果业务对 JWT AccessToken 有强需求,最根本的解决方案是升级 CAS 版本。CAS 6.6 和 7.3 都提供了完善的 JWT AccessToken 支持,配置简单且扩展灵活。


第三章 CAS 6.6:继承式 JWT 编码器

导读: 本章深入分析 CAS 6.6 版本中基于继承式设计的 JWT AccessToken 编码器架构,包括配置启用、核心类关系、Lombok 集成、自定义属性注入以及 OIDC 场景下的特殊处理。

3.1 createAsJwt 配置启用

CAS 6.6 正式引入了 createAsJwt 配置项,开发者只需在 application.yml 中添加一行配置即可启用 JWT 格式的 AccessToken:

yaml
cas:
  authn:
    oauth:
      accessToken:
        createAsJwt: true    # 启用 JWT 格式的 AccessToken
        timeToKillInSeconds: 86400
        maxTimeToLiveInSeconds: 43200
        releaseProtocolAttributes: true

createAsJwt 设置为 true 后,CAS 的默认 OAuth20DefaultAccessTokenResponseGenerator 在生成 AccessToken 响应时,会调用内部的 OAuth20JwtAccessTokenEncoder 将票据 ID 编码为 JWT 格式。JWT 中包含标准的 Claims 信息(如 subaudexpiss 等),以及通过 Scope 过滤后的用户属性。

然而,默认实现只会包含 CAS Principal 中已有的属性。如果需要注入来自外部数据源(如数据库)的自定义属性,就需要覆盖默认的编码器实现。

3.2 CustomJwtAccessTokenConfiguration 架构

CAS 6.6 的自定义 JWT AccessToken 配置以 CustomJwtAccessTokenConfiguration 为核心,该类使用 Spring 的 @Configuration 注解标记为配置类,负责注册自定义的 Bean 来替换 CAS 的默认实现。

CAS 6.6 自定义 JWT AccessToken 架构:

CustomJwtAccessTokenConfiguration (@Configuration)
  |
  +-- @Bean accessTokenResponseGenerator
  |     |
  |     +--> CustomOAuth20AccessTokenResponseGenerator
  |           |
  |           +--> getAccessTokenBuilder()
  |                 |
  |                 +--> CustomOAuth20JwtAccessTokenEncoder (@SuperBuilder)
  |                       |
  |                       +--> getJwtRequestBuilder()  [覆盖父类方法]
  |                       |     |
  |                       |     +--> addCustomAttributes()
  |                       |           |
  |                       |           +--> UserInfoService.selectByName()
  |                       |
  |                       +--> addCustomAttributes()  [自定义方法]
  |
  +-- @Bean oidcAccessTokenResponseGenerator
        |
        +--> CustomOidcAccessTokenResponseGenerator (extends OidcAccessTokenResponseGenerator)
              |
              +--> getAccessTokenBuilder()  [覆盖父类方法]
                    |
                    +--> CustomOAuth20JwtAccessTokenEncoder (@SuperBuilder)

图 3-1:CAS 6.6 自定义 JWT AccessToken 类关系图

CustomJwtAccessTokenConfiguration 的完整结构如下(教学简化版):

java
@Configuration("customJwtAccessTokenConfiguration")
@EnableConfigurationProperties(CasConfigurationProperties.class)
public class CustomJwtAccessTokenConfiguration {

    @Autowired
    private CasConfigurationProperties casProperties;

    @Autowired
    private UserInfoService userInfoService;

    /**
     * 注册自定义的 OAuth20 AccessToken 响应生成器
     * 使用 @ConditionalOnMissingBean 确保不会与默认 Bean 冲突
     * 使用 @RefreshScope 支持配置动态刷新
     */
    @Bean
    @RefreshScope(proxyMode = ScopedProxyMode.DEFAULT)
    @ConditionalOnMissingBean(name = "accessTokenResponseGenerator")
    public OAuth20AccessTokenResponseGenerator accessTokenResponseGenerator(
            @Qualifier("accessTokenJwtBuilder") final JwtBuilder accessTokenJwtBuilder,
            @Qualifier("profileScopeToAttributesFilter")
                final OAuth20ProfileScopeToAttributesFilter filter) {

        return new CustomOAuth20AccessTokenResponseGenerator(
            accessTokenJwtBuilder, casProperties, filter, userInfoService);
    }

    /**
     * 注册自定义的 OIDC AccessToken 响应生成器
     */
    @Bean
    @RefreshScope(proxyMode = ScopedProxyMode.DEFAULT)
    @ConditionalOnMissingBean(name = "oidcAccessTokenResponseGenerator")
    public OAuth20AccessTokenResponseGenerator oidcAccessTokenResponseGenerator(
            @Qualifier("oidcIdTokenGenerator") final IdTokenGeneratorService idTokenGen,
            @Qualifier("accessTokenJwtBuilder") final JwtBuilder jwtBuilder,
            @Qualifier(OidcIssuerService.BEAN_NAME) final OidcIssuerService issuerService,
            @Qualifier("profileScopeToAttributesFilter")
                final OAuth20ProfileScopeToAttributesFilter filter) {

        return new CustomOidcAccessTokenResponseGenerator(
            idTokenGen, jwtBuilder, casProperties, issuerService, filter, userInfoService);
    }
}

这个配置类的设计有几个关键点:

  1. @ConditionalOnMissingBean:确保只有当 CAS 默认的 Bean 不存在时才注册自定义 Bean。这是一种防御性编程策略,避免在 CAS 升级后默认实现发生变化时产生冲突。

  2. @RefreshScope:支持 Spring Cloud Config 的配置动态刷新。当配置中心推送新的配置时,标记了 @RefreshScope 的 Bean 会被重新创建,从而加载最新的配置值。

  3. @Qualifier 注入:通过名称限定注入 CAS 内部的 JwtBuilderOAuth20ProfileScopeToAttributesFilter 等 Bean,确保注入的是 CAS 框架提供的正确实例。

3.3 CustomOAuth20JwtAccessTokenEncoder 继承模式

CAS 6.6 的核心自定义逻辑集中在 CustomOAuth20JwtAccessTokenEncoder 类中。该类继承了 CAS 内部的 OAuth20JwtAccessTokenEncoder 基类,通过覆盖 getJwtRequestBuilder() 方法来注入自定义属性。

java
@SuperBuilder
@Getter
@Slf4j
public class CustomOAuth20JwtAccessTokenEncoder
        extends OAuth20JwtAccessTokenEncoder {

    private final UserInfoService userInfoService;

    @Override
    protected JwtBuilder.JwtRequest getJwtRequestBuilder(
            final Optional<RegisteredService> registeredService,
            final OAuth20AccessToken accessToken) {

        // 1. 获取认证信息
        Authentication authentication = accessToken.getAuthentication();

        // 2. 构建用于属性过滤的 Principal
        Principal activePrincipal = buildPrincipalForAttributeFilter(accessToken);

        // 3. 通过 Scope 过滤器过滤属性
        Principal principal = getProfileScopeToAttributesFilter()
            .filter(accessToken.getService(), activePrincipal,
                    registeredService.orElseThrow(), accessToken);

        // 4. 复制过滤后的属性
        Map<String, List<Object>> attributes =
            new HashMap<>(principal.getAttributes());

        // 5. 处理 DPoP 确认(如果存在)
        if (accessToken.getAuthentication()
                .containsAttribute(OAuth20Constants.DPOP_CONFIRMATION)) {
            // ... DPoP 处理逻辑
        }

        // 6. 注入自定义属性
        addCustomAttributes(attributes, accessToken);

        // 7. 构建 JWT 请求
        JwtBuilder.JwtRequest.JwtRequestBuilder builder =
            JwtBuilder.JwtRequest.builder();
        var dt = authentication.getAuthenticationDate()
            .plusSeconds(accessToken.getExpirationPolicy().getTimeToLive());
        String issuerValue = StringUtils.defaultIfBlank(
            getIssuer(), getCasProperties().getServer().getPrefix());

        return builder
            .serviceAudience(accessToken.getClientId())
            .issueDate(DateTimeUtils.dateOf(authentication.getAuthenticationDate()))
            .jwtId(accessToken.getId())
            .subject(authentication.getPrincipal().getId())
            .validUntilDate(DateTimeUtils.dateOf(dt))
            .attributes(attributes)
            .registeredService(registeredService)
            .issuer(issuerValue)
            .build();
    }
}

继承式设计的核心思路是:CAS 的 OAuth20JwtAccessTokenEncoder 基类已经实现了 JWT 构建的基本框架,子类只需要覆盖 getJwtRequestBuilder() 方法,在标准属性的基础上添加自定义属性即可。这种方式的优势是代码量少,能够复用父类的所有基础设施;劣势是与父类的耦合度较高,当 CAS 升级后父类方法签名发生变化时,子类也需要同步修改。

3.4 @SuperBuilder 与 Lombok 集成

CAS 6.6 的 OAuth20JwtAccessTokenEncoder 基类使用了 Lombok 的 @SuperBuilder 注解来生成 Builder 模式代码。这是一个关键的设计决策,因为自定义子类也需要使用 Builder 来构建实例,而标准的 @Builder 注解不支持继承场景。

@SuperBuilder 的工作原理如下:

父类 OAuth20JwtAccessTokenEncoder:
  @SuperBuilder
  public class OAuth20JwtAccessTokenEncoder {
      private JwtBuilder accessTokenJwtBuilder;
      private CasConfigurationProperties casProperties;
      private OAuth20ProfileScopeToAttributesFilter profileScopeToAttributesFilter;
      // ... 其他字段
  }

  Lombok 生成:
  - OAuth20JwtAccessTokenEncoderBuilder (抽象内部类)
  - builder() 静态方法

子类 CustomOAuth20JwtAccessTokenEncoder:
  @SuperBuilder
  public class CustomOAuth20JwtAccessTokenEncoder
          extends OAuth20JwtAccessTokenEncoder {
      private UserInfoService userInfoService;  // 新增字段
  }

  Lombok 生成:
  - CustomOAuth20JwtAccessTokenEncoderBuilder (具体内部类)
  - builder() 静态方法(返回子类 Builder)

调用方式:
  CustomOAuth20JwtAccessTokenEncoder.builder()
      .accessTokenJwtBuilder(jwtBuilder)         // 父类属性
      .casProperties(casProperties)              // 父类属性
      .profileScopeToAttributesFilter(filter)    // 父类属性
      .userInfoService(userInfoService)          // 子类属性
      .build();

图 3-2:@SuperBuilder 继承链的 Builder 模式

CustomOAuth20AccessTokenResponseGenerator 中,Builder 的调用方式如下:

java
protected OAuth20JwtAccessTokenEncoder.OAuth20JwtAccessTokenEncoderBuilder
        getAccessTokenBuilder(final OAuth20AccessToken accessToken,
                              final OAuth20AccessTokenResponseResult result) {

    return CustomOAuth20JwtAccessTokenEncoder.builder()
        .accessToken(accessToken)
        .registeredService(result.getRegisteredService())
        .service(result.getService())
        .profileScopeToAttributesFilter(profileScopeToAttributesFilter)
        .accessTokenJwtBuilder(accessTokenJwtBuilder)
        .casProperties(casProperties)
        .userInfoService(userInfoService);
}

需要注意的是,@SuperBuilder 要求父类和子类都使用该注解。如果 CAS 的基类在某个版本中去掉了 @SuperBuilder,自定义子类将无法编译通过。这也是继承式设计的一个潜在风险点。

3.5 自定义属性查询与注入

自定义属性的查询和注入是整个方案的核心业务逻辑。在 CAS 6.6 中,这一逻辑封装在 CustomOAuth20JwtAccessTokenEncoderaddCustomAttributes() 方法中:

java
private void addCustomAttributes(
        Map<String, List<Object>> attributes,
        OAuth20AccessToken accessToken) {
    try {
        // 1. 从 AccessToken 的认证信息中获取用户标识
        String userId = accessToken.getAuthentication()
            .getPrincipal().getId();

        // 2. 校验参数有效性
        if (userId != null && !userId.isEmpty() && userInfoService != null) {
            // 3. 查询外部数据源获取用户详细信息
            UserInfoDTO userInfo = userInfoService.selectByName(userId);

            if (userInfo != null) {
                // 4. 将用户属性注入到 JWT Claims 中
                attributes.put("userId", List.of(userInfo.getId()));
                attributes.put("userName", List.of(userInfo.getName()));
                attributes.put("userNickName", List.of(userInfo.getNickname()));
                attributes.put("userTelphone", List.of(userInfo.getTelphone()));
                attributes.put("userEmail", List.of(userInfo.getEmail()));

                log.debug("Successfully added custom attributes for user: {}",
                    userId);
            } else {
                log.warn("User info not found for user: {}", userId);
            }
        }
    } catch (Exception e) {
        log.error("Error adding custom attributes to JWT: {}",
            e.getMessage(), e);
    }
}

这段代码的设计体现了几个重要的工程实践:

防御性编程: 方法内部对 userIduserInfoService 等关键参数进行了空值检查,确保在任何异常情况下都不会导致系统崩溃。所有异常都被捕获并记录日志,不会向上层抛出。

属性值的 List 包装: CAS 框架中所有 Principal 属性值都以 List<Object> 形式存储,自定义属性也遵循这一约定。使用 List.of() 创建不可变列表,既符合框架规范,又保证了线程安全。

日志分级记录: 正常情况使用 DEBUG 级别(避免生产环境日志泛滥),异常情况使用 WARNERROR 级别,便于问题排查。

数据源解耦: 通过 UserInfoService 接口访问用户数据,而非直接操作数据库。这使得数据源可以从关系型数据库切换到 LDAP、缓存或其他任何存储方式,只要实现相同的接口即可。

3.6 OIDC AccessTokenResponseGenerator 覆盖

在 OIDC 场景下,CAS 使用 OidcAccessTokenResponseGenerator 替代 OAuth20DefaultAccessTokenResponseGenerator。该类继承自默认实现,并增加了 OIDC 特有的逻辑(如 IdToken 生成、Issuer 设置等)。为了在 OIDC 场景下也注入自定义属性,需要额外覆盖 OidcAccessTokenResponseGenerator

java
@Slf4j
public class CustomOidcAccessTokenResponseGenerator
        extends OidcAccessTokenResponseGenerator {

    private final UserInfoService userInfoService;
    private final JwtBuilder jwtBuilder;
    private final CasConfigurationProperties casProperties;
    private final OAuth20ProfileScopeToAttributesFilter profileScopeToAttributesFilter;
    private final OidcIssuerService oidcIssuerService;

    public CustomOidcAccessTokenResponseGenerator(
            final IdTokenGeneratorService idTokenGenerator,
            final JwtBuilder jwtBuilder,
            final CasConfigurationProperties casProperties,
            final OidcIssuerService oidcIssuerService,
            final OAuth20ProfileScopeToAttributesFilter profileScopeToAttributesFilter,
            final UserInfoService userInfoService) {
        super(idTokenGenerator, jwtBuilder, casProperties,
              oidcIssuerService, profileScopeToAttributesFilter);
        this.userInfoService = userInfoService;
        this.jwtBuilder = jwtBuilder;
        this.casProperties = casProperties;
        this.profileScopeToAttributesFilter = profileScopeToAttributesFilter;
        this.oidcIssuerService = oidcIssuerService;
    }

    @Override
    protected OAuth20JwtAccessTokenEncoder.OAuth20JwtAccessTokenEncoderBuilder
            getAccessTokenBuilder(
                final OAuth20AccessToken accessToken,
                final OAuth20AccessTokenResponseResult result) {

        // 使用自定义的 JWT 编码器(与 OAuth2.0 场景共用同一个编码器)
        CustomOAuth20JwtAccessTokenEncoder.CustomOAuth20JwtAccessTokenEncoderBuilder
            builder = CustomOAuth20JwtAccessTokenEncoder.builder()
                .accessToken(accessToken)
                .registeredService(result.getRegisteredService())
                .service(result.getService())
                .profileScopeToAttributesFilter(profileScopeToAttributesFilter)
                .accessTokenJwtBuilder(jwtBuilder)
                .casProperties(casProperties)
                .userInfoService(userInfoService);

        // 设置 OIDC 特有的 Issuer
        var service = Optional.ofNullable(result.getRegisteredService())
            .filter(OidcRegisteredService.class::isInstance)
            .map(OidcRegisteredService.class::cast);
        return builder.issuer(oidcIssuerService.determineIssuer(service));
    }
}

OIDC 场景下的关键差异在于 Issuer 的确定方式。OAuth 2.0 场景使用 CAS 服务器的前缀作为 Issuer,而 OIDC 场景需要使用 OIDC 专用的 Issuer(如 https://cas.bima.cc/cas/oidc),这通过 OidcIssuerService.determineIssuer() 方法来确定。

CAS 6.6 中需要同时覆盖两个 ResponseGenerator 的原因在于 CAS 的 Bean 注册机制。CAS 内部分别注册了 accessTokenResponseGeneratoroidcAccessTokenResponseGenerator 两个 Bean,它们在 OAuth 2.0 和 OIDC 两种流程中被分别调用。如果只覆盖其中一个,另一种流程中将无法注入自定义属性。


第四章 CAS 7.3:组合式 JWT 编码器完全重构

导读: 本章是全文的核心章节,深入分析 CAS 7.3 版本中 JWT 编码器从继承到组合的架构重构,包括 EncodableCipher 接口实现、OAuth20ConfigurationContext 的核心作用、DPoP/X.509/Token Exchange 等高级特性的支持,以及 shouldEncodeAsJwt 的三条件判断逻辑。

4.1 从继承到组合的设计哲学转变

CAS 7.3 在 JWT AccessToken 编码器的设计上进行了根本性的重构。最核心的变化是从继承式(Inheritance)设计转向了组合式(Composition)设计。这一转变不仅仅是代码组织方式的变化,更反映了 CAS 框架设计哲学的深刻演进。

继承式设计的问题:

在 CAS 6.6 的继承式设计中,CustomOAuth20JwtAccessTokenEncoder 继承了 OAuth20JwtAccessTokenEncoder 基类。这种设计存在以下问题:

  1. 紧耦合: 子类与父类的实现细节紧密耦合。父类的任何内部变化都可能影响子类的行为。
  2. 脆弱的基类问题: 当 CAS 升级后,如果基类的方法签名、字段访问权限或构造逻辑发生变化,子类可能无法正常工作。
  3. Lombok 依赖: 必须依赖 @SuperBuilder 注解来支持 Builder 模式的继承,增加了对 Lombok 版本的敏感性。
  4. 单一继承限制: Java 不支持多重继承,如果需要组合多个基类的功能,继承式设计无法满足。

组合式设计的优势:

CAS 7.3 的组合式设计完全抛弃了继承关系,转而通过实现通用的 EncodableCipher<String, String> 接口来定义编码器的行为:

CAS 6.6 继承式设计:

  OAuth20JwtAccessTokenEncoder (CAS 内部基类)
          ^
          | extends
          |
  CustomOAuth20JwtAccessTokenEncoder
  +-- getJwtRequestBuilder()  [覆盖]
  +-- addCustomAttributes()   [新增]


CAS 7.3 组合式设计:

  EncodableCipher<String, String> (通用接口)
          ^
          | implements
          |
  CustomOAuth20JwtAccessTokenEncoder
  +-- encode(String value, Object[] params)  [接口方法]
  +-- getJwtRequestBuilder()                 [自定义方法]
  +-- collectClaimsForAccessToken()          [自定义方法]
  +-- shouldEncodeAsJwt()                    [自定义方法]
  +-- addCustomAttributes()                  [自定义方法]
  |
  +-- composition: OAuth20ConfigurationContext (组合)
  +-- composition: RegisteredService (组合)
  +-- composition: OAuth20Token (组合)
  +-- composition: UserInfoService (组合)

图 4-1:继承式 vs 组合式设计对比

组合式设计的核心思想是"has-a"而非"is-a"。编码器不再"是一个"特殊的 CAS 内部编码器,而是"拥有"一个配置上下文,通过上下文获取所有需要的基础设施。这种设计带来了以下优势:

  1. 松耦合: 编码器与 CAS 内部实现完全解耦,只依赖公开的接口和上下文对象。
  2. 版本兼容性: 只要 EncodableCipher 接口和 OAuth20ConfigurationContext 的 API 保持稳定,自定义编码器就不需要随 CAS 版本升级而修改。
  3. 无 Lombok 依赖: 不再需要 @SuperBuilder,使用普通的 Java 构造方法即可。
  4. 灵活组合: 可以自由组合不同的依赖,不受单一继承链的限制。

4.2 EncodableCipher 接口实现

EncodableCipher<String, String> 是 CAS 框架中定义的通用加密编码接口,其定义非常简洁:

java
public interface EncodableCipher<T, R> {
    R encode(T value, Object[] parameters);
}

对于 JWT AccessToken 编码器,TString(输入的票据 ID),R 也为 String(输出的 JWT 字符串或原始票据 ID)。CustomOAuth20JwtAccessTokenEncoder 实现该接口后的核心 encode() 方法如下:

java
@Override
public String encode(final String value, final Object[] parameters) {
    // 1. 判断是否需要编码为 JWT
    if (registeredService instanceof OAuthRegisteredService
            && shouldEncodeAsJwt()) {
        // 2. 构建 JWT 请求并编码
        return FunctionUtils.doUnchecked(() -> {
            JwtBuilder.JwtRequest request = getJwtRequestBuilder();
            return configurationContext.getAccessTokenJwtBuilder()
                .build(request);
        });
    }
    // 3. 不需要 JWT 编码时,返回原始票据 ID
    return token.getId();
}

这个方法的设计非常优雅:

  • 条件编码: 通过 shouldEncodeAsJwt() 方法判断是否需要 JWT 编码,如果不满足条件,直接返回原始的不透明令牌 ID。这意味着同一个编码器可以同时处理 JWT 和非 JWT 两种场景。
  • 异常处理: 使用 CAS 的 FunctionUtils.doUnchecked() 工具方法将受检异常转换为非受检异常,简化了调用链的异常处理。
  • 委托编码: 实际的 JWT 构建委托给 configurationContext.getAccessTokenJwtBuilder() 完成,编码器本身只负责准备 JWT 的 Claims 数据。

4.3 OAuth20ConfigurationContext 的核心作用

OAuth20ConfigurationContext 是 CAS 7.3 引入的核心上下文对象,它封装了 OAuth 2.0 模块所需的所有基础设施和配置信息。在组合式设计中,编码器不再直接依赖各种分散的 Bean,而是通过这个统一的上下文对象获取所有需要的基础设施。

OAuth20ConfigurationContext 包含的核心组件:

+------------------------------------------------------------+
|              OAuth20ConfigurationContext                     |
+------------------------------------------------------------+
|                                                              |
|  casProperties: CasConfigurationProperties                  |
|  |-- server.prefix                                           |
|  |-- authn.oauth.accessToken.create-as-jwt                  |
|  |-- authn.oauth.accessToken.include-claims-in-jwt          |
|  |-- authn.oauth.access-token.crypto.*                       |
|  |-- ...                                                     |
|                                                              |
|  accessTokenJwtBuilder: JwtBuilder                          |
|  |-- build(JwtRequest) -> String                             |
|                                                              |
|  profileScopeToAttributesFilter: OAuth20ProfileScope...     |
|  |-- filter(service, principal, service, token) -> Principal |
|                                                              |
|  authenticationAttributeReleasePolicy:                      |
|  |-- getAuthenticationAttributesForRelease(authn, svc)       |
|                                                              |
|  principalFactory: PrincipalFactory                          |
|  |-- createPrincipal(id, attributes) -> Principal            |
|                                                              |
|  ticketRegistry: TicketRegistry                              |
|  |-- getTicket(id, clazz) -> Ticket                          |
|                                                              |
+------------------------------------------------------------+

图 4-2:OAuth20ConfigurationContext 的组成结构

CustomOAuth20JwtAccessTokenEncoder 的构造方法中,可以清楚地看到上下文对象的使用方式:

java
public CustomOAuth20JwtAccessTokenEncoder(
        OAuth20ConfigurationContext configurationContext,
        RegisteredService registeredService,
        OAuth20Token token,
        Service service,
        String issuer,
        boolean forceEncodeAsJwt,
        UserInfoService userInfoService) {
    this.configurationContext = configurationContext;
    this.registeredService = registeredService;
    this.token = token;
    this.service = service;
    this.issuer = issuer;
    this.forceEncodeAsJwt = forceEncodeAsJwt;
    this.userInfoService = userInfoService;
}

与 CAS 6.6 的继承式设计相比,CAS 7.3 的构造方法参数更加清晰和语义化:

参数CAS 6.6(继承式)CAS 7.3(组合式)
配置属性通过 @Getter 继承父类字段通过 configurationContext 获取
JWT 构建器通过 @SuperBuilder 设置通过 configurationContext 获取
属性过滤器通过 @SuperBuilder 设置通过 configurationContext 获取
票据对象通过 @SuperBuilder 设置直接作为构造参数
注册服务通过 @SuperBuilder 设置直接作为构造参数
用户信息服务通过 @SuperBuilder 设置直接作为构造参数
强制 JWT 编码无此概念直接作为构造参数

4.4 DPoP 确认与 X.509 证书摘要

CAS 7.3 的 JWT 编码器新增了对 DPoP(Demonstrating Proof of Possession)和 X.509 客户端证书摘要的支持。这些是 OAuth 2.0 安全增强的重要特性。

DPoP 支持:

DPoP 是一种令牌绑定机制,它要求客户端在每次请求时提供一个 JWT 证明,证明其确实持有与 AccessToken 关联的密钥。CAS 7.3 在编码 JWT AccessToken 时,会自动将 DPoP 确认信息(cnf 声明)注入到 JWT 中:

java
// DPoP 确认处理(CAS 7.3)
if (originalAttributes.containsKey(OAuth20Constants.DPOP_CONFIRMATION)) {
    CollectionUtils.firstElement(
        originalAttributes.get(OAuth20Constants.DPOP_CONFIRMATION)
    ).ifPresent(conf -> {
        JWKThumbprintConfirmation confirmation =
            new JWKThumbprintConfirmation(new Base64URL(conf.toString()));
        var claim = confirmation.toJWTClaim();
        attributesToRelease.put(claim.getKey(), List.of(claim.getValue()));
    });
    // 同时保留 DPoP 相关的原始属性
    attributesToRelease.put(OAuth20Constants.DPOP,
        originalAttributes.get(OAuth20Constants.DPOP));
    attributesToRelease.put(OAuth20Constants.DPOP_CONFIRMATION,
        originalAttributes.get(OAuth20Constants.DPOP_CONFIRMATION));
}

DPoP 确认在 JWT 中的表现形式如下:

json
{
  "sub": "zhangsan",
  "aud": "client-app-001",
  "exp": 1715000000,
  "cnf": {
    "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
  },
  "dpop": "...",
  "dpop_confirmation": "..."
}

X.509 证书摘要支持:

类似地,当客户端使用 TLS 客户端证书认证时,CAS 7.3 会将证书的摘要信息注入到 JWT 中:

java
// X.509 证书摘要处理(CAS 7.3)
if (originalAttributes.containsKey(
        OAuth20Constants.X509_CERTIFICATE_DIGEST)) {
    CollectionUtils.firstElement(
        originalAttributes.get(OAuth20Constants.X509_CERTIFICATE_DIGEST)
    ).ifPresent(conf -> {
        X509CertificateConfirmation confirmation =
            new X509CertificateConfirmation(new Base64URL(conf.toString()));
        var claim = confirmation.toJWTClaim();
        attributesToRelease.put(claim.getKey(), List.of(claim.getValue()));
    });
    attributesToRelease.put(OAuth20Constants.X509_CERTIFICATE_DIGEST,
        originalAttributes.get(OAuth20Constants.X509_CERTIFICATE_DIGEST));
}

4.5 Token Exchange 场景支持

CAS 7.3 新增了对 RFC 8693 OAuth 2.0 Token Exchange 的支持。Token Exchange 允许客户端将一种类型的令牌交换为另一种类型的令牌,例如将一个 AccessToken 交换为另一个服务使用的 JWT。

CustomOAuth20AccessTokenResponseGenerator 中,Token Exchange 场景的处理逻辑如下:

java
protected String encodeOAuthToken(
        OAuth20Token token,
        OAuth20AccessTokenResponseResult result) {
    var ctx = configurationContext.getObject();
    var registeredService = result.getRegisteredService();
    var service = result.getService();
    String issuer = ctx.getCasProperties().getServer().getPrefix();

    // 判断是否需要强制编码为 JWT(Token Exchange 场景)
    boolean forceEncodeAsJwt = result.getRequestedTokenType() != null
        && result.getRequestedTokenType() == OAuth20TokenExchangeTypes.JWT;

    var cipher = new CustomOAuth20JwtAccessTokenEncoder(
        ctx, registeredService, token, service, issuer,
        forceEncodeAsJwt, userInfoService);

    // Token Exchange 场景下设置目标受众
    if (result.getGrantType() == OAuth20GrantTypes.TOKEN_EXCHANGE
            && result.getRequestedTokenType() == OAuth20TokenExchangeTypes.JWT) {
        String audience = Optional.ofNullable(result.getTokenExchangeAudience())
            .or(() -> Optional.ofNullable(result.getTokenExchangeResource())
                .map(Service::getId))
            .orElse("");
        if (!audience.isEmpty()) {
            cipher.setTokenAudience(audience);
        }
    }

    return cipher.encode(token.getId(), new Object[]{token, result});
}

Token Exchange 场景下,forceEncodeAsJwt 参数会被设置为 true,确保即使全局配置未启用 JWT AccessToken,在 Token Exchange 请求 JWT 类型令牌时也能正确生成 JWT。同时,通过 setTokenAudience() 方法设置目标受众,确保生成的 JWT 包含正确的 aud 声明。

4.6 RefreshToken JWT 编码

CAS 7.3 不仅支持 AccessToken 的 JWT 编码,还支持 RefreshToken 的 JWT 编码。这是 CAS 6.6 所不具备的能力。在 shouldEncodeAsJwt() 方法中,RefreshToken 的 JWT 编码判断逻辑如下:

java
protected boolean shouldEncodeAsJwt() {
    OAuthRegisteredService oauthRegisteredService =
        (OAuthRegisteredService) registeredService;
    OAuthProperties oauthProps = configurationContext.getCasProperties()
        .getAuthn().getOauth();

    // 条件一:DPoP 请求强制 JWT
    boolean dpopRequest = token.getAuthentication()
        .containsAttribute(OAuth20Constants.DPOP);

    // 条件二:AccessToken 是否需要 JWT 编码
    boolean accessTokenAsJwt = token instanceof OAuth20AccessToken
        && (oauthProps.getAccessToken().isCreateAsJwt()
            || oauthRegisteredService.isJwtAccessToken());

    // 条件三:RefreshToken 是否需要 JWT 编码
    boolean refreshTokenAsJwt = token instanceof OAuth20RefreshToken
        && (oauthProps.getRefreshToken().isCreateAsJwt()
            || oauthRegisteredService.isJwtRefreshToken());

    return this.forceEncodeAsJwt
        || accessTokenAsJwt
        || refreshTokenAsJwt
        || dpopRequest;
}

RefreshToken 的 JWT 编码需要额外配置:

yaml
cas:
  authn:
    oauth:
      refresh-token:
        create-as-jwt: true    # 启用 RefreshToken 的 JWT 编码

4.7 shouldEncodeAsJwt 三条件判断

shouldEncodeAsJwt() 方法是 CAS 7.3 JWT 编码器的决策核心,它通过四个条件的组合判断来确定是否需要将令牌编码为 JWT:

shouldEncodeAsJwt() 决策流程:

                    shouldEncodeAsJwt()
                           |
              +------------+------------+
              |                         |
        forceEncodeAsJwt?         false
              |                         |
         true |                    +----+----+
              |                    |         |
              |              token instanceof    token instanceof
              |              OAuth20AccessToken  OAuth20RefreshToken
              |                    |         |
              |              global config   global config
              |              or service      or service
              |              config?         config?
              |                    |         |
              +-------> return true <---------+
                           |
                      return false

详细条件分解:
  1. forceEncodeAsJwt = true        -> 强制 JWT(Token Exchange 场景)
  2. accessTokenAsJwt = true        -> AccessToken 配置或服务级配置启用
  3. refreshTokenAsJwt = true       -> RefreshToken 配置或服务级配置启用
  4. dpopRequest = true             -> DPoP 请求强制 JWT

  任一条件为 true 即返回 true

图 4-3:shouldEncodeAsJwt 决策流程图

这种多条件判断的设计体现了 CAS 7.3 的灵活性:

  • 全局配置 + 服务级配置: 既支持全局统一的 JWT 策略,也支持每个 OAuth 客户端(RegisteredService)独立配置。
  • 令牌类型区分: AccessToken 和 RefreshToken 可以独立配置是否使用 JWT 格式。
  • 安全特性驱动: DPoP 和 Token Exchange 等安全特性会自动触发 JWT 编码,无需额外配置。

第五章 Spring Boot 3.x Bean 注册模式

导读: 本章分析 CAS 7.3 基于 Spring Boot 3.x 的 Bean 注册模式变化,包括从 @ConditionalOnMissingBean 到 @Primary 的转变、ObjectProvider 延迟注入解决循环依赖的机制,以及 @RefreshScope 动态刷新的演进。

5.1 @ConditionalOnMissingBean 到 @Primary

CAS 6.6 和 CAS 7.3 在 Bean 注册策略上存在显著差异。CAS 6.6 使用 @ConditionalOnMissingBean,而 CAS 7.3 使用 @Primary。这一变化背后反映了 Spring Boot 版本升级带来的架构调整。

CAS 6.6 的 @ConditionalOnMissingBean 模式:

java
// CAS 6.6
@Bean
@RefreshScope(proxyMode = ScopedProxyMode.DEFAULT)
@ConditionalOnMissingBean(name = "accessTokenResponseGenerator")
public OAuth20AccessTokenResponseGenerator accessTokenResponseGenerator(...) {
    return new CustomOAuth20AccessTokenResponseGenerator(...);
}

@ConditionalOnMissingBean 的工作原理是:只有当 Spring 容器中不存在名为 accessTokenResponseGenerator 的 Bean 时,才注册自定义 Bean。这意味着如果 CAS 的自动配置先于自定义配置执行,自定义 Bean 将不会被注册。

这种模式在 CAS 6.6 中存在一个潜在问题:Bean 的注册顺序取决于 @Configuration 类的加载顺序,而加载顺序受到 @Order@AutoConfigureBefore/@AutoConfigureAfter 等注解的影响。如果 CAS 内部的自动配置类先于自定义配置类加载,默认的 Bean 已经注册,自定义的 Bean 就会被跳过。

CAS 7.3 的 @Primary 模式:

java
// CAS 7.3
@Bean("accessTokenResponseGenerator")
@Primary
public OAuth20AccessTokenResponseGenerator accessTokenResponseGenerator(
        @Qualifier("oauth20ConfigurationContext")
        final ObjectProvider<OAuth20ConfigurationContext> ctx) {
    return new CustomOAuth20AccessTokenResponseGenerator(ctx, userInfoService);
}

@Primary 的工作原理完全不同:它告诉 Spring,当容器中存在多个同类型的 Bean 时,优先使用标记了 @Primary 的 Bean。这意味着 CAS 的默认 Bean 和自定义 Bean 可以同时存在于容器中,但在注入时会优先选择自定义 Bean。

两种模式的对比如下:

对比维度@ConditionalOnMissingBean@Primary
注册策略条件注册(不存在才注册)优先选择(存在多个时优先)
Bean 数量容器中最多一个容器中可以有多个
顺序依赖依赖配置类加载顺序不依赖加载顺序
冲突处理可能被默认 Bean 抢先注册自定义 Bean 始终优先
Spring Boot 3.x 兼容性可用但不够可靠推荐使用
配合 allow-bean-definition-overriding不需要需要(spring.main.allow-bean-definition-overriding: true

需要注意的是,CAS 7.3 使用 @Primary 模式需要在 application.yml 中启用 Bean 覆盖:

yaml
spring:
  main:
    allow-bean-definition-overriding: true

5.2 ObjectProvider 延迟注入解决循环依赖

CAS 7.3 的另一个重要变化是使用 ObjectProvider 进行延迟注入。在 CAS 6.6 中,构造方法直接接收具体的 Bean 类型:

java
// CAS 6.6 - 直接注入
public CustomOAuth20AccessTokenResponseGenerator(
        JwtBuilder accessTokenJwtBuilder,
        CasConfigurationProperties casProperties,
        OAuth20ProfileScopeToAttributesFilter profileScopeToAttributesFilter,
        UserInfoService userInfoService) {
    // ...
}

而在 CAS 7.3 中,构造方法接收的是 ObjectProvider<OAuth20ConfigurationContext>

java
// CAS 7.3 - 延迟注入
public CustomOAuth20AccessTokenResponseGenerator(
        ObjectProvider<OAuth20ConfigurationContext> configurationContext,
        UserInfoService userInfoService) {
    this.configurationContext = configurationContext;
    this.userInfoService = userInfoService;
}

ObjectProvider 是 Spring 提供的延迟注入机制。它不在构造方法执行时立即解析 Bean,而是在实际调用 getObject() 方法时才从容器中获取 Bean 实例。这种机制在解决循环依赖问题中发挥了关键作用。

循环依赖的产生场景:

在 CAS 7.3 中,OAuth20ConfigurationContext 的创建依赖于多个 Bean(如 TicketRegistryServicesManager 等),而这些 Bean 的创建又可能间接依赖于 OAuth20AccessTokenResponseGenerator。如果直接注入 OAuth20ConfigurationContext,就会形成循环依赖:

CustomOAuth20AccessTokenResponseGenerator
    --> OAuth20ConfigurationContext (构造注入)
        --> TicketRegistry
            --> ... (其他 Bean)
                --> OAuth20AccessTokenResponseGenerator (循环!)

使用 ObjectProvider 打破循环:

CustomOAuth20AccessTokenResponseGenerator
    --> ObjectProvider<OAuth20ConfigurationContext> (延迟注入)
        [在 encodeOAuthToken() 方法中调用时才解析]
            --> OAuth20ConfigurationContext (此时所有 Bean 已创建完毕)

图 5-1:ObjectProvider 解决循环依赖的原理

在实际使用中,ObjectProvider.getObject() 的调用时机是在 encodeOAuthToken() 方法中:

java
protected String encodeOAuthToken(
        OAuth20Token token,
        OAuth20AccessTokenResponseResult result) {
    // 此时才真正从容器中获取 OAuth20ConfigurationContext
    var ctx = configurationContext.getObject();
    // ... 使用 ctx 进行编码
}

由于 encodeOAuthToken() 是在 HTTP 请求处理过程中被调用的,此时 Spring 容器已经完成了所有 Bean 的初始化,因此不存在循环依赖的问题。

5.3 @RefreshScope 动态刷新

CAS 6.6 使用了 @RefreshScope 注解来支持配置的动态刷新。CAS 7.3 虽然在 CustomJwtAccessTokenConfiguration 中去掉了 @RefreshScope,但这一机制仍然值得深入理解。

@RefreshScope 的工作原理:

@RefreshScope 是 Spring Cloud 提供的注解,它创建了一个特殊的 Bean 作用域。标记了该注解的 Bean 会被缓存在一个特殊的 Scope 缓存中。当 /actuator/refresh 端点被调用时,这个缓存会被清空,下次访问该 Bean 时会重新创建。

@RefreshScope 生命周期:

  正常请求:
    1. 请求到达 -> 检查 RefreshScope 缓存
    2. 缓存命中 -> 返回缓存的 Bean 实例
    3. 使用 Bean 处理请求

  刷新配置后:
    1. POST /actuator/refresh -> 清空 RefreshScope 缓存
    2. 下次请求到达 -> 缓存未命中
    3. 重新创建 Bean 实例(使用最新配置)
    4. 将新实例放入缓存
    5. 使用新 Bean 处理请求

图 5-2:@RefreshScope 动态刷新原理

CAS 7.3 去掉 @RefreshScope 的原因可能包括:

  1. Spring Boot 3.x 的变化: Spring Boot 3.x 对 @RefreshScope 的处理方式有所调整,在某些场景下可能导致意外行为。
  2. 配置集中化: CAS 7.3 通过 OAuth20ConfigurationContext 统一管理配置,配置的动态刷新可以通过上下文对象的重建来实现,而不需要标记整个 Bean 为 @RefreshScope
  3. 性能考虑: @RefreshScope 使用 CGLIB 代理,会增加启动时间和内存开销。在 CAS 7.3 的组合式设计中,编码器是按请求创建的(在 encodeOAuthToken() 中 new),不需要 @RefreshScope 来实现配置更新。

第六章 用户资料视图渲染器自定义

导读: 本章分析 CAS 7.3 中新增的 UserProfile 视图渲染器自定义需求,探讨 userInfo 对象被转换为数组问题的根源,以及通过自定义 Renderer 解决该问题的完整方案。

6.1 userInfo 对象转数组问题的根源

在 CAS 7.3 中,当启用 JWT AccessToken 并注入自定义属性后,通过 /oauth2.0/profile/oidc/profile 端点获取用户信息时,可能会遇到一个令人困惑的问题:userInfo 对象被转换成了数组。

问题复现:

正常情况下,用户信息端点应该返回如下格式的响应:

json
{
  "id": "zhangsan",
  "attributes": {
    "userId": ["10086"],
    "userName": ["张三"],
    "userNickName": ["小张"],
    "userTelphone": ["138****1234"],
    "userEmail": ["zhangsan@example.com"]
  },
  "userInfo": {
    "id": 10086,
    "name": "张三",
    "nickname": "小张",
    "telphone": "138****1234",
    "email": "zhangsan@example.com"
  }
}

但在某些情况下,userInfo 会被错误地转换为数组:

json
{
  "id": "zhangsan",
  "attributes": {
    "userId": ["10086"],
    "userName": ["张三"]
  },
  "userInfo": [
    {
      "id": 10086,
      "name": "张三",
      "nickname": "小张"
    }
  ]
}

问题根源分析:

这个问题的根源在于 CAS 默认的 OAuth20UserProfileViewRenderer 的属性处理逻辑。CAS 框架中所有 Principal 属性值都以 List<Object> 形式存储。当渲染用户信息视图时,默认的 Renderer 会对所有属性值执行统一的转换逻辑:

  1. 如果属性值是 Map 类型,保持原样
  2. 如果属性值是 Collection 类型,转换为 List
  3. 如果属性值是单元素 List,提取单个元素
  4. 如果属性值是多元素 List,保持为数组

问题在于,当 userInfo 被存储为一个包含单个 MapList 时(即 List.of(userInfoMap)),默认的 Renderer 会将其识别为单元素集合,尝试提取单个元素。但由于 userInfo 的值本身是一个复杂对象(Map),Renderer 的类型判断逻辑可能将其误判,导致最终输出为数组。

默认 Renderer 的属性值处理流程:

  属性值 (Object)
       |
       v
  instanceof Map?
    是 -> 保持为 Map
    否 |
       v
  转换为 Collection
       |
       v
  Collection.size() == 1?
    是 -> 提取单个元素
    否 -> 保持为 Collection

  问题场景:
  userInfo = List.of(Map{id:10086, name:"张三", ...})
       |
       v
  instanceof Map? -> 否(它是 List)
       |
       v
  转换为 Collection -> [Map{...}]
       |
       v
  Collection.size() == 1? -> 是
       |
       v
  提取单个元素 -> Map{...}
  但在某些边界情况下,提取逻辑可能异常,导致输出为 [Map{...}]

图 6-1:默认 Renderer 属性值处理流程

6.2 CustomOAuth20UserProfileViewRenderer

为了解决上述问题,CAS 7.3 中需要自定义 OAuth20UserProfileViewRenderer。自定义渲染器的核心策略是:直接将模型作为响应体返回,不做任何属性值的类型转换。

java
public class CustomOAuth20UserProfileViewRenderer
        implements OAuth20UserProfileViewRenderer {

    private final ServicesManager servicesManager;
    private final CasConfigurationProperties casProperties;
    private final AttributeDefinitionStore attributeDefinitionStore;

    public CustomOAuth20UserProfileViewRenderer(
            ServicesManager servicesManager,
            CasConfigurationProperties casProperties,
            AttributeDefinitionStore attributeDefinitionStore) {
        this.servicesManager = servicesManager;
        this.casProperties = casProperties;
        this.attributeDefinitionStore = attributeDefinitionStore;
    }

    @Override
    public ResponseEntity render(
            Map<String, Object> model,
            OAuth20AccessToken accessToken,
            HttpServletResponse response) {
        try {
            // 直接将模型作为响应体返回,不做任何转换
            // 这样可以确保 userInfo 保持为一个对象而不是被转换为数组
            return ResponseEntity.ok(model);
        } catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.ok(Map.of());
        }
    }
}

这个自定义渲染器的实现非常简洁,但它的作用至关重要。通过跳过默认的属性值转换逻辑,确保了 userInfo 对象的原始结构得以保持。

需要注意的是,这种"不做任何转换"的策略虽然解决了 userInfo 对象转数组的问题,但同时也放弃了默认 Renderer 提供的其他功能(如属性定义处理、单值提取等)。如果业务上需要这些功能,可以在 render() 方法中添加自定义的转换逻辑。

6.3 CustomOidcUserProfileViewRenderer

OIDC 场景下的用户信息渲染比 OAuth 2.0 更复杂,因为 OIDC 规范要求支持签名和加密的 UserInfo 响应。CustomOidcUserProfileViewRenderer 继承了 OidcUserProfileViewRenderer,在保持原有签名加密能力的同时,解决了 userInfo 对象转数组的问题。

java
public class CustomOidcUserProfileViewRenderer
        extends OidcUserProfileViewRenderer {

    private static final String USER_INFO_KEY = "userInfo";

    private final OAuth20TokenSigningAndEncryptionService signingService;
    private final AttributeDefinitionStore attrStore;

    @Override
    protected ResponseEntity renderProfileForModel(
            final Map<String, Object> userProfile,
            final OAuth20AccessToken accessToken,
            final HttpServletResponse response) {

        var service = OAuth20Utils.getRegisteredOAuthServiceByClientId(
            servicesManager, accessToken.getClientId());

        if (service instanceof OidcRegisteredService oidcRegisteredService) {
            return FunctionUtils.doAndHandle(() -> {
                if (signingService.shouldSignToken(oidcRegisteredService)
                    || signingService.shouldEncryptToken(oidcRegisteredService)) {
                    // 签名加密场景
                    return signAndEncryptUserProfileClaims(
                        userProfile, response, oidcRegisteredService);
                }
                // 普通场景
                return buildPlainUserProfileClaims(
                    userProfile, response, oidcRegisteredService);
            }, e -> ResponseEntity.badRequest()
                .body("Unable to produce user profile claims")).get();
        }

        // 非 OIDC 服务,直接返回处理后的 Map
        var result = processUserProfile(userProfile);
        return new ResponseEntity<>(result, HttpStatus.OK);
    }
}

processUserProfile() 方法是解决 userInfo 对象转数组问题的核心:

java
private Map<String, Object> processUserProfile(
        final Map<String, Object> userProfile) {
    var result = new LinkedHashMap<String, Object>();
    userProfile.entrySet().stream()
        .filter(entry -> !entry.getKey()
            .startsWith(CentralAuthenticationService.NAMESPACE))
        .forEach(entry -> {
            if (USER_INFO_KEY.equals(entry.getKey())) {
                // 关键:userInfo 保持为对象,不进行转换
                result.put(entry.getKey(), entry.getValue());
            } else if (MODEL_ATTRIBUTE_ATTRIBUTES.equals(entry.getKey())) {
                // attributes 中的每个属性值进行智能转换
                var attributes = (Map<String, Object>) entry.getValue();
                var newAttributes = new HashMap<String, Object>();
                attributes.forEach((attrName, attrValue) ->
                    newAttributes.put(attrName,
                        determineAttributeValue(attrName, attrValue)));
                result.put(entry.getKey(), newAttributes);
            } else {
                // 其他字段进行智能转换
                result.put(entry.getKey(),
                    determineAttributeValue(entry.getKey(), entry.getValue()));
            }
        });
    return result;
}

6.4 签名加密场景下的属性保持

在 OIDC 场景下,当客户端要求对 UserInfo 响应进行签名或加密时,CustomOidcUserProfileViewRenderer 需要确保在签名加密过程中 userInfo 对象的结构不被破坏。

java
@Override
protected ResponseEntity<String> signAndEncryptUserProfileClaims(
        final Map<String, Object> userProfile,
        final HttpServletResponse response,
        final OidcRegisteredService registeredService) throws Throwable {

    // 1. 先处理用户资料(确保 userInfo 保持为对象)
    var processedProfile = processUserProfile(userProfile);

    // 2. 构建 JWT Claims
    var claims = new JwtClaims();
    processedProfile.forEach(claims::setClaim);

    // 3. 设置 OIDC 标准声明
    claims.setAudience(registeredService.getClientId());
    claims.setIssuedAt(NumericDate.now());
    claims.setJwtId(UUID.randomUUID().toString());
    claims.setIssuer(signingService.resolveIssuer(
        Optional.of(registeredService)));

    // 4. 使用 jose4j 进行签名和加密
    var result = signingService.encode(registeredService, claims);

    // 5. 设置响应内容类型为 JWT
    response.setContentType(OidcConstants.CONTENT_TYPE_JWT);
    return buildResponseEntity(result, response, registeredService);
}

签名加密场景下的处理流程如下:

OIDC UserInfo 签名加密流程:

  1. 客户端请求 /oidc/profile
     Header: Authorization: Bearer <access_token>

  2. CAS 检查 RegisteredService 配置
     |-- userInfoSigningEnabled = true
     |-- userInfoEncryptionEnabled = true

  3. CustomOidcUserProfileViewRenderer.renderProfileForModel()
     |-- processUserProfile() -> 确保 userInfo 保持为对象
     |-- 构建 JwtClaims
     |-- signingService.encode() -> 签名 + 加密
     |-- 返回 application/jwt 格式的响应

  4. 客户端收到 JWT 格式的 UserInfo
     Content-Type: application/jwt
     Body: eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOi...

  5. 客户端解析 JWT
     |-- 验证签名
     |-- 解密内容
     |-- 获取 userInfo 对象(保持原始结构)

图 6-2:OIDC UserInfo 签名加密流程

determineAttributeValue() 方法提供了智能的属性值转换逻辑,它能够根据属性定义(AttributeDefinition)来决定如何处理属性值:

java
protected Object determineAttributeValue(
        final String name, final Object attrValue) {
    // userInfo 始终保持为对象
    if (USER_INFO_KEY.equals(name)) {
        return attrValue;
    }
    // Map 类型保持原样
    if (attrValue instanceof Map) {
        return attrValue;
    }
    // 其他类型根据属性定义进行转换
    var values = CollectionUtils.toCollection(attrValue, ArrayList.class);
    var result = attrStore.locateAttributeDefinition(
        name, OAuth20AttributeDefinition.class);
    return result.map(defn -> defn.toAttributeValue(values))
        .orElseGet(() -> values.size() == 1
            ? values.getFirst() : values);
}

第七章 配置体系与密钥管理

导读: 本章对比分析 CAS 三个版本在配置命名风格、密钥管理、Session 复制 Cookie 加密以及监控指标导出等方面的差异,帮助读者理解 CAS 配置体系的演进脉络。

7.1 camelCase 到 kebab-case 迁移

CAS 从 7.x 版本开始,配置属性的命名风格从传统的 camelCase 迁移到了 Spring Boot 推荐的 kebab-case。这一变化虽然看似只是命名风格的调整,但实际上对配置的兼容性和可读性都产生了深远影响。

命名风格对比:

配置项CAS 5.3 / 6.6 (camelCase)CAS 7.3 (kebab-case)
JWT AccessToken 启用createAsJwtcreate-as-jwt
Claims 包含include-claims-in-jwt
Token 有效期timeToKillInSecondstime-to-kill-in-seconds
最大有效期maxTimeToLiveInSecondsmax-time-to-live-in-seconds
授权码使用次数numberOfUsesnumber-of-uses
服务头要求requireServiceHeaderrequire-service-header
LDAP 搜索过滤器searchFiltersearch-filter
LDAP 基础 DNbaseDnbase-dn
绑定凭据bindCredentialbind-credential
动态客户端注册dynamicClientRegistrationModedynamic-client-registration-mode
JWKS 文件路径jwksFilejwks-file
加密密钥N/Acrypto.encryption.key
签名密钥N/Acrypto.signing.key

Spring Boot 的 relaxed binding 机制:

需要注意的是,Spring Boot 的 relaxed binding 机制允许在 application.yml 中使用不同的命名风格来引用同一个配置属性。例如,以下三种写法在 Spring Boot 中是等价的:

yaml
# 方式一:kebab-case(推荐)
cas.authn.oauth.access-token.create-as-jwt: true

# 方式二:camelCase
cas.authn.oauth.accessToken.createAsJwt: true

# 方式三:underscore_case
cas.authn.oauth.access_token.create_as_jwt: true

但在 CAS 7.3 中,官方文档和示例统一使用 kebab-case,建议开发者遵循这一约定。使用 kebab-case 的好处包括:

  1. 可读性更好: time-to-kill-in-secondstimeToKillInSeconds 更容易阅读
  2. 与 Spring Boot 生态一致: Spring Boot 3.x 的所有官方配置都使用 kebab-case
  3. 避免歧义: kebab-case 中单词之间有明确的分隔符,不会产生歧义

7.2 AccessToken 签名加密密钥配置

CAS 7.3 新增了 AccessToken 的签名和加密密钥配置。当启用 JWT AccessToken 后,JWT 需要使用密钥进行签名(确保令牌不被篡改)和加密(确保令牌内容不被泄露)。

yaml
cas:
  authn:
    oauth:
      access-token:
        create-as-jwt: true
        include-claims-in-jwt: true
        crypto:
          encryption:
            # AES-256 加密密钥(Base64 编码)
            key: sGI7Yc41aeD2CRkTnV801D8NyIW_ZFZwf8q9Q21f68VAAN-NsPewdtsUDV-HcLSw2zzMXBp2lgYNLY6cdqNEZg
          signing:
            # HMAC-SHA256 签名密钥(Base64 编码)
            key: 1KUkMoUiws5Da5A82VOyA3KmrnZciOFYqQiySQd3LnJPMLajO6D18d0aauyMruAgkM4sFiBPV_vTjdxPz8iJsw

密钥生成方式:

这些密钥需要使用 Base64 编码。可以使用以下命令生成示例密钥:

bash
# 生成 AES-256 加密密钥(256 位 = 32 字节 = 43 个 Base64 字符)
openssl rand -base64 32

# 生成 HMAC-SHA256 签名密钥(256 位 = 32 字节 = 43 个 Base64 字符)
openssl rand -base64 32

密钥管理最佳实践:

  1. 生产环境密钥管理: 不要将密钥硬编码在配置文件中。建议使用 Spring Cloud Config Server、Vault 或 KMS 等密钥管理服务。
  2. 密钥轮换: 定期轮换签名和加密密钥,降低密钥泄露的风险。
  3. 环境隔离: 开发、测试、生产环境使用不同的密钥。
  4. 密钥长度: 加密密钥至少 256 位(AES-256),签名密钥至少 256 位(HMAC-SHA256)。

CAS 6.6 vs CAS 7.3 的密钥配置差异:

CAS 6.6 中,JWT 的签名密钥通常通过 JWKS(JSON Web Key Set)文件来管理,配置方式如下:

yaml
# CAS 6.6
cas:
  authn:
    oidc:
      jwks:
        fileSystem:
          jwksFile: ${user.dir}/keystore.jwks

CAS 7.3 除了支持 JWKS 文件外,还新增了直接通过配置属性设置密钥的方式,简化了配置流程。两种方式可以并存,但直接配置密钥的方式更适合中小规模部署。

CAS 7.3 新增了 Session Replication Cookie 的加密配置。当 CAS 部署在集群环境中时,Session 信息需要通过 Cookie 在节点间复制。为了防止 Session 信息被篡改或泄露,CAS 7.3 要求对 Cookie 进行签名和加密。

yaml
cas:
  authn:
    oauth:
      session-replication:
        cookie:
          crypto:
            signing:
              # Cookie 签名密钥
              key: _HsYXfPLzasX3NBneWJWUo1KeU8ijZCc4odfLCxZLVdj-VTU0bk-me8oxd6ngBRSxqGCHD6VFIhH4k9xdyo2OQ
            encryption:
              # Cookie 加密密钥
              key: roJYfamrq-BKnnvBROP2Lai1Z5aoJz3dEeFQOZzDQNscjrSi9yLrcIpUyVhPep894xuIKGkNRXpiIyuE9zXbmA

这个配置在 CAS 6.6 中是不存在的。CAS 6.6 的 Session 管理相对简单,主要通过 Redis 或其他分布式缓存来实现 Session 共享,Cookie 中只存储 Session ID。CAS 7.3 引入了更完善的 Session Replication 机制,支持将 Session 数据直接编码到 Cookie 中,从而减少对外部存储的依赖。

7.4 监控指标导出全量禁用

CAS 7.3 基于 Spring Boot 3.x,默认集成了 Micrometer 监控框架,支持多种监控系统的指标导出(如 Prometheus、Datadog、New Relic、OTLP 等)。在生产环境中,如果不需要这些监控功能,建议全部禁用以减少资源消耗。

CAS 7.3 的 application.properties 中包含了全量禁用监控指标导出的配置:

properties
# 禁用所有监控指标导出
management.datadog.metrics.export.enabled=false
management.newrelic.metrics.export.enabled=false
management.prometheus.metrics.export.enabled=false
management.otlp.metrics.export.enabled=false
management.wavefront.metrics.export.enabled=false
management.graphite.metrics.export.enabled=false
management.atlas.metrics.export.enabled=false
management.elastic.metrics.export.enabled=false
management.influx.metrics.export.enabled=false
management.ganglia.metrics.export.enabled=false

CAS 三个版本的监控配置对比:

yaml
# CAS 5.3 - Spring Boot 1.x / 2.x 风格
endpoints:
  enabled: false
  sensitive: true
management:
  security:
    enabled: true
    roles: ACTUATOR,ADMIN

# CAS 6.6 - Spring Boot 2.x 风格
management:
  endpoints:
    enabled-by-default: false
    web:
      exposure:
        include: health,info
        exclude: refresh,shutdown
      base-path: /status
  endpoint:
    health:
      enabled: true
      show-details: when_authorized

# CAS 7.3 - Spring Boot 3.x 风格
management:
  endpoints:
    web:
      exposure:
        include: health,info
        exclude: refresh,shutdown
      base-path: /status
  endpoint:
    health:
      enabled: true
      show-details: when_authorized
# + 全量禁用第三方指标导出(application.properties)

从三个版本的监控配置演进可以看出,CAS 的监控配置越来越精细化和规范化:

  1. 端点粒度控制: 从全局 enabled: false 发展到每个端点独立控制
  2. Web 暴露控制: 明确区分 Web 暴露的端点和 JMX 暴露的端点
  3. 第三方集成管理: CAS 7.3 新增了对各种第三方监控系统的独立开关
  4. 安全加固: 始终保持对敏感端点的访问控制

总结与展望

核心要点回顾

本文基于 cas-overlay 项目 5.3、6.6、7.3 三个版本的源码,深入分析了 CAS JWT AccessToken 自定义属性注入的三代架构演进。以下是各章的核心要点:

第一章 - JWT AccessToken 在 CAS 中的定位与演进: 不透明 Token 和 JWT Token 各有优劣,CAS 从 5.3 的不支持、到 6.6 的继承式支持、再到 7.3 的组合式支持,体现了框架对现代 OAuth 2.0 安全实践的不断跟进。

第二章 - CAS 5.3 不透明 Token 时代: 5.3 版本由于框架定位和 API 成熟度的限制,不支持 JWT AccessToken。开发者需要通过 UserInfo 端点或升级版本来满足 JWT 需求。

第三章 - CAS 6.6 继承式 JWT 编码器: 6.6 版本通过 createAsJwt 配置启用 JWT AccessToken,开发者需要继承 OAuth20JwtAccessTokenEncoder 并使用 @SuperBuilder 来注入自定义属性。Bean 注册使用 @ConditionalOnMissingBean + @RefreshScope 模式。

第四章 - CAS 7.3 组合式 JWT 编码器完全重构: 7.3 版本实现了从继承到组合的根本性架构转变。编码器实现了 EncodableCipher<String, String> 接口,通过 OAuth20ConfigurationContext 获取所有基础设施。新增了 DPoP、X.509 证书摘要、Token Exchange、RefreshToken JWT 等高级特性支持。

第五章 - Spring Boot 3.x Bean 注册模式:@ConditionalOnMissingBean@Primary 的转变,ObjectProvider 延迟注入解决循环依赖,以及 @RefreshScope 动态刷新的演进。

第六章 - 用户资料视图渲染器自定义: CAS 7.3 中 userInfo 对象被转换为数组的问题根源分析,以及通过自定义 OAuth20UserProfileViewRendererOidcUserProfileViewRenderer 解决该问题的完整方案。

第七章 - 配置体系与密钥管理:camelCasekebab-case 的命名风格迁移,AccessToken 签名加密密钥配置,Session Replication Cookie 加密,以及监控指标导出的全量禁用。

未来展望

CAS 框架仍在持续演进中。基于当前的分析,我们可以预见以下几个发展方向:

  1. 配置体系的进一步统一: CAS 可能会完全迁移到 kebab-case,并移除对 camelCase 的兼容支持。建议新项目统一使用 kebab-case 命名风格。

  2. 组合式设计的深化: CAS 7.3 的组合式设计是一个良好的开端。未来可能会有更多的 CAS 内部组件采用组合式设计,提供更灵活的扩展点。

  3. 无密钥部署模式: 随着云原生技术的发展,CAS 可能会提供更完善的无密钥(Zero-Key)部署模式,支持自动生成和轮换密钥。

  4. 更丰富的 Token 类型支持: 除了 JWT 之外,CAS 可能会支持更多类型的令牌格式(如 PASETO、SD-JWT 等),以适应不同的安全需求。

  5. 可观测性增强: CAS 可能会进一步强化与 OpenTelemetry 等可观测性标准的集成,提供更细粒度的监控和追踪能力。

对于正在进行 CAS 版本升级或新项目选型的开发者,本文的分析可以提供以下建议:

  • 新项目: 优先选择 CAS 7.3,其组合式设计和完善的配置体系能够更好地满足现代微服务架构的需求。
  • 升级项目: 从 6.6 升级到 7.3 时,需要重点关注编码器的重写(从继承式到组合式)、配置命名风格的变更(从 camelCase 到 kebab-case)、以及新增的 UserProfile 视图渲染器。
  • 遗留系统: 如果暂时无法升级,CAS 6.6 的继承式方案仍然是一个稳定可靠的选择。

版权声明: 本文为必码(bima.cc)原创技术文章,仅供学习交流。

本文内容基于实际项目源码解析整理,代码示例均为教学简化版本,仅供学习参考。

如需获取完整项目代码或技术支持,请访问 bima.cc