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 架构图)

/introspect 与 /userinfo 调用流程对比 /introspect 流程(资源服务器) 客户端请求 API 网关 Introspect 后端服务 /userinfo 流程(客户端) 用户浏览器/App 客户端应用 (RP) UserInfo 端点 用户资料展示 左侧:资源服务器通过 introspection 校验令牌活性,不接触用户隐私数据 右侧:客户端应用通过 userinfo 获取用户展示信息,不负责令牌有效性判断

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。