Spring Security 7 之 OIDC /connect/userinfo 端点解析:ID Token 与用户信息获取

前言

在使用 Spring Security OAuth2 Authorization Server 时,很多开发者对 /userinfo 端点存在两个常见的疑问:

  1. 如果 ID Token 已经包含了用户信息,为什么还需要单独的 /userinfo 端点?
  2. 请求 /userinfo 时使用的是 access_token,那么框架是如何从中获取 ID Token 中的信息的?

本文将深入剖析这两个问题,帮助你更好地理解 OIDC 规范的设计哲学和框架的实现机制。


一、问题背景

在 OAuth2 + OIDC 授权流程中,用户完成授权后会获得两种 Token:

Token 类型 用途 包含信息
Access Token 访问受保护资源 仅标识用户身份,不含详细claims
ID Token 身份证明 包含用户身份信息(claims)

同时,OIDC 规范还定义了一个标准的用户信息端点:/userinfo

这不禁让人产生疑问:既然 ID Token 已经包含了用户信息,为什么还需要 /userinfo 端点?请求时使用的是 access_token,框架又是如何获取 ID Token 中的信息的?


二、ID Token vs /userinfo 端点

2.1 为什么需要 /userinfo 端点?

这是 OIDC 规范精心设计的结果,两者在功能和使用场景上有本质区别:

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                    ID Token 的特性                              │
├─────────────────────────────────────────────────────────────────┤
│  • 一次性发放,验证后通常会被丢弃                                │
│  • 短有效期(通常 5-15 分钟)                                    │
│  • Claims 在签发时固定,无法反映用户状态变化                     │
│  • 客户端可以直接解析(JWT 格式)                               │
│  • 包含敏感信息,不建议长期存储                                  │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                  /userinfo 端点的特性                           │
├─────────────────────────────────────────────────────────────────┤
│  • 每次请求都返回最新的用户信息                                  │
│  • 可以包含实时数据(在线状态、权限变更等)                      │
│  • 可以根据客户端动态过滤敏感信息                                │
│  • 是 OIDC 规范规定的标准接口,保证互操作性                     │
│  • 支持增量请求(通过 scope 控制返回字段)                       │
└─────────────────────────────────────────────────────────────────┘

2.2 实际使用场景

java 复制代码
// 场景一:客户端只解析 ID Token,不调用 /userinfo
// 适用于:SPA 移动端等对性能要求高的场景

// 场景二:客户端调用 /userinfo 获取完整用户信息
// 适用于:需要最新用户数据的企业级应用

// 场景三:结合使用
// ID Token 用于快速身份验证
// /userinfo 用于获取详细用户资料和实时状态

2.3 OIDC 规范的设计动机

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                    OIDC 规范设计原则                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. 职责分离                                                    │
│     - ID Token:身份证明(谁登录了)                            │
│     - /userinfo:用户信息服务(用户是谁)                       │
│                                                                 │
│  2. 灵活性                                                      │
│     - 客户端可以选择只使用 ID Token                             │
│     - 或结合 /userinfo 获取更完整信息                           │
│                                                                 │
│  3. 安全性                                                      │
│     - ID Token 短生命周期,减少泄露风险                         │
│     - /userinfo 需要 access_token 保护                          │
│                                                                 │
│  4. 可扩展性                                                    │
│     - /userinfo 可以返回比 ID Token 更丰富的字段                │
│     - 支持通过 scope 动态控制返回内容                           │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

三、框架实现原理

3.1 /userinfo 请求处理流程

