Skip to content

CAS OIDC 用户资料视图渲染器自定义:解决 UserInfo 对象序列化与签名加密场景下的属性保持难题

作者: 必码 | bima.cc


前言

在现代企业级身份认证体系中,OpenID Connect(OIDC)协议已经成为连接用户身份信息与业务应用的核心桥梁。作为 OIDC 协议中至关重要的组成部分,UserInfo 端点承担着向客户端提供已认证用户详细资料信息的职责。当客户端通过授权码流程获取到 Access Token 后,便可以调用 UserInfo 端点来获取用户的详细信息——包括用户名、昵称、手机号、邮箱等业务关键字段。这个端点的响应质量直接决定了下游业务系统能否正确地识别和使用用户身份信息。

然而,在基于 Apereo CAS 构建 OIDC 授权服务器的实际项目中,我们遇到了一个令人困惑的问题:CAS 默认的用户资料视图渲染器会将 UserInfo 对象中的单值属性强制转换为数组形式返回。 例如,我们期望返回的 {"userName": "zhangsan"} 会被渲染为 {"userName": ["zhangsan"]}。这种看似微小的格式差异,在签名加密场景下会引发更为严重的连锁问题——嵌套的 UserInfo 对象结构在 JWT Claims 序列化过程中可能被完全"展平"或"变形",导致客户端无法正确解析用户资料。

这个问题并非偶然。它根植于 CAS 框架对属性值(Attribute)的统一处理逻辑——CAS 内部将所有用户属性视为多值集合(Collection),在视图渲染阶段通过 determineAttributeValue 方法将集合转换为 JSON 友好的格式。这种设计在处理标准 OIDC Claims(如 subnameemail)时工作良好,但当我们在用户资料中嵌入自定义的嵌套对象结构时,就会产生意想不到的副作用。

本文基于我们在 cas-overlay 项目中 CAS 6.6 和 CAS 7.3 两个版本的实际开发经验,系统性地剖析 UserInfo 对象序列化问题的根源,并给出完整的解决方案。文章将从 OIDC 协议的 UserInfo 端点架构讲起,逐步深入到 CAS 默认渲染器的内部实现机制,然后分别介绍在 CAS 6.6 和 CAS 7.3 版本中的自定义视图渲染器实现方案,最后深入分析签名加密场景下的特殊处理逻辑。

读者受众说明: 本文面向正在使用或计划使用 Apereo CAS 作为 OIDC/OAuth2.0 授权服务器的后端开发工程师、系统架构师,以及负责身份认证基础设施运维的技术负责人。阅读本文需要具备以下基础知识:熟悉 OAuth 2.0 和 OIDC 协议基本概念、了解 Spring Boot 和 Spring Framework 的 Bean 注册机制、具备 Java 反射和泛型编程的基本能力。如果你正在为 CAS 的 UserInfo 端点返回格式问题而苦恼,或者需要在签名加密场景下保持自定义属性结构的完整性,那么本文将为你提供一套经过生产验证的完整解决方案。


第一章 OAuth2.0/OIDC 用户资料端点架构

1.1 UserInfo 端点在 OIDC 协议中的位置

要理解 UserInfo 端点的重要性,首先需要将其放在 OIDC 协议的整体架构中审视。OpenID Connect 1.0 协议在 OAuth 2.0 的基础上,增加了一个身份认证层(Identity Layer),使得客户端不仅可以获得访问资源的授权,还能获取用户的身份信息。整个 OIDC 协议定义了三个核心端点:

  1. Authorization Endpoint(授权端点): 处理用户的认证和授权确认,位于 /oauth2.0/authorize
  2. Token Endpoint(令牌端点): 处理授权码到令牌的交换,位于 /oauth2.0/accessToken
  3. UserInfo Endpoint(用户信息端点): 提供已认证用户的详细信息,位于 /oauth2.0/profile

在 CAS 的实现中,这三个端点分别对应不同的 Controller 和处理逻辑。其中,UserInfo 端点由 OAuth20UserProfileController(或其 OIDC 子类 OidcUserProfileController)负责处理。当客户端携带有效的 Access Token 请求该端点时,CAS 会执行以下核心流程:

┌──────────────┐     ┌──────────────────┐     ┌──────────────────────┐     ┌──────────────┐
│   Client     │     │   CAS Server     │     │  DataCreator         │     │  Database    │
│              │     │                  │     │                      │     │              │
│  GET /oauth2 │────>│  OAuth20User     │────>│  OAuth20UserProfile  │────>│  User Info   │
│  /profile    │     │  ProfileController│     │  DataCreator         │     │  Table       │
│  Bearer Token│     │                  │     │                      │     │              │
│              │<────│  ViewRenderer    │<────│  Returns Map         │<────│  Returns DTO │
│  JSON Response│    │  Renders Model   │     │  <String, Object>    │     │              │
└──────────────┘     └──────────────────┘     └──────────────────────┘     └──────────────┘

从协议规范的角度来看,OIDC 的 UserInfo 端点响应应当是一个标准的 JSON 对象,包含一组预定义的 Claims。这些 Claims 在 OIDC 规范(OpenID Connect Core 1.0)中有明确的定义:

Claim 名称类型描述
substringSubject Identifier,用户的唯一标识
namestring用户的全名
given_namestring用户的名字
family_namestring用户的姓氏
nicknamestring用户的昵称
emailstring用户的邮箱地址
phone_numberstring用户的电话号码
addressJSON object用户的地址信息

值得注意的是,规范中某些 Claim 的类型是 string(标量值),而某些 Claim(如 address)的类型是 JSON object(嵌套对象)。这意味着 UserInfo 端点的响应天然就包含嵌套对象结构。然而,CAS 的默认实现并没有很好地处理这种混合类型的场景。

在我们的实际项目中,用户资料不仅包含上述标准 Claims,还需要包含业务自定义字段,如 userId(内部数据库 ID)、userName(登录用户名)、userNickName(显示昵称)、userTelphone(手机号码)、userEmail(电子邮箱)。这些字段被组织为一个嵌套的 userInfo 对象,放置在 profile 响应的顶层。这种嵌套结构在 CAS 默认的渲染逻辑中会遭遇"展平"问题。

1.2 CAS 用户资料处理流程

CAS 的用户资料处理流程可以分解为三个核心阶段:数据创建阶段数据过滤阶段视图渲染阶段。每个阶段都有对应的扩展点,开发者可以在不同的阶段插入自定义逻辑。

阶段一:数据创建(Data Creation)

数据创建阶段由 OAuth20UserProfileDataCreator 接口负责。该接口定义了一个核心方法:

java
public interface OAuth20UserProfileDataCreator {
    Map<String, Object> createFrom(OAuth20AccessToken ticket) throws Throwable;
}

CAS 默认的实现会从 Access Token 中提取认证信息(Authentication),然后从 Principal 的属性(Attributes)中收集用户数据。在我们的自定义实现中,这个阶段负责从数据库中查询用户的详细信息,并组装为包含 userInfo 嵌套对象的 Map 结构。

在我们的 CAS 7.3 项目中,CustomOAuth20UserProfileDataCreatorcreateFrom 方法会创建如下结构的数据模型:

java
// 教学简化版本 - 展示核心数据结构
Map<String, Object> profile = new HashMap<>();

// 基本身份信息
profile.put("id", "zhangsan");
profile.put("client_id", "oauth2-clientId-bima-web");
profile.put("login_type", "OAUTH2.0");
profile.put("service", "https://app.bima.cc");

// 认证属性
Map<String, Object> attributes = new HashMap<>();
attributes.put("credentialType", "UsernamePasswordCredential");
attributes.put("id", "zhangsan");
profile.put("attributes", attributes);

// 自定义用户信息对象 - 这是一个嵌套的Map结构
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("userId", 1001L);
userInfo.put("userName", "zhangsan");
userInfo.put("userNickName", "张三");
userInfo.put("userTelphone", "13800138000");
userInfo.put("userEmail", "zhangsan@bima.cc");
profile.put("userInfo", userInfo);

这个数据模型的 JSON 表示如下:

