4. 令牌校验端点设计:/introspect 与 /userinfo 的区别与适用场景
在 OAuth 2.0 与 OpenID Connect (OIDC) 的混合架构中,资源服务器(Resource Server, RS)通常需要两种不同的令牌校验能力:令牌的活性与合法性校验,以及令牌持有者的身份与属性获取。这两个职责分别由 /introspect 和 /userinfo 端点承担。混淆它们的设计意图,会导致安全漏洞或性能灾难。
4.1 核心职责对比
| 对比维度 | /introspect(令牌 introspection) |
/userinfo(用户信息端点) |
|---|---|---|
| 协议归属 | OAuth 2.0 (RFC 7662) | OpenID Connect (OIDC Core 1.0) |
| 输入参数 | token(必选),token_type_hint(可选) |
Access Token(通过 Authorization Header 或 POST 参数) |
| 输出核心 | active(布尔值),scope,client_id,exp 等元数据 |
sub(用户标识),name,email,preferred_username 等用户声明 |
| 调用方 | 资源服务器(RS)或 API 网关 | 客户端应用(RP,Relying Party) |
| 安全级别 | 需要客户端认证(client_secret 或私钥 JWT) | 仅需 Bearer Token,无额外客户端认证 |
| 主要用途 | 判断令牌是否有效、未过期、未被撤销 | 获取已认证用户的标准化身份信息 |
关键原则:资源服务器应优先使用
/introspect 做令牌级校验,而非直接调用 /userinfo 来“顺便”验证令牌。后者会导致用户信息泄露风险,且无法检测令牌的 scope 与 client_id 约束。
4.2 适用场景矩阵
根据不同的架构角色与安全需求,选择正确的端点:
- API 网关 / 资源服务器(无状态校验):使用
/introspect。网关在每次请求到达后端服务前,调用 introspection 端点确认令牌是否 active,并提取 scope 做细粒度权限判断。这是最标准的 OAuth 2.0 资源保护模式。 - 微服务间调用(令牌传播):使用
/introspect。下游服务收到上游传递的令牌后,应 introspection 而非 userinfo,因为下游不需要用户个人信息,只需要令牌有效性。 - 前端 SPA / 移动端获取用户资料:使用
/userinfo。客户端在登录成功后,通过 access token 调用 userinfo 端点获取用户昵称、头像、邮箱等展示信息。 - 令牌撤销检测(黑名单场景):必须使用
/introspect。userinfo 端点无法返回active: false状态,它只会返回 401 或 403,无法区分“令牌过期”与“令牌被撤销”。 - OIDC 身份认证流程(ID Token 验证):使用
/userinfo。OIDC 标准要求 RP 在收到 ID Token 后,应调用 userinfo 端点获取用户声明,并与 ID Token 中的sub比对,防止令牌伪造。
常见错误:部分开发者将
/userinfo 作为令牌校验的唯一手段。这会导致:1) 无法检测已撤销但未过期的令牌;2) 暴露用户隐私数据给不需要的业务服务;3) 增加 userinfo 端点的负载,影响用户体验。
4.3 端点调用流程对比(SVG 架构图)
4.4 工程落地:缓存与异步刷新策略差异
由于两个端点的调用频率与安全要求不同,缓存策略也应差异化设计:
| 策略维度 | /introspect 缓存 |
/userinfo 缓存 |
|---|---|---|
| 缓存粒度 | 按 token_hash 缓存,每个令牌独立 |
按 sub(用户 ID)缓存,同一用户共享 |
| 缓存 TTL | 取令牌剩余有效期(exp - now)的 50%,最大不超过 5 分钟 | 固定 10~30 分钟,因为用户信息变更频率低 |
| 异步刷新触发条件 | 缓存剩余 TTL < 30s 时,异步预刷新,避免同步阻塞 | 缓存过期后,由下一次请求触发同步刷新,无需预刷新 |
| 缓存失效事件 | 令牌撤销事件(如用户登出)需立即清除对应缓存 | 用户资料更新事件(如修改昵称)需清除该用户缓存 |
| 存储选型 | Redis + 本地 LRU(两级缓存),降低 AS 压力 | 仅 Redis 集中缓存,避免本地缓存导致的数据不一致 |
实战建议:对于高并发资源服务器,建议对
/introspect 结果做本地内存缓存(如 Caffeine),并配合 Redis 发布/订阅机制处理令牌撤销事件。对于 /userinfo,由于调用方是客户端,QPS 通常较低,使用 Redis 集中缓存即可,无需引入本地缓存复杂度。
4.5 代码示例:Go 语言端点调用实现
以下示例展示资源服务器如何安全调用 /introspect 端点,并区分与 /userinfo 的调用方式:
// introspection 调用(资源服务器侧)
func introspectToken(ctx context.Context, token string) (*IntrospectResult, error) {
// 使用客户端凭证认证(client_secret_basic)
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret))
reqBody := url.Values{"token": {token}, "token_type_hint": {"access_token"}}
req, _ := http.NewRequestWithContext(ctx, "POST", asIntrospectURL,
strings.NewReader(reqBody.Encode()))
req.Header.Set("Authorization", "Basic "+auth)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("introspect call failed: %w", err)
}
defer resp.Body.Close()
var result IntrospectResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
if !result.Active {
return nil, ErrTokenInactive
}
return &result, nil
}
// userinfo 调用(客户端侧,仅需 Bearer Token)
func fetchUserInfo(ctx context.Context, accessToken string) (*UserInfo, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", oidcUserInfoURL, nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("userinfo call failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("userinfo returned %d", resp.StatusCode)
}
var info UserInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, err
}
return &info, nil
}
安全提醒:注意
/introspect 调用必须使用客户端认证(Basic Auth 或 Private Key JWT),而 /userinfo 仅需 Bearer Token。切勿将客户端凭证泄露给前端代码。另外,/introspect 返回的 scope 字段应作为资源服务器权限判断的依据,而非信任令牌中的自声明 scope。