前言
在使用 Spring Security OAuth2 Authorization Server 时,很多开发者对 /userinfo 端点存在两个常见的疑问:
- 如果 ID Token 已经包含了用户信息,为什么还需要单独的
/userinfo端点? - 请求
/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
原因分析:
- ID Token 的 claims 被意外删除(如在 TokenCustomizer 中移除了
sub) 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 控制 |
参考资料
- OpenID Connect Core 1.0 - UserInfo Endpoint
- Spring Security OAuth2 Authorization Server
- RFC 6749 - OAuth 2.0 Authorization Framework
本文基于 Spring Security OAuth2 Authorization Server 7.0.2 版本编写