json
{
  "id": "zhangsan",
  "client_id": "oauth2-clientId-bima-web",
  "login_type": "OAUTH2.0",
  "service": "https://app.bima.cc",
  "attributes": {
    "credentialType": "UsernamePasswordCredential",
    "id": "zhangsan"
  },
  "userInfo": {
    "userId": 1001,
    "userName": "zhangsan",
    "userNickName": "张三",
    "userTelphone": "13800138000",
    "userEmail": "zhangsan@bima.cc"
  }
}

阶段二:数据过滤(Scope Filtering)

数据过滤阶段由 OAuth20ProfileScopeToAttributesFilter 负责。该过滤器根据客户端请求的 scope(权限范围)来决定哪些属性可以被包含在最终的响应中。例如,当客户端请求 scope=profile email 时,只有与 profile 和 email 相关的属性才会被保留。

CAS 默认提供了 DefaultOAuth20ProfileScopeToAttributesFilter 实现,它维护了一组 scope 到属性名的映射规则。在我们的 CAS 6.6 配置中,直接使用了这个默认实现:

java
// CAS 6.6 配置中的 Scope 过滤器注册
@ConditionalOnMissingBean(name = "profileScopeToAttributesFilter")
@Bean
public OAuth20ProfileScopeToAttributesFilter profileScopeToAttributesFilter() {
    return new DefaultOAuth20ProfileScopeToAttributesFilter();
}

阶段三:视图渲染(View Rendering)

视图渲染阶段由 OAuth20UserProfileViewRenderer 接口负责。这是本文的核心关注点——渲染器接收经过创建和过滤后的数据模型(Map<String, Object>),并将其转换为最终的 HTTP 响应。CAS 默认的渲染器会在这个阶段对属性值进行类型转换,将单值属性包装为数组,这正是导致我们问题的根源所在。

1.3 视图渲染器的职责与扩展点

视图渲染器在 CAS 的用户资料处理流程中扮演着"最后一公里"的角色。它的职责看似简单——将 Map 转换为 HTTP 响应——但实际上承担了多项关键任务:

  1. 属性值格式化: 将内部的多值属性表示转换为 JSON 友好的格式。
  2. Scope 过滤执行: 确保只有授权范围内的属性被返回。
  3. 响应类型协商: 根据客户端的请求决定返回纯 JSON 还是 JWT 格式。
  4. 签名与加密: 在需要时对响应进行 JWS 签名或 JWE 加密。

CAS 提供了两个核心的视图渲染器接口:

OAuth20UserProfileViewRenderer (接口)
├── OAuth20DefaultUserProfileViewRenderer (默认OAuth2.0实现)
└── OidcUserProfileViewRenderer (OIDC扩展实现)
    └── 自定义实现: CustomOidcUserProfileViewRenderer

OAuth20UserProfileViewRenderer 接口定义了核心的 render 方法:

java
public interface OAuth20UserProfileViewRenderer {
    ResponseEntity render(Map<String, Object> model,
                          OAuth20AccessToken accessToken,
                          HttpServletResponse response);
}

对于 OIDC 场景,OidcUserProfileViewRenderer 在此基础上增加了对签名加密的支持,并提供了 renderProfileForModelbuildPlainUserProfileClaimssignAndEncryptUserProfileClaims 等扩展方法。

理解这些扩展点的存在和作用,是后续自定义渲染器实现的理论基础。在接下来的章节中,我们将详细分析默认渲染器的处理逻辑,揭示 UserInfo 对象被"展平"的内在机制。


第二章 UserInfo 对象转数组问题的根源

2.1 问题现象描述

在深入分析问题根源之前,让我们先完整地描述问题现象。当使用 CAS 默认的视图渲染器返回用户资料时,我们期望的响应格式和实际返回的格式之间存在显著差异。

期望的响应格式:

json
{
  "id": "zhangsan",
  "client_id": "oauth2-clientId-bima-web",
  "login_type": "OAUTH2.0",
  "attributes": {
    "credentialType": "UsernamePasswordCredential",
    "id": "zhangsan"
  },
  "userInfo": {
    "userId": 1001,
    "userName": "zhangsan",
    "userNickName": "张三",
    "userTelphone": "13800138000",
    "userEmail": "zhangsan@bima.cc"
  }
}

CAS 默认渲染器实际返回的格式:

json
{
  "id": ["zhangsan"],
  "client_id": ["oauth2-clientId-bima-web"],
  "login_type": ["OAUTH2.0"],
  "attributes": {
    "credentialType": ["UsernamePasswordCredential"],
    "id": ["zhangsan"]
  },
  "userInfo": {
    "userId": [1001],
    "userName": ["zhangsan"],
    "userNickName": ["张三"],
    "userTelphone": ["13800138000"],
    "userEmail": ["zhangsan@bima.cc"]
  }
}

对比两个响应,可以清晰地看到以下问题:

  1. 顶层标量值被包装为数组: idclient_idlogin_type 等字段从标量值变成了单元素数组。
  2. attributes 内部的值被包装为数组: credentialTypeid 从字符串变成了单元素数组。
  3. userInfo 对象内部的值被包装为数组: 所有 userInfo 的子属性值都被包装成了数组。

对于问题 1 和问题 2,虽然格式不符合预期,但客户端可以通过"取数组第一个元素"的方式来兼容。然而,问题 3 则更为严重——在签名加密场景下,userInfo 对象的结构可能在 JWT Claims 序列化过程中发生更复杂的变形。

在签名加密场景下,问题会进一步恶化。当 OIDC 服务配置了 JWS 签名或 JWE 加密时,用户资料会被序列化为 JWT Claims,然后经过签名/加密处理。在这个序列化过程中,userInfo 对象的嵌套结构可能被完全"展平"为扁平的键值对,或者其内部的 Map 结构被转换为 JSON 字符串,导致客户端完全无法解析。

2.2 默认渲染器的处理逻辑

CAS 默认的 OAuth2.0 用户资料渲染器 OAuth20DefaultUserProfileViewRenderer 的核心处理逻辑如下(教学简化版本):

java
// 教学简化版本 - CAS 默认 OAuth20UserProfileViewRenderer 的核心逻辑
public class OAuth20DefaultUserProfileViewRenderer implements OAuth20UserProfileViewRenderer {

    private final ServicesManager servicesManager;
    private final OAuthProperties oauthProperties;
    private final AttributeDefinitionStore attributeDefinitionStore;

    @Override
    public ResponseEntity render(Map<String, Object> model,
                                  OAuth20AccessToken accessToken,
                                  HttpServletResponse response) {
        // 步骤1: 获取已注册的服务配置
        var registeredService = findRegisteredService(accessToken);

        // 步骤2: 遍历模型中的每个属性
        Map<String, Object> result = new LinkedHashMap<>();
        for (Map.Entry<String, Object> entry : model.entrySet()) {
            String key = entry.getKey();
            Object value = entry.getValue();

            // 步骤3: 对每个属性值进行类型转换
            Object processedValue = determineAttributeValue(key, value);
            result.put(key, processedValue);
        }

        // 步骤4: 返回处理后的结果
        return ResponseEntity.ok(result);
    }

    protected Object determineAttributeValue(String name, Object attrValue) {
        // 将属性值统一转换为集合
        var values = CollectionUtils.toCollection(attrValue, ArrayList.class);

        // 查找属性定义
        var definition = attributeDefinitionStore
            .locateAttributeDefinition(name, OAuth20AttributeDefinition.class);

        // 根据属性定义决定返回格式
        return definition
            .map(defn -> defn.toAttributeValue(values))
            .orElseGet(() -> values.size() == 1 ? values.get(0) : values);
    }
}

从这段简化代码中,我们可以看到问题的核心在于 determineAttributeValue 方法。该方法首先将所有属性值通过 CollectionUtils.toCollection 转换为集合类型,然后根据属性定义来决定最终的返回格式。

2.3 determineAttributeValue 方法分析

determineAttributeValue 方法是理解整个问题的关键。让我们逐行分析其执行逻辑:

