2. 授权码生命周期管理:授权码的生成、有效期、一次性使用原则
授权码(Authorization Code)是 OAuth 2.0 授权码流程中的临时凭证,用于在授权服务器与客户端之间安全地交换访问令牌。其生命周期管理直接决定了授权流程的安全性与可靠性。本章从生成算法、有效期设定、一次性使用约束三个维度展开,并结合 Redis 缓存实现方案进行阐述。
2.1 授权码的生成:不可预测性与唯一性
授权码必须满足两个核心安全属性:不可预测性(防止攻击者猜测)和唯一性(避免冲突与重放)。推荐使用密码学安全的随机数生成器(CSPRNG)生成授权码,并采用足够长的熵值。
| 生成方式 | 熵值(bits) | 示例 | 安全性评级 |
|---|---|---|---|
| UUID v4(随机) | 122 | f47ac10b-58cc-4372-a567-0e02b2c3d479 |
中等(依赖实现) |
| Base64 URL-safe 随机字节(32字节) | 256 | a3F8zT9wR5yX2pL7mN4qV6cB1jH0kG3s |
高 |
| HMAC-based 带时间戳签名 | ≥128 | t=1712345678.s=abc123def456... |
高(可校验来源) |
实践建议: 优先使用
crypto.randomBytes(32)(Node.js)或 java.security.SecureRandom(Java)生成 32 字节随机数,再通过 Base64 URL-safe 编码得到 43 字符的授权码。避免使用时间戳、序列号或用户标识作为授权码的组成部分,否则会降低不可预测性。
2.2 有效期设定:平衡安全与用户体验
授权码的有效期必须足够短,以降低泄露或被截获后的攻击窗口。OAuth 2.0 规范(RFC 6749)未强制规定具体时长,但业界最佳实践通常为 30 秒到 10 分钟。有效期设定需考虑以下因素:
- 网络延迟与用户操作时间: 用户从授权页面跳转回客户端回调地址需要时间,过短的有效期会导致正常流程失败。
- 安全风险: 授权码在浏览器 URL 中传输(即使使用 HTTPS),长期有效会增加被中间人攻击或浏览器历史记录泄露的风险。
- 并发与重放: 有效期越长,攻击者越有机会在授权码被使用前进行重放攻击。
| 场景 | 推荐有效期 | 说明 |
|---|---|---|
| 高安全敏感(金融、医疗) | 30 秒 – 60 秒 | 配合 PKCE 使用,强制短生命周期 |
| 通用 Web 应用 | 5 分钟 – 10 分钟 | 兼顾用户体验与安全 |
| 移动端 / 慢速网络 | 10 分钟 | 考虑应用切换与网络延迟 |
注意: 授权码有效期不应超过访问令牌(Access Token)的有效期。授权码仅用于交换令牌,交换完成后应立即失效,不应作为长期凭证使用。
2.3 一次性使用原则:严格防重放
授权码必须遵循一次性使用(One-Time Use)原则:一旦授权码被成功兑换为访问令牌,该授权码必须立即失效,任何后续使用该授权码的请求都应被拒绝。这是防止授权码泄露后被重放攻击的关键防线。
实现一次性使用的核心机制:
- 状态标记: 在存储层(如 Redis)为授权码维护一个状态字段,标记为
unused或consumed。 - 原子性检查与更新: 使用 Redis 的
GET+DEL或 Lua 脚本确保检查与删除操作的原子性,避免并发竞争。 - 删除即失效: 授权码被成功兑换后,立即从 Redis 中删除该键;若键不存在,则视为无效或已使用。
核心逻辑: 授权码的“一次性”不是依赖客户端承诺,而是由授权服务器通过存储层的原子操作强制保证。即使攻击者截获了授权码,也无法在合法客户端使用之后再次兑换。
2.4 Redis 缓存联动方案:生命周期管理落地
Redis 凭借其高性能、支持 TTL(Time-To-Live)自动过期以及原子操作能力,成为管理授权码生命周期的理想存储层。以下方案将生成、有效期、一次性使用三者统一实现。
2.4.1 数据结构设计
使用 Redis String 类型存储授权码的元数据,键名设计为 auth_code:{code_hash},其中 code_hash 为授权码的 SHA-256 哈希值,避免直接暴露原始授权码。
| Redis Key | Value(JSON 字符串) | TTL |
|---|---|---|
auth_code:a3f8zT9w... |
{"client_id":"app123","redirect_uri":"https://client.com/cb","code_challenge":"E9Melhoa...","status":"unused"} |
300 秒(5 分钟) |
2.4.2 生成与存储流程
// 伪代码:授权码生成与 Redis 存储
function generateAuthorizationCode(clientId, redirectUri, codeChallenge) {
// 1. 生成 32 字节随机数,Base64 URL-safe 编码
const rawBytes = crypto.randomBytes(32);
const code = base64urlEncode(rawBytes); // 43 字符
// 2. 计算哈希作为 Redis key
const codeHash = sha256(code);
// 3. 构建存储对象
const payload = {
client_id: clientId,
redirect_uri: redirectUri,
code_challenge: codeChallenge,
status: 'unused',
created_at: Date.now()
};
// 4. 写入 Redis,设置 TTL 为 300 秒
redis.set(`auth_code:${codeHash}`, JSON.stringify(payload), 'EX', 300);
// 5. 返回原始授权码给客户端(不返回哈希)
return code;
}
2.4.3 兑换与一次性校验流程
// 伪代码:授权码兑换(原子操作)
function exchangeAuthorizationCode(code, clientId, redirectUri, codeVerifier) {
const codeHash = sha256(code);
const key = `auth_code:${codeHash}`;
// 使用 Lua 脚本保证原子性:GET + 校验 + DEL
const luaScript = `
local payload = redis.call('GET', KEYS[1])
if not payload then
return nil -- 授权码不存在或已过期
end
local data = cjson.decode(payload)
if data.status ~= 'unused' then
return nil -- 已被使用
end
-- 标记为已使用(或直接删除)
redis.call('DEL', KEYS[1])
return payload
`;
const result = redis.eval(luaScript, 1, key);
if (!result) {
throw new Error('Invalid or already used authorization code');
}
// 后续校验 client_id、redirect_uri、PKCE code_verifier...
// 校验通过后颁发访问令牌
}
Redis 优势总结:
- TTL 自动过期: 无需后台定时任务清理过期授权码,Redis 自动删除过期键。
- 原子操作: Lua 脚本或
GETDEL命令(Redis 6.2+)确保检查与删除的原子性,避免并发兑换。 - 高性能: 内存操作,单次授权码兑换延迟通常在毫秒级。
- 持久化可选: 授权码为临时凭证,即使 Redis 重启丢失,仅影响进行中的授权流程,不会破坏已颁发的令牌。
2.5 生命周期状态图
下图展示了授权码从生成到失效的完整生命周期,涵盖正常兑换与异常场景。
2.6 异常场景处理
在实际运行中,授权码生命周期可能遭遇以下异常,需在设计中提前考虑:
- 并发兑换: 同一授权码在极短时间内被两个请求同时使用。通过 Redis Lua 脚本的原子性保证只有一个请求能成功兑换,另一个请求收到“授权码已使用”错误。
- 授权码泄露后未使用: 攻击者截获授权码但合法用户尚未兑换。短 TTL(如 60 秒)可大幅缩小攻击窗口。
- 客户端重复使用: 客户端因 Bug 或重试机制多次发送同一授权码。服务器端的一次性校验会拒绝后续请求,并记录安全日志。
- Redis 宕机: 授权码存储丢失。此时所有进行中的授权流程失败,用户需重新发起授权请求。由于授权码是临时凭证,这种影响是可接受的,但需配合监控告警。
安全红线: 绝对不要在授权码的 Redis value 中存储敏感信息(如密码、完整令牌)。授权码本身是临时凭证,其关联的元数据应仅包含校验所需的最小字段(client_id、redirect_uri、code_challenge 等)。