当客户端请求 /userinfo 端点时,Spring Security OAuth2 Authorization Server 的处理流程如下:

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                  /userinfo 请求完整流程                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. 请求到达                                                   │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  GET /userinfo                                           │   │
│  │  Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6...   │   │
│  └─────────────────────────────────────────────────────────┘   │
│                          ↓                                      │
│  2. OidcUserInfoEndpointFilter 拦截                            │
│     - 提取 Authorization Header 中的 access_token             │
│     - 调用 OAuth2AuthorizationService.findByToken()           │
│                          ↓                                      │
│  3. Redis 查询(假设使用 RedisOAuth2AuthorizationService)    │
│                                                                 │
│     ┌─────────────────────────────────────────────────────┐    │
│     │  Key: oauth2:authorization:token:access_token:xxx   │    │
│     │  Value: authorization_id                            │    │
│     └─────────────────────────────────────────────────────┘    │
│                          ↓                                      │
│     ┌─────────────────────────────────────────────────────┐    │
│     │  Key: oauth2:authorization:id:authorization_id      │    │
│     │  Value: OAuth2Authorization 对象                    │    │
│     └─────────────────────────────────────────────────────┘    │
│                          ↓                                      │
│  4. 从 OAuth2Authorization 中提取 ID Token claims             │
│                                                                 │
│     OAuth2Authorization 结构:                                  │
│     ┌─────────────────────────────────────────────────┐        │
│     │  registeredClientId: "client-123"               │        │
│     │  principalName: "zhangsan"                      │        │
│     │  authorizationGrantType: "authorization_code"   │        │
│     │                                                  │        │
│     │  tokens: {                                      │        │
│     │    "access_token": { tokenValue: "..." },       │        │
│     │    "refresh_token": { tokenValue: "..." },      │        │
│     │    "id_token": {                                │ ← 关键
│     │      claims: {                                  │        │
│     │        "sub": "user-123",                       │        │
│     │        "name": "张三",                           │        │
│     │        "email": "zhangsan@example.com",         │        │
│     │        ...                                      │        │
│     │      }                                          │        │
│     │    }                                            │        │
│     │  }                                              │        │
│     └─────────────────────────────────────────────────┘        │
│                          ↓                                      │
│  5. 框架处理                                                   │
│     OidcUserInfoAuthenticationProvider                          │
│     - 获取 ID Token 的 claims                                   │
│     - 根据请求的 scope 过滤字段                                  │
│     - 构建 OidcUserInfo 对象                                    │
│                          ↓                                      │
│  6. 返回响应                                                   │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  HTTP/1.1 200 OK                                        │   │
│  │  Content-Type: application/json                         │   │
│  │                                                          │   │
│  │  {                                                       │   │
│  │    "sub": "user-123",                                   │   │
│  │    "name": "张三",                                       │   │
│  │    "email": "zhangsan@example.com",                     │   │
│  │    ...                                                  │   │
│  │  }                                                       │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

3.2 关键代码解析

3.2.1 Redis 中的 Token 映射存储
java 复制代码
// RedisOAuth2AuthorizationService.java

// Token 映射存储
private void storeTokenMappings(OAuth2Authorization authorization) {
    // 存储 access_token -> authorization_id 的映射
    OAuth2Authorization.Token<OAuth2AccessToken> accessToken =
        authorization.getAccessToken();
    if (accessToken != null) {
        String tokenKey = buildTokenKey(TOKEN_TYPE_ACCESS_TOKEN,
            accessToken.getToken().getTokenValue());
        redisTemplate.opsForValue().set(tokenKey,
            authorization.getId(), defaultTimeout);
    }

    // 存储 id_token -> authorization_id 的映射
    OAuth2Authorization.Token<OidcIdToken> oidcIdToken =
        authorization.getToken(OidcIdToken.class);
    if (oidcIdToken != null) {
        String tokenKey = buildTokenKey(TOKEN_TYPE_ID_TOKEN,
            oidcIdToken.getToken().getTokenValue());
        redisTemplate.opsForValue().set(tokenKey,
            authorization.getId(), defaultTimeout);
    }
}
3.2.2 OAuth2Authorization 结构
复制代码
OAuth2Authorization 是 Spring Security OAuth2 的核心实体,存储完整的授权信息:

registeredClientId    - 注册的客户端 ID
principalName         - 主体名称(用户名)
authorizationGrantType- 授权类型(authorization_code)
state                 - 状态值
attributes            - 其他属性

tokens:
  - access_token      - 访问令牌
  - refresh_token     - 刷新令牌
  - id_token          - ID 令牌(OidcIdToken,包含 claims)
  - authorization_code- 授权码

refreshToken          - 刷新令牌引用
authorizations        - 授权同意信息
3.2.3 TokenCustomizer 中 ID Token 的 Claims 配置
java 复制代码
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> myJWTOAuth2TokenCustomizer() {
    return context -> {
        boolean isIdToken = "id_token".equals(
            context.getTokenType().getValue());

        if (isIdToken) {
            // ID Token:添加用户信息 claims
            Authentication auth = context.getPrincipal();
            if (auth.getPrincipal() instanceof MyUser myUser) {
                Map<String, Object> claims = context.getClaims().build();

                // 基础信息
                claims.put("sub", myUser.getUcUid());  // OIDC 必须
                claims.put("name", myUser.getNickname());

                // 根据 scope 添加更多字段
                Set<String> scopes = context.getAuthorizedScopes();
                if (scopes.contains("profile")) {
                    claims.put("given_name", myUser.getNickname());
                }
                if (scopes.contains("phone")) {
                    claims.put("phone_number", myUser.getPhone());
                    claims.put("phone_number_verified", true);
                }
                // ...
            }
        }
    };
}