java
protected Object determineAttributeValue(final String name, final Object attrValue) {
    // 关键步骤1: 将任意类型的属性值转换为 List 集合
    var values = CollectionUtils.toCollection(attrValue, ArrayList.class);

    // 关键步骤2: 从属性定义存储中查找该属性的元数据定义
    var result = attrStore.locateAttributeDefinition(name, OAuth20AttributeDefinition.class);

    // 关键步骤3: 根据属性定义决定返回值
    return result.map(defn -> defn.toAttributeValue(values))
        .orElseGet(() -> values.size() == 1 ? values.getFirst() : values);
}

步骤 1:CollectionUtils.toCollection 的行为分析

CollectionUtils.toCollection 是 CAS 提供的工具方法,它的行为如下:

  • 如果 attrValue 已经是 Collection 类型,直接返回(可能包装为指定类型)。
  • 如果 attrValue 是数组,转换为 List
  • 如果 attrValueMap 类型,返回 Map 的 values() 集合。
  • 如果 attrValue 是标量值(String、Number 等),包装为单元素 List

这就是问题的根源!attrValue 是一个 Map 对象(如我们的 userInfo)时,toCollection 方法会返回该 Map 的所有值的集合,而不是保持 Map 的结构。例如:

输入: {"userId": 1001, "userName": "zhangsan", ...}
toCollection 返回: [1001, "zhangsan", "张三", "13800138000", "zhangsan@bima.cc"]

Map 的键(userIduserName 等)完全丢失了,只剩下值的集合。这意味着 userInfo 对象的结构被彻底破坏。

步骤 2:属性定义查找

attributeDefinitionStore.locateAttributeDefinition 会在 CAS 的属性定义存储中查找指定名称的属性元数据。属性定义(OAuth20AttributeDefinition)可以配置属性的存储方式(单值/多值)和作用域映射。如果找到了属性定义,则使用定义中的 toAttributeValue 方法来决定返回格式。

步骤 3:默认返回逻辑

如果没有找到属性定义(这是自定义属性的常见情况),则使用默认逻辑:如果集合中只有一个元素,返回该元素本身;否则返回整个集合。

对于我们的 userInfo 对象,由于 toCollection 返回了包含 5 个元素的集合,默认逻辑会直接返回这个集合。这就是为什么 userInfo 从一个对象变成了一个值数组。

2.4 对客户端的影响

这个问题对客户端系统的影响是多层次的,取决于客户端如何处理 UserInfo 响应。

影响层级一:数据格式不一致

当客户端期望接收 {"userName": "zhangsan"} 格式的数据时,实际收到的是 {"userName": ["zhangsan"]} 格式。如果客户端代码直接访问 response.userName(期望得到字符串),实际得到的是数组,会导致类型不匹配错误。

javascript
// 客户端期望的代码
const userName = response.userInfo.userName; // 期望: "zhangsan"
console.log(userName.toUpperCase()); // 实际: TypeError: userName.toUpperCase is not a function

// 因为实际值是 ["zhangsan"],需要
const userName = response.userInfo.userName[0]; // 才能获取到 "zhangsan"

影响层级二:签名加密场景下的结构破坏

在签名加密场景下,问题更加严重。当 OIDC 服务配置了 JWS 签名或 JWE 加密时,用户资料会经历以下处理流程:

原始 Map → processUserProfile → JwtClaims.setClaim → JWT 序列化 → JWS/JWE 处理

JwtClaims.setClaim 阶段,如果传入的值是一个 Map 对象,jose4j 库会将其正确地序列化为 JSON 对象。但如果传入的值已经被 determineAttributeValue 转换为 List,那么序列化结果就会变成 JSON 数组,完全丢失了键值对结构。

影响层级三:多客户端兼容性

在一个企业级 SSO 系统中,通常会有多个客户端接入同一个 CAS 授权服务器。不同客户端可能对 UserInfo 响应格式有不同的期望。如果 CAS 返回的格式不一致(有时是对象,有时是数组),就会导致不同客户端的集成工作变得复杂且脆弱。


第三章 CAS 6.6 用户资料处理机制

3.1 默认 OAuth20DefaultUserProfileViewRenderer

在 CAS 6.6 版本中,我们的 CallBackCasOAuthConfiguration 配置类注册了 OAuth20DefaultUserProfileViewRenderer 作为 OAuth2.0 的用户资料视图渲染器:

java
// CAS 6.6 配置 - 使用默认渲染器
@ConditionalOnMissingBean(name = "oauthUserProfileViewRenderer")
@Bean
@RefreshScope
public OAuth20UserProfileViewRenderer oauthUserProfileViewRenderer() {
    return new OAuth20DefaultUserProfileViewRenderer(
        servicesManager,
        casProperties.getAuthn().getOauth()
    );
}

OAuth20DefaultUserProfileViewRenderer 是 CAS 框架提供的默认实现,它的构造函数接受 ServicesManagerOAuthProperties 两个参数。在 CAS 6.6 中,这个渲染器的核心行为包括:

  1. model 中提取用户属性。
  2. 通过 determineAttributeValue 方法对每个属性进行类型转换。
  3. 将处理后的属性组装为最终的响应 Map。
  4. 通过 Spring MVC 的视图解析机制将 Map 渲染为 JSON 响应。

在 CAS 6.6 中,OAuth20DefaultUserProfileViewRenderer 的构造函数签名与 CAS 7.3 有所不同。CAS 6.6 版本接受 OAuthProperties 参数,而 CAS 7.3 版本接受 CasConfigurationPropertiesAttributeDefinitionStore 参数。这种 API 变化反映了 CAS 在版本演进中对配置体系的重构。

3.2 ProfileScopeToAttributesFilter 作用

在 CAS 6.6 的配置中,我们还注册了 DefaultOAuth20ProfileScopeToAttributesFilter

java
// CAS 6.6 配置 - Scope 过滤器
@ConditionalOnMissingBean(name = "profileScopeToAttributesFilter")
@Bean
public OAuth20ProfileScopeToAttributesFilter profileScopeToAttributesFilter() {
    return new DefaultOAuth20ProfileScopeToAttributesFilter();
}

DefaultOAuth20ProfileScopeToAttributesFilter 的作用是将客户端请求的 OAuth 2.0 scope 映射为具体的属性名称集合。例如:

  • profile scope 映射为 namefamily_namegiven_namenickname 等属性。
  • email scope 映射为 emailemail_verified 属性。
  • phone scope 映射为 phone_numberphone_number_verified 属性。

在我们的自定义实现中,由于用户资料数据是由 CustomOAuth20UserProfileDataCreator 直接从数据库查询并组装的,Scope 过滤器的作用被弱化了——我们不需要依赖 scope 到属性的映射关系来决定返回哪些字段,而是在数据创建阶段就已经确定了完整的属性集合。

然而,Scope 过滤器仍然在 CAS 的内部流程中发挥着作用。当 CAS 的 Controller 调用 OAuth20UserProfileDataCreator.createFrom 方法后,返回的 Map 会经过 Scope 过滤器的处理,然后才传递给视图渲染器。如果 Scope 过滤器移除了某些属性,那么这些属性就不会出现在最终的响应中。

3.3 6.6 版本的局限性

CAS 6.6 版本在用户资料处理方面存在以下局限性:

局限性一:缺乏对嵌套对象的原生支持

CAS 6.6 的默认渲染器假设所有属性值都是标量值或多值集合,没有考虑嵌套对象结构的场景。当属性值是一个 Map 时,determineAttributeValue 方法会将其转换为值的集合,丢失键信息。

局限性二:接口实现方式受限

在 CAS 6.6 中,CustomOAuth20UserProfileDataCreator 并没有直接实现 OAuth20UserProfileDataCreator 接口,而是通过 JDK 动态代理来适配接口调用:

java
// CAS 6.6 - 使用动态代理适配接口
@Bean
public OAuth20UserProfileDataCreator oAuth2UserProfileDataCreator() {
    final CustomOAuth20UserProfileDataCreator customCreator =
        new CustomOAuth20UserProfileDataCreator(servicesManager, profileScopeToAttributesFilter());

    // 通过反射注入 userService
    try {
        Field userServiceField = CustomOAuth20UserProfileDataCreator.class
            .getDeclaredField("userService");
        userServiceField.setAccessible(true);
        userServiceField.set(customCreator, userService);
    } catch (Exception e) {
        e.printStackTrace();
    }

    // 使用动态代理实现 OAuth20UserProfileDataCreator 接口
    return (OAuth20UserProfileDataCreator) Proxy.newProxyInstance(
        OAuth20UserProfileDataCreator.class.getClassLoader(),
        new Class<?>[] { OAuth20UserProfileDataCreator.class },
        new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                if (method.getName().equals("createFrom")) {
                    return customCreator.createFrom(args[0], args[1]);
                }
                return method.invoke(customCreator, args);
            }
        });
}

这种代理方式虽然解决了接口适配问题,但引入了额外的复杂性和性能开销。更重要的是,createFrom 方法的参数签名是 createFrom(Object ticket, Object context),而不是标准的 createFrom(OAuth20AccessToken ticket),这意味着在方法内部需要通过反射来获取用户 ID:

java
// CAS 6.6 - 通过反射获取用户ID
Method getAuthenticationMethod = ticket.getClass().getMethod("getAuthentication");
Object authentication = getAuthenticationMethod.invoke(ticket);

Method getPrincipalMethod = authentication.getClass().getMethod("getPrincipal");
Object principal = getPrincipalMethod.invoke(authentication);

Method getIdMethod = principal.getClass().getMethod("getId");
userId = (String) getIdMethod.invoke(principal);

这种反射调用方式虽然灵活,但存在以下问题:

  • 性能开销较大(反射调用比直接方法调用慢一个数量级)。
  • 缺乏编译时类型检查,容易因为 API 变更而出现运行时错误。
  • 异常处理复杂,需要捕获 ReflectiveOperationException

局限性三:视图渲染器未自定义

在 CAS 6.6 版本中,我们直接使用了 OAuth20DefaultUserProfileViewRenderer,没有进行自定义。这意味着所有通过默认渲染器返回的属性值都会经过 determineAttributeValue 方法的处理,导致 UserInfo 对象的结构被破坏。


第四章 CAS 7.3 CustomOAuth20UserProfileViewRenderer

4.1 接口实现方式选择

在 CAS 7.3 版本中,我们采取了与 CAS 6.6 完全不同的策略——直接实现 OAuth20UserProfileViewRenderer 接口,创建自定义的视图渲染器 CustomOAuth20UserProfileViewRenderer。这个设计决策基于以下考虑:

  1. 最小化变更原则: 通过实现接口而非继承类,我们可以完全控制渲染逻辑,避免被父类的默认行为所干扰。
  2. 简洁性: 我们的 OAuth2.0 场景不需要签名加密功能,只需要将数据模型原样返回即可。
  3. 类型安全: CAS 7.3 基于 Spring Boot 3 和 Java 21,接口方法签名更加清晰,直接实现接口可以获得完整的编译时类型检查。

CustomOAuth20UserProfileViewRenderer 的完整实现如下:

java
package cc.bima.cas.profile;

import java.util.Map;
import org.apereo.cas.authentication.attribute.AttributeDefinitionStore;
import org.apereo.cas.configuration.CasConfigurationProperties;
import org.apereo.cas.services.ServicesManager;
import org.apereo.cas.support.oauth.web.views.OAuth20UserProfileViewRenderer;
import org.apereo.cas.ticket.accesstoken.OAuth20AccessToken;
import org.springframework.http.ResponseEntity;
import jakarta.servlet.http.HttpServletResponse;

/**
 * 自定义OAuth20用户资料视图渲染器
 * 用于渲染OAuth2.0用户资料视图
 * @author 必码 bima.cc
 */
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;
    }

    @SuppressWarnings("rawtypes")
    @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());
        }
    }
}

4.2 ResponseEntity 直接返回模型

CustomOAuth20UserProfileViewRenderer 的核心设计思想非常简洁:跳过所有属性值转换逻辑,直接将数据模型作为 ResponseEntity 的响应体返回。

java
return ResponseEntity.ok(model);

这一行代码解决了我们在第二章中分析的所有问题:

  1. 标量值保持为标量值: idclient_idlogin_type 等字段不会被包装为数组。
  2. 嵌套对象保持为嵌套对象: userInfoattributes 的 Map 结构不会被破坏。
  3. 属性值类型保持不变: userId(Long 类型)、userTelphone(String 类型)等保持其原始类型。

Spring MVC 的 ResponseEntity.ok(model) 会使用 Jackson(CAS 默认的 JSON 序列化库)将 Map<String, Object> 序列化为 JSON。Jackson 对 Map 的序列化是"所见即所得"的——Map 的结构直接映射为 JSON 对象的结构,不会进行额外的类型转换。

这种方式的另一个优势是性能。默认渲染器需要对每个属性值进行 toCollection 转换、属性定义查找、条件判断等一系列操作,而我们的自定义渲染器直接跳过了这些步骤,将序列化工作完全交给 Jackson 处理。

4.3 与默认渲染器的差异

让我们通过一个对比表格来清晰地展示 CustomOAuth20UserProfileViewRenderer 与默认 OAuth20DefaultUserProfileViewRenderer 的差异:

特性默认渲染器自定义渲染器
属性值处理通过 determineAttributeValue 转换不做任何转换
标量值格式可能被包装为数组保持原始标量值
嵌套对象格式Map 被转换为值集合保持原始 Map 结构
Scope 过滤内置 Scope 过滤逻辑由 DataCreator 阶段控制
属性定义支持支持属性定义存储查询不依赖属性定义
签名加密不支持(OAuth2.0 层面)不支持(由 OIDC 渲染器处理)
异常处理框架内置自定义异常处理
代码复杂度较高(框架内部实现)极低(直接返回)

4.4 Scope 过滤保持

虽然 CustomOAuth20UserProfileViewRenderer 跳过了默认的 Scope 过滤逻辑,但这并不意味着 Scope 控制完全失效。在我们的架构中,Scope 过滤的职责被前置到了数据创建阶段。

CallBackCasOAuthConfiguration 中,我们仍然注册了 DefaultOAuth20ProfileScopeToAttributesFilter

java
// CAS 7.3 配置 - Scope 过滤器仍然存在
@ConditionalOnMissingBean(name = "profileScopeToAttributesFilter")
@Bean
public OAuth20ProfileScopeToAttributesFilter profileScopeToAttributesFilter() {
    return new DefaultOAuth20ProfileScopeToAttributesFilter();
}

同时,CustomOAuth20UserProfileDataCreator 在创建数据时已经通过直接数据库查询确定了返回的字段集合。这种设计将 Scope 过滤的职责从渲染阶段前移到了数据创建阶段,具有以下优势:

  1. 减少不必要的数据查询: 如果某些字段不需要返回,可以在数据库查询阶段就排除,避免浪费数据库资源。
  2. 简化渲染逻辑: 渲染器只需要关注"如何正确地格式化输出",不需要关心"应该返回哪些字段"。
  3. 更灵活的控制: 数据创建阶段可以根据业务逻辑动态决定返回哪些字段,而不仅限于 Scope 的静态映射。

需要注意的是,这种设计在标准的 OIDC 场景中可能需要调整。标准的 OIDC 客户端期望通过 Scope 来控制返回的 Claims,如果完全绕过 Scope 过滤,可能导致返回了客户端未请求的敏感信息。在我们的项目中,由于所有客户端都是内部系统,且返回的字段集合是固定的,因此这种简化设计是可接受的。


第五章 CAS 7.3 CustomOidcUserProfileViewRenderer

5.1 继承 OidcUserProfileViewRenderer 的设计

与 OAuth2.0 场景不同,OIDC 场景下的视图渲染器需要处理更为复杂的逻辑——特别是签名加密支持。因此,我们在 CAS 7.3 中采用了继承 OidcUserProfileViewRenderer 的方式来实现自定义渲染器 CustomOidcUserProfileViewRenderer