四、常见问题与解决方案

4.1 /userinfo 返回 claims 为空

错误信息

复制代码
java.lang.IllegalArgumentException: claims cannot be empty

原因分析

  1. ID Token 的 claims 被意外删除(如在 TokenCustomizer 中移除了 sub
  2. OAuth2Authorization 中没有正确保存 ID Token

解决方案

java 复制代码
// 错误做法:删除所有标准 claims
cc.remove("sub");  // ❌ 导致 userinfo 端点无 claims 返回

// 正确做法:只删除 Access Token 的非必要 claims
if (!isIdToken) {
    cc.remove("iss");
    cc.remove("aud");
    // 保留 sub、exp 等 OIDC 必需的 claims
}

4.2 如何让 /userinfo 返回更多用户信息

方案:在 TokenCustomizer 中根据 scope 添加 claims

java 复制代码
private void oidcClaimsByScope(Map<String, Object> claims,
                               MyUser myUser,
                               Set<String> scopes) {
    // 始终返回:sub (框架自动) 和 name
    claims.put("name", myUser.getNickname());

    // 根据 scope 返回
    if (scopes.contains("profile")) {
        claims.put("given_name", myUser.getNickname());
        claims.put("family_name", "");
    }

    if (scopes.contains("email")) {
        claims.put("email", myUser.getEmail());
        claims.put("email_verified", true);
    }

    if (scopes.contains("phone")) {
        claims.put("phone_number", myUser.getPhone());
        claims.put("phone_number_verified", true);
    }
}

4.3 ID Token 和 /userinfo 的 Scope 区别

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                    Scope 对比                                    │
├───────────────────────┬─────────────────────────────────────────┤
│  ID Token 的 scope    │  决定签发时包含哪些 claims              │
│  /userinfo 的 scope   │  决定返回时包含哪些 claims              │
├───────────────────────┼─────────────────────────────────────────┤
│  通常保持一致         │  但实现上可以不同                        │
│  例如:               │  例如:                                 │
│  scope=openid profile │  - ID Token 包含 name                   │
│                      │  - /userinfo 可返回额外字段              │
└───────────────────────┴─────────────────────────────────────────┘

五、总结

核心要点

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                    核心要点总结                                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. ID Token 和 /userinfo 是互补关系                            │
│     - ID Token:用于快速身份验证                                │
│     - /userinfo:用于获取完整用户信息                           │
│                                                                 │
│  2. /userinfo 端点通过 access_token 定位 OAuth2Authorization    │
│     再从其中提取 ID Token 的 claims                             │
│                                                                 │
│  3. ID Token 的 claims 由 TokenCustomizer 在签发时决定          │
│                                                                 │
│  4. 保持 ID Token 的标准 claims(sub, iss, aud 等)完整性       │
│     避免删除导致 /userinfo 端点异常                              │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

实践建议

场景 推荐做法
轻量级客户端 只使用 ID Token,不调用 /userinfo
企业级应用 结合使用 ID Token(验证)+ /userinfo(获取信息)
需要实时数据 优先使用 /userinfo,它返回最新信息
敏感信息保护 敏感字段只放在 /userinfo,通过 scope 控制

参考资料


本文基于 Spring Security OAuth2 Authorization Server 7.0.2 版本编写

相关推荐
慧一居士2 小时前
同一个服务器上不同的域名跳往不同的前端项目页面,不显示端口号 ngnix根据不同域名跳转
运维·服务器·前端
筑梦之路2 小时前
linux XFS文件系统误删救星——筑梦之路
linux·运维·服务器
爱丽_2 小时前
Spring Bean 管理与依赖注入实践
java·后端·spring
m0_737302582 小时前
云服务器:企业数字化转型的核心引擎与价值重构
服务器
凯子坚持 c2 小时前
从 DeepSeek 的服务器繁忙到 Claude Code 全栈交付:2025 年 AI 原生开发实录
运维·服务器·人工智能
Coder_Boy_2 小时前
基于SpringAI的在线考试系统-数据库设计关联关系设计
服务器·网络·数据库
独自破碎E2 小时前
什么是Spring Bean?
java·后端·spring
手握风云-2 小时前
JavaEE 进阶第十期:Spring MVC - Web开发的“交通枢纽”(四)
前端·spring·java-ee
燃于AC之乐2 小时前
【Linux系统编程】进程管理探秘:从硬件架构到僵尸/孤儿进程
linux·操作系统·硬件架构·进程管理·系统编程·冯诺依曼架构·僵尸、孤儿进程