选择继承而非直接实现接口的原因如下:

  1. 复用签名加密基础设施: OidcUserProfileViewRenderer 内置了对 JWS 签名和 JWE 加密的支持,包括与 OAuth20TokenSigningAndEncryptionService 的集成。直接继承可以复用这些基础设施,避免重复实现。
  2. 保持协议兼容性: OIDC 规范要求 UserInfo 端点在配置了签名加密时返回 JWT 格式的响应,而非纯 JSON。OidcUserProfileViewRenderer 已经实现了这些协议细节。
  3. 最小化自定义范围: 我们只需要修改属性值的处理逻辑(保持 userInfo 对象结构),而不需要改变签名加密的整体流程。

CustomOidcUserProfileViewRenderer 的类继承关系如下:

OAuth20UserProfileViewRenderer (接口)
    └── OidcUserProfileViewRenderer (抽象类)
        └── CustomOidcUserProfileViewRenderer (自定义实现)

构造函数设计如下:

java
public class CustomOidcUserProfileViewRenderer extends OidcUserProfileViewRenderer {

    private final OAuth20TokenSigningAndEncryptionService signingService;
    private final AttributeDefinitionStore attrStore;

    public CustomOidcUserProfileViewRenderer(
            OAuthProperties oauthProperties,
            ServicesManager servicesManager,
            OAuth20TokenSigningAndEncryptionService signingAndEncryptionService,
            AttributeDefinitionStore attributeDefinitionStore) {
        super(oauthProperties, servicesManager,
              signingAndEncryptionService, attributeDefinitionStore);
        this.signingService = signingAndEncryptionService;
        this.attrStore = attributeDefinitionStore;
    }
}

5.2 签名加密场景的特殊处理

OIDC 协议允许对 UserInfo 响应进行 JWS 签名(JSON Web Signature)或 JWE 加密(JSON Web Encryption)。当 OIDC 服务配置了签名或加密要求时,UserInfo 端点的响应格式会从纯 JSON 变为 JWT(JSON Web Token)。

CustomOidcUserProfileViewRenderer 通过重写 renderProfileForModel 方法来处理这两种场景的分支:

java
@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)) {
                // 签名加密场景:走 JWT 序列化路径
                return signAndEncryptUserProfileClaims(
                    userProfile, response, oidcRegisteredService);
            }
            // 普通场景:走纯 JSON 路径
            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);
}

这段代码的处理逻辑可以用以下流程图表示:

                    renderProfileForModel 被调用


                    获取 RegisteredService

                    ┌─────────┴──────────┐
                    │ 是否为 Oidc         │
                    │ RegisteredService? │
                    └─────────┬──────────┘
                     是 │           │ 否
                        ▼           ▼
              ┌─────────────────┐  processUserProfile
              │ 需要签名或加密?  │  直接返回 Map
              └────────┬────────┘
                 是 │       │ 否
                    ▼       ▼
     signAndEncrypt   buildPlain
     UserProfileClaims UserProfileClaims
     (JWT路径)         (JSON路径)

5.3 processUserProfile 方法解析

processUserProfile 方法是 CustomOidcUserProfileViewRenderer 的核心——它负责在保持 userInfo 对象结构的前提下,对其他属性进行标准的类型转换处理。

java
@SuppressWarnings("unchecked")
private Map<String, Object> processUserProfile(final Map<String, Object> userProfile) {
    var result = new LinkedHashMap<String, Object>();

    userProfile.entrySet().stream()
        // 过滤掉 CAS 内部命名空间的属性
        .filter(entry -> !entry.getKey()
            .startsWith(CentralAuthenticationService.NAMESPACE))
        .forEach(entry -> {
            if (USER_INFO_KEY.equals(entry.getKey())) {
                // 核心逻辑:userInfo 保持为对象,不进行转换
                result.put(entry.getKey(), entry.getValue());
                LOGGER.debug("userInfo preserved as object: {}", 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;
}

这个方法的设计体现了"选择性处理"的理念:

  1. userInfo 对象完全保持原样: 当遇到 key 为 "userInfo" 的属性时,直接将其值(Map 对象)放入结果中,不经过任何转换。这是解决嵌套对象序列化问题的关键。

  2. attributes 内部值进行标准转换: attributes 本身是一个 Map,但其内部值(如 credentialTypeid)是标量值,需要经过 determineAttributeValue 处理。

  3. 其他属性进行标准转换: 顶层的其他属性(如 idclient_id 等)也经过 determineAttributeValue 处理。

  4. 过滤 CAS 内部属性:CentralAuthenticationService.NAMESPACE(通常为 "CAS_")开头的属性会被过滤掉,避免泄露内部实现细节。

自定义的 determineAttributeValue 方法在父类版本的基础上增加了对 userInfo 的特殊处理:

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

与父类的 determineAttributeValue 相比,自定义版本增加了两个前置判断:

  • 如果属性名是 userInfo,直接返回原值。
  • 如果属性值是 Map 类型,直接返回原值(这是一个防御性编程措施,防止其他嵌套对象也被展平)。

5.4 JwtClaims 序列化与属性保持

在签名加密场景下,signAndEncryptUserProfileClaims 方法负责将处理后的用户资料序列化为 JWT Claims,然后进行签名或加密操作:

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: 设置标准 JWT Claims
    claims.setAudience(registeredService.getClientId());
    claims.setIssuedAt(NumericDate.now());
    claims.setJwtId(UUID.randomUUID().toString());
    claims.setIssuer(signingService.resolveIssuer(Optional.of(registeredService)));

    // 步骤4: 签名/加密
    var result = signingService.encode(registeredService, claims);

    // 步骤5: 构建响应
    response.setContentType(OidcConstants.CONTENT_TYPE_JWT);
    return buildResponseEntity(result, response, registeredService);
}

这里的关键在于步骤 1 和步骤 2 的协作。processUserProfile 确保 userInfo 保持为 Map<String, Object> 对象,然后 claims.setClaim("userInfo", mapValue) 将这个 Map 正确地设置到 JWT Claims 中。

jose4j 库的 JwtClaims.setClaim 方法对 Map 类型的处理如下:

java
// jose4j JwtClaims 内部处理逻辑(简化)
public void setClaim(String claimName, Object value) {
    if (value instanceof Map) {
        // Map 类型会被序列化为 JSON 对象
        claimsMap.put(claimName, value);
    } else if (value instanceof Collection) {
        // Collection 类型会被序列化为 JSON 数组
        claimsMap.put(claimName, new ArrayList<>(value));
    } else {
        // 标量值直接存储
        claimsMap.put(claimName, value);
    }
}

JwtClaims.toJson() 被调用时,jose4j 会使用 JSON 序列化将 Claims Map 转换为 JSON 字符串。此时,userInfo 的 Map 结构会被正确地序列化为嵌套的 JSON 对象。

同样,buildPlainUserProfileClaims 方法也使用了相同的 processUserProfile 逻辑:

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

    response.setContentType(MediaType.APPLICATION_JSON_VALUE);

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

    // 使用 JwtClaims 的 toJson 方法来序列化
    var claims = new JwtClaims();
    processedProfile.forEach(claims::setClaim);

    return buildResponseEntity(claims.toJson(), response, registeredService);
}

即使是纯 JSON 场景(非签名加密),我们也通过 JwtClaims.toJson() 来进行序列化,而不是直接使用 Jackson。这样做的好处是确保签名加密场景和非签名加密场景的序列化行为一致——两者都使用相同的 processUserProfile 方法来处理数据模型。


第六章 签名加密场景深度剖析

6.1 OIDC UserInfo 签名机制

OIDC 协议允许对 UserInfo 响应进行 JWS(JSON Web Signature)签名,以确保响应的完整性和真实性。当客户端收到签名的 UserInfo 响应时,可以使用授权服务器的公钥(通过 JWKS 端点获取)来验证签名,确保响应确实来自授权服务器且未被篡改。

在 CAS 中,OIDC 服务的签名配置是通过 OidcRegisteredService 的属性来控制的。以下是一个启用了 UserInfo 签名的服务配置示例(JSON 格式):

json
{
  "@class": "org.apereo.cas.services.OidcRegisteredService",
  "serviceId": "https://app.bima.cc/.*",
  "name": "BIMA Web Application",
  "id": 1001,
  "clientId": "bima-web-client",
  "clientSecret": "encrypted-secret",
  "supportedGrantTypes": ["java.util.HashSet", ["authorization_code", "refresh_token"]],
  "supportedResponseTypes": ["java.util.HashSet", ["code"]],
  "signIdToken": true,
  "encryptIdToken": false,
  "userInfoSigningAlg": "RS256",
  "jwksKeyId": "bima-rs256-key"
}

其中,userInfoSigningAlg 指定了签名算法(如 RS256、ES256 等),jwksKeyId 指定了用于签名的密钥标识符。CAS 会根据这些配置,在 signAndEncryptUserProfileClaims 方法中自动完成签名操作。

签名后的 UserInfo 响应格式如下:

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

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ6aGFuZ3NhbiIsInVzZXJJbmZvIjp7InVzZXJJZCI6MTAwMSwidXNlck5hbWUiOiJ6aGFuZ3NhbiIsInVzZXJOaWNrTmFtZSI6IuW8oOS4iSIsInVzZXJUZWxwaG9uZSI6IjEzODAwMTM4MDAwIiwidXNlckVtYWlsIjoiemhhbmdzYW5AYmltYS5jYyJ9LCJhdWQiOiJiaW1hLXdlYi1jbGllbnQiLCJpYXQiOjE3MTYwMDAwMDAsImp0aSI6IjEyMzQ1Njc4LTkwYWItY2RlZi0xMjM0LTU2Nzg5MGFiY2RlZiIsImlzcyI6Imh0dHBzOi8vY2FzLmJpbWEuY2Mvb2lkYyJ9.SIGNATURE_PART

这个响应是一个由三部分组成的 JWT:Header(头部)、Payload(载荷)和 Signature(签名)。其中 Payload 部分解码后就是我们的用户资料 JSON。

6.2 JWE 加密流程

除了签名,OIDC 还支持对 UserInfo 响应进行 JWE(JSON Web Encryption)加密。加密后的响应只有持有正确私钥的客户端才能解密,这为用户信息提供了额外的隐私保护。

JWE 加密后的响应格式是一个五部分的 JWT:

HEADER.ENCRYPTED_KEY.INITIALIZATION_VECTOR.CIPHERTEXT.AUTHENTICATION_TAG

在 CAS 中,JWE 加密的配置与签名类似,通过 OidcRegisteredService 的属性来控制:

json
{
  "encryptIdToken": true,
  "userInfoEncryptionAlg": "RSA-OAEP",
  "userInfoEncryptionEnc": "A256GCM",
  "jwksKeyId": "bima-encryption-key"
}

signingService.encode(registeredService, claims) 方法会根据服务配置自动决定执行签名、加密还是两者兼有。其内部逻辑如下:

                    encode(registeredService, claims)


                    ┌─────────────────────┐
                    │ shouldEncryptToken? │──是──> JWE 加密
                    └────────┬────────────┘
                             │ 否

                    ┌─────────────────────┐
                    │ shouldSignToken?    │──是──> JWS 签名
                    └────────┬────────────┘
                             │ 否

                    直接返回 Claims JSON

6.3 属性在序列化过程中的变形

为了更深入地理解属性在序列化过程中的变形问题,让我们追踪一个具体的 userInfo 对象在默认渲染器和自定义渲染器中的完整处理链路。

默认渲染器的处理链路:

步骤1: DataCreator 创建数据
  userInfo = Map.of(
    "userId", 1001,
    "userName", "zhangsan",
    "userNickName", "张三"
  )

步骤2: 默认 determineAttributeValue 处理
  CollectionUtils.toCollection(userInfo) → [1001, "zhangsan", "张三"]
  // Map 的键信息丢失!

步骤3: JwtClaims.setClaim("userInfo", [1001, "zhangsan", "张三"])
  // Claims 中 userInfo 变成了 List

步骤4: JwtClaims.toJson() 序列化
  {"userInfo": [1001, "zhangsan", "张三"]}
  // 完全丢失了键值对结构!

步骤5: JWS/JWE 处理
  // 签名/加密一个结构错误的 JWT

自定义渲染器的处理链路:

步骤1: DataCreator 创建数据
  userInfo = Map.of(
    "userId", 1001,
    "userName", "zhangsan",
    "userNickName", "张三"
  )

步骤2: processUserProfile 处理
  // 检测到 key 为 "userInfo",直接保持原样
  result.put("userInfo", userInfo)

步骤3: JwtClaims.setClaim("userInfo", Map{userId:1001, userName:"zhangsan", ...})
  // Claims 中 userInfo 保持为 Map

步骤4: JwtClaims.toJson() 序列化
  {"userInfo": {"userId": 1001, "userName": "zhangsan", "userNickName": "张三"}}
  // 结构完整保持!

步骤5: JWS/JWE 处理
  // 签名/加密一个结构正确的 JWT

对比两条链路,差异的核心在于步骤 2。默认渲染器在步骤 2 就将 Map 结构破坏了,后续的所有步骤都无法恢复。而自定义渲染器在步骤 2 就保护了 Map 结构,后续步骤自然能正确处理。

6.4 属性保持的完整链路

将前面的分析整合起来,我们可以绘制出 CustomOidcUserProfileViewRenderer 在完整请求处理流程中的属性保持链路:

┌─────────────────────────────────────────────────────────────────────────────┐
│                        完整的 UserInfo 请求处理链路                          │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  1. 客户端请求                                                                │
│     GET /oauth2.0/profile                                                   │
│     Authorization: Bearer AT-xxxxx                                          │
│                     │                                                       │
│                     ▼                                                       │
│  2. OidcUserProfileController                                              │
│     ├── 验证 Access Token                                                   │
│     ├── 调用 OidcUserProfileDataCreator.createFrom()                        │
│     │   └── CustomOidcUserProfileDataCreator 创建包含 userInfo 的 Map       │
│     └── 调用 ViewRenderer.render()                                          │
│                     │                                                       │
│                     ▼                                                       │
│  3. CustomOidcUserProfileViewRenderer.renderProfileForModel()               │
│     ├── 判断服务类型 (OidcRegisteredService)                                 │
│     ├── 判断是否需要签名/加密                                                 │
│     │                                                                       │
│     ├── [签名/加密路径]                                                      │
│     │   └── signAndEncryptUserProfileClaims()                               │
│     │       ├── processUserProfile() ← 保持 userInfo 为 Map                 │
│     │       ├── JwtClaims.setClaim() ← Map 正确设置到 Claims                │
│     │       ├── signingService.encode() ← JWT 生成并签名/加密               │
│     │       └── 返回 application/jwt 响应                                   │
│     │                                                                       │
│     ├── [普通 JSON 路径]                                                     │
│     │   └── buildPlainUserProfileClaims()                                   │
│     │       ├── processUserProfile() ← 保持 userInfo 为 Map                 │
│     │       ├── JwtClaims.setClaim() ← Map 正确设置到 Claims                │
│     │       ├── claims.toJson() ← 序列化为 JSON 字符串                      │
│     │       └── 返回 application/json 响应                                  │
│     │                                                                       │
│     └── [非 OIDC 服务路径]                                                   │
│         └── processUserProfile() → ResponseEntity.ok(result)                │
│                     │                                                       │
│                     ▼                                                       │
│  4. 客户端收到响应                                                            │
│     Content-Type: application/json 或 application/jwt                       │
│     Body: {"id":"zhangsan", "userInfo":{"userId":1001, ...}, ...}           │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

这个链路图清晰地展示了 processUserProfile 方法在整个流程中的核心位置——它是保护 userInfo 对象结构的"守门人"。无论最终走哪条输出路径(签名加密、纯 JSON、非 OIDC),所有路径都会经过 processUserProfile 的处理,确保 userInfo 对象的结构完整性。


第七章 生产环境实践与调试技巧

7.1 渲染器注册与优先级

在 CAS 的自动配置体系中,Bean 的注册和覆盖遵循 Spring Boot 的条件化配置机制。我们的自定义渲染器通过 @ConditionalOnMissingBean 注解来确保在 CAS 默认渲染器未被注册时才生效。

CAS 7.3 中的渲染器注册配置:

java
// OAuth2.0 渲染器注册
@Configuration("callBackCasOAuthConfiguration")
@EnableConfigurationProperties(CasConfigurationProperties.class)
public class CallBackCasOAuthConfiguration {

    @ConditionalOnMissingBean(name = "oauthUserProfileViewRenderer")
    @Bean
    @RefreshScope
    public OAuth20UserProfileViewRenderer oauthUserProfileViewRenderer() {
        return new CustomOAuth20UserProfileViewRenderer(
            servicesManager, casProperties, attributeDefinitionStore);
    }
}

// OIDC 渲染器注册
@Configuration("callBackCasOidcConfiguration")
@EnableConfigurationProperties(CasConfigurationProperties.class)
public class CallBackCasOidcConfiguration {

    @ConditionalOnMissingBean(name = "oidcUserProfileViewRenderer")
    @Bean
    @RefreshScope
    public OAuth20UserProfileViewRenderer oidcUserProfileViewRenderer() {
        return new OAuth20DefaultUserProfileViewRenderer(
            servicesManager, casProperties.getAuthn().getOauth(),
            attributeDefinitionStore);
    }
}

关于渲染器注册,有几个重要的注意事项:

注意事项一:Bean 名称的重要性

@ConditionalOnMissingBean(name = "oauthUserProfileViewRenderer") 中的 name 属性指定了要检查的 Bean 名称。CAS 内部的自动配置类也会注册同名 Bean,因此 @ConditionalOnMissingBean 确保了我们的自定义 Bean 只在 CAS 默认 Bean 未被注册时才生效。如果 CAS 的自动配置先于我们的配置类执行,那么默认的渲染器 Bean 已经存在,我们的自定义渲染器就不会被注册。

要确保自定义渲染器优先注册,可以采取以下策略:

  • 使用 @AutoConfigureBefore 注解让自定义配置类在 CAS 自动配置之前执行。
  • 使用 @Primary 注解标记自定义 Bean 为首选。
  • 直接移除 @ConditionalOnMissingBean,强制使用自定义渲染器。

注意事项二:@RefreshScope 的使用

@RefreshScope 注解使得渲染器 Bean 可以在运行时通过 Spring Cloud 的 Refresh 机制进行刷新。这在需要动态更新渲染器配置的场景下非常有用,但也需要注意:每次 Refresh 都会创建新的 Bean 实例,可能导致短暂的不可用。

注意事项三:OAuth2.0 与 OIDC 渲染器的分离

在我们的 CAS 7.3 配置中,OAuth2.0 使用 CustomOAuth20UserProfileViewRenderer,而 OIDC 使用 OAuth20DefaultUserProfileViewRenderer。这种分离设计是有意为之的:

  • OAuth2.0 场景不需要签名加密,使用简单的直接返回即可。
  • OIDC 场景的 CustomOidcUserProfileViewRenderer 需要继承 OidcUserProfileViewRenderer 来获得签名加密支持。

在实际部署中,如果 OIDC 场景也需要自定义渲染器(保持 userInfo 对象结构),则需要额外注册 CustomOidcUserProfileViewRenderer

java
// 如果 OIDC 也需要自定义渲染器
@ConditionalOnMissingBean(name = "oidcUserProfileViewRenderer")
@Bean
@RefreshScope
public OAuth20UserProfileViewRenderer oidcUserProfileViewRenderer() {
    return new CustomOidcUserProfileViewRenderer(
        casProperties.getAuthn().getOauth(),
        servicesManager,
        signingAndEncryptionService,
        attributeDefinitionStore);
}

7.2 Scope 配置与属性映射

CAS 的 Scope 到属性的映射配置是控制 UserInfo 端点返回内容的重要机制。默认的映射关系定义在 CAS 的核心模块中,我们可以通过配置文件进行扩展和覆盖。

CAS 配置文件中的 Scope 映射配置示例:

properties
# application.properties 或 cas.properties

# 定义自定义的属性映射
cas.authn.oauth.user-profile.scope-to-attributes[0].scope=profile
cas.authn.oauth.user-profile.scope-to-attributes[0].attributes=userName,userNickName,userId

cas.authn.oauth.user-profile.scope-to-attributes[1].scope=email
cas.authn.oauth.user-profile.scope-to-attributes[1].attributes=userEmail

cas.authn.oauth.user-profile.scope-to-attributes[2].scope=phone
cas.authn.oauth.user-profile.scope-to-attributes[2].attributes=userTelphone

在我们的项目中,由于自定义渲染器直接返回了 DataCreator 创建的完整数据模型,Scope 映射的实际效果取决于 DataCreator 的实现逻辑。如果需要在渲染阶段进行 Scope 过滤,可以在 processUserProfile 方法中添加相应的过滤逻辑:

java
// 教学示例 - 在 processUserProfile 中添加 Scope 过滤
private Map<String, Object> processUserProfile(
        final Map<String, Object> userProfile,
        final Set<String> allowedScopes) {

    var result = new LinkedHashMap<String, Object>();
    userProfile.entrySet().stream()
        .filter(entry -> !entry.getKey()
            .startsWith(CentralAuthenticationService.NAMESPACE))
        .filter(entry -> {
            // 根据 Scope 决定是否包含该属性
            if (USER_INFO_KEY.equals(entry.getKey())) {
                return allowedScopes.contains("profile") ||
                       allowedScopes.contains("email") ||
                       allowedScopes.contains("phone");
            }
            return true;
        })
        .forEach(entry -> {
            // ... 原有的处理逻辑
        });

    return result;
}

7.3 调试 UserInfo 响应

调试 UserInfo 端点的响应是排查问题的关键步骤。以下是在生产环境中常用的调试方法:

方法一:直接使用 curl 调用

bash
# 步骤1: 获取 Access Token
TOKEN_RESPONSE=$(curl -s -k -X POST \
  "https://cas.bima.cc/oauth2.0/accessToken" \
  -d "grant_type=authorization_code" \
  -d "code=AUTHORIZATION_CODE" \
  -d "client_id=bima-web-client" \
  -d "client_secret=CLIENT_SECRET" \
  -d "redirect_uri=https://app.bima.cc/callback")

ACCESS_TOKEN=$(echo $TOKEN_RESPONSE | jq -r '.access_token')

# 步骤2: 调用 UserInfo 端点
curl -s -k -X GET \
  "https://cas.bima.cc/oauth2.0/profile" \
  -H "Authorization: Bearer $ACCESS_TOKEN" | jq .

方法二:查看 CAS 日志

CustomOidcUserProfileViewRenderer 中,我们已经添加了详细的日志输出:

java
LOGGER.info("CustomOidcUserProfileViewRenderer.renderProfileForModel called");
LOGGER.debug("userInfo preserved as object: {}", entry.getValue());
LOGGER.debug("Collected user profile claims, before cipher operations, are [{}]", claims);
LOGGER.debug("Finalized user profile is [{}]", result);

在生产环境中,可以通过调整日志级别来控制输出:

properties
# log4j2.xml 或 application.properties
logging.level.cc.bima.cas.profile=DEBUG

方法三:解码 JWT 响应

如果 UserInfo 端点返回的是 JWT 格式的响应(签名加密场景),可以使用 jwt.io 或命令行工具来解码:

bash
# 使用 jq 解码 JWT Payload(Base64URL 解码)
echo "eyJzdWIiOiJ6aGFuZ3NhbiIsInVzZXJJbmZvIjp7..." | \
  cut -d'.' -f2 | \
  base64 -d 2>/dev/null | jq .

方法四:使用 CAS 内置的协议消息日志

CAS 内置了协议消息日志功能,可以记录所有 OIDC 协议交互的详细信息:

properties
# 启用协议消息日志
cas.webflow.log-protocol-messages=true

这会在 CAS 日志中输出完整的请求和响应信息,包括 UserInfo 端点的响应内容。

7.4 常见问题排查

在实际项目中,我们遇到了以下常见问题,这里给出排查思路和解决方案。

问题一:自定义渲染器未生效

症状:UserInfo 响应仍然使用默认格式(属性值被包装为数组)。

排查步骤:

  1. 确认自定义配置类被 Spring 扫描到。检查 @Configuration 注解和组件扫描路径。
  2. 确认 @ConditionalOnMissingBean 条件是否满足。可以通过 actuator/beans 端点查看已注册的 Bean。
  3. 检查 CAS 的自动配置是否先于自定义配置执行。如果有冲突,使用 @AutoConfigureOrder@AutoConfigureBefore 调整顺序。

解决方案:

java
// 强制使用自定义渲染器,移除 @ConditionalOnMissingBean
@Bean
@RefreshScope
public OAuth20UserProfileViewRenderer oauthUserProfileViewRenderer() {
    return new CustomOAuth20UserProfileViewRenderer(
        servicesManager, casProperties, attributeDefinitionStore);
}

问题二:签名加密后 userInfo 结构被破坏

症状:纯 JSON 响应中 userInfo 结构正确,但签名加密后的 JWT 中 userInfo 变成了数组。

排查步骤:

  1. 确认 signAndEncryptUserProfileClaims 方法被正确重写。
  2. 确认 processUserProfile 在签名加密路径中被调用。
  3. 解码 JWT Payload,检查 Claims 中的 userInfo 结构。

解决方案: 确保 signAndEncryptUserProfileClaims 方法中调用了 processUserProfile

java
@Override
protected ResponseEntity<String> signAndEncryptUserProfileClaims(
        final Map<String, Object> userProfile,
        final HttpServletResponse response,
        final OidcRegisteredService registeredService) throws Throwable {
    // 必须先调用 processUserProfile 处理数据
    var processedProfile = processUserProfile(userProfile);

    var claims = new JwtClaims();
    processedProfile.forEach(claims::setClaim);
    // ...
}

问题三:CAS 内部属性泄露到响应中

症状:UserInfo 响应中包含了以 "CAS_" 开头的内部属性。

排查步骤:

  1. 检查 processUserProfile 方法中的过滤逻辑。
  2. 确认 CentralAuthenticationService.NAMESPACE 常量的值是否正确。

解决方案: 确保 processUserProfile 中包含命名空间过滤:

java
userProfile.entrySet().stream()
    .filter(entry -> !entry.getKey()
        .startsWith(CentralAuthenticationService.NAMESPACE))
    .forEach(entry -> { ... });

问题四:跨版本升级后渲染器不兼容

症状:从 CAS 6.6 升级到 CAS 7.3 后,自定义渲染器编译失败或运行时异常。

排查步骤:

  1. 检查 CAS 7.3 中 OAuth20UserProfileViewRenderer 接口的方法签名是否变更。
  2. 检查 OidcUserProfileViewRenderer 的构造函数参数是否变更。
  3. 检查 jakarta.servlet 包名是否正确(CAS 7.3 使用 Jakarta EE,不再是 javax.servlet)。

解决方案: CAS 6.6 到 7.3 的主要 API 变化:

  • javax.servletjakarta.servlet
  • OAuth20DefaultUserProfileViewRenderer 构造函数参数变更
  • CollectionUtils.toCollection 的返回类型可能变化
  • OidcUserProfileViewRenderer 的抽象方法签名可能变化

问题五:反射注入 userService 失败

症状:CustomOAuth20UserProfileDataCreator 中的 userService 为 null,导致数据库查询失败。

排查步骤:

  1. 检查反射注入代码是否正确执行。
  2. 检查 UserService Bean 是否在 Spring 容器中注册。
  3. 检查 Java 安全管理器是否阻止了反射访问。

解决方案(推荐): 在 CAS 7.3 中,推荐使用构造函数注入或 Setter 注入,避免使用反射:

java
// 推荐方式 - 使用 Setter 注入
public class CustomOAuth20UserProfileDataCreator implements OAuth20UserProfileDataCreator {

    private UserInfoService userService;

    public void setUserService(UserInfoService userService) {
        this.userService = userService;
    }

    // ...
}

// 配置类中
@Bean
public OAuth20UserProfileDataCreator oauth2UserProfileDataCreator() {
    var customCreator = new CustomOAuth20UserProfileDataCreator(
        servicesManager, profileScopeToAttributesFilter());
    customCreator.setUserService(userService); // 直接调用 Setter
    return customCreator;
}

总结与展望

本文从 CAS OIDC 用户资料视图渲染器的自定义实践出发,系统性地剖析了 UserInfo 对象在序列化过程中被"展平"为值数组的根本原因,并给出了从 CAS 6.6 到 CAS 7.3 两个版本的完整解决方案。

核心要点回顾

  1. 问题根源在于 determineAttributeValue 方法: CAS 默认的视图渲染器通过 CollectionUtils.toCollection 将所有属性值统一转换为集合类型,当属性值是 Map(嵌套对象)时,Map 的键信息会丢失,只剩值的集合。

  2. OAuth2.0 场景的简洁方案: CustomOAuth20UserProfileViewRenderer 直接实现 OAuth20UserProfileViewRenderer 接口,通过 ResponseEntity.ok(model) 跳过所有属性值转换逻辑,将序列化工作完全交给 Jackson。

  3. OIDC 场景的继承方案: CustomOidcUserProfileViewRenderer 继承 OidcUserProfileViewRenderer,通过 processUserProfile 方法在保持 userInfo 对象结构的前提下,对其他属性进行标准转换,同时复用父类的签名加密基础设施。

  4. 签名加密场景的特殊处理:signAndEncryptUserProfileClaimsbuildPlainUserProfileClaims 两个方法中,都先调用 processUserProfile 处理数据模型,确保 userInfo 在进入 JWT Claims 序列化之前保持为 Map 结构。

  5. CAS 6.6 到 7.3 的演进: 从 6.6 版本的反射获取用户 ID 和动态代理适配接口,到 7.3 版本的直接 API 调用和标准接口实现,CAS 的扩展开发体验得到了显著改善。

架构设计启示

从这个问题的解决过程中,我们可以提炼出以下架构设计启示:

启示一:理解框架的默认行为是自定义的前提。 CAS 将所有属性视为多值集合的设计是有其合理性的——在 LDAP 等目录服务中,一个属性确实可以有多个值。但在我们的业务场景中,用户资料是结构化的嵌套对象,与 CAS 的默认假设不匹配。只有深入理解了框架的默认行为,才能有针对性地进行自定义。

启示二:选择正确的扩展点至关重要。 CAS 在用户资料处理流程中提供了多个扩展点(DataCreator、ScopeFilter、ViewRenderer),每个扩展点适用于不同的自定义场景。我们的实践表明,将数据组装逻辑放在 DataCreator 中,将格式控制逻辑放在 ViewRenderer 中,是一种清晰的职责分离。

启示三:签名加密场景需要端到端测试。 纯 JSON 场景下正确的实现,在签名加密场景下可能暴露问题。JWT Claims 的序列化过程引入了额外的类型转换环节,必须确保整个链路中数据结构的完整性。

未来展望

随着 OIDC 协议的不断演进和 CAS 版本的持续更新,用户资料处理机制也将面临新的挑战和机遇:

  1. OIDC for Identity Assurance (OIDC4IDA): 新的 OIDC 扩展协议对用户身份验证级别提出了更高的要求,可能需要在 UserInfo 中包含更复杂的验证信息结构。
  2. JSON Web Token (JWT) Profile for OAuth 2.0: JWT Access Token 的广泛使用要求 UserInfo 端点与 Token 中的 Claims 保持一致性,这对属性值格式化提出了更严格的要求。
  3. CAS 8.x 的变化: 随着 CAS 向 Spring Boot 3.x 和 Java 21+ 的全面迁移,API 接口和配置体系可能进一步调整,自定义渲染器的实现方式也可能需要相应更新。

我们希望本文的实践经验能够为 CAS 社区的开发者提供有价值的参考,也期待与更多同行交流在身份认证领域的最佳实践。


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

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

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