3、令牌生成安全:随机数生成器安全、密钥管理策略、令牌签名算法选择、令牌过期时间设计

令牌生成,是整个安全体系的起点。起点要是歪了,后面再多的防护措施都是白搭。我见过太多团队把精力全放在传输和存储上,结果令牌本身生成得就漏洞百出——嗯,这就像你修了个金库大门,结果钥匙是用橡皮泥捏的。

这一章,我们就把令牌生成拆成四个核心环节来讲。每个环节都踩过坑,也填过坑,希望能帮你少走弯路。

3.1 随机数生成器安全:别让“随机”变成“可预测”

令牌的本质,是一个让服务端和客户端都能信任的随机凭证。如果这个“随机”不够随机,那攻击者就能猜出来。

核心原则:永远使用密码学安全的伪随机数生成器(CSPRNG)。

普通语言的 Math.random() 或 rand() 是给游戏、模拟用的,不是给安全用的。它们基于线性同余算法,周期短、可预测。我曾在一次代码审计中看到,某团队用 Java 的 java.util.Random 生成 API 令牌。结果呢?攻击者只要拿到两个连续令牌,就能推算出种子,进而预测后续所有令牌。那次事故,直接导致全站令牌批量失效。

避坑指南: 我曾经接手过一个项目,他们用当前时间戳 + 用户ID 拼接后做 MD5 来生成令牌。这根本不算随机,时间戳和用户ID都是可枚举的。攻击者只要知道用户注册时间,就能暴力枚举出令牌。记住:不可预测性 ≠ 哈希运算,哈希只是让数据看起来乱,但输入一旦被猜到,输出就毫无秘密可言。

正确的做法,是使用操作系统提供的 CSPRNG 接口:

语言/平台 推荐 API 说明
Python secrets.token_hex() Python 3.6+ 专用安全模块
Java java.security.SecureRandom 不要用 new Random()
Node.js crypto.randomBytes() 比 Math.random() 安全得多
Go crypto/rand.Read() 标准库自带
.NET System.Security.Cryptography.RandomNumberGenerator 替代已过时的 RNGCryptoServiceProvider

我个人习惯,生成令牌时至少取 32 字节(256 位)的随机数,然后做 Base64 编码。这样出来的令牌长度适中,熵值也够用。

// Node.js 示例:生成安全的随机令牌
const crypto = require('crypto');
const token = crypto.randomBytes(32).toString('base64url');
console.log(token); // 输出类似:5x8Pq3Z...(共43字符)
小技巧: 用 Base64URL 编码代替标准 Base64,可以避免 + 和 / 字符在 URL 中需要转义的问题。省心又安全。

3.2 密钥管理策略:密钥泄露 = 令牌系统崩塌

令牌签名需要密钥。密钥一旦泄露,攻击者就能伪造任意令牌。这道理谁都懂,但实际操作中,我见过太多把密钥硬编码在代码里、上传到 GitHub、或者放在配置文件里不加保护的情况。

密钥管理的三个层次:

  1. 存储安全:密钥不能出现在代码仓库中。使用环境变量、密钥管理服务(如 AWS KMS、HashiCorp Vault)或硬件安全模块(HSM)。
  2. 轮换策略:密钥不能一成不变。我建议每 90 天轮换一次。轮换时,旧密钥要保留一个缓冲期(比如 24 小时),让已签发的令牌能正常验证通过。
  3. 访问控制:谁有权限读取密钥?生产环境的密钥,只有 CI/CD 系统和运维人员能接触。开发者本地开发应该用独立的测试密钥。
我踩过的坑: 有一次,团队把 JWT 密钥写在了 Spring Boot 的 application.yml 里,然后整个项目被推到了公开仓库。虽然几分钟后就删除了,但已经被爬虫抓取。我们不得不紧急轮换所有密钥,并通知所有用户重新登录。那次之后,我强制在 CI 流程中加入了密钥扫描步骤,任何包含疑似密钥的提交都会被拦截。

密钥的生成,也要用 CSPRNG。别自己拍脑袋想一个字符串,那熵值可能低得可怜。

# 生成 256 位 HMAC 密钥(适合 HS256)
openssl rand -base64 32
# 输出示例:uD39x8zF3a...(共44字符)

3.3 令牌签名算法选择:对称还是非对称?

这是令牌生成中最容易出技术决策偏差的地方。说白了,就是选 HMAC(对称)还是 RSA/ECDSA(非对称)。

特性 HMAC(HS256/HS384/HS512) RSA(RS256) ECDSA(ES256)
密钥类型 对称(同一个密钥签名和验证) 非对称(私钥签名,公钥验证) 非对称(私钥签名,公钥验证)
性能 快(纯哈希运算) 慢(大数运算) 较快(椭圆曲线运算)
密钥分发 需要安全通道共享密钥 公钥可公开,私钥保密 公钥可公开,私钥保密
适用场景 单一服务、内网环境 微服务、第三方验证 移动端、IoT 设备

我的建议:

  • 如果你的系统是单体架构,或者只有几个内部服务互相调用,用 HMAC(HS256)就够了。简单、高效。
  • 如果你有多个服务,或者需要让第三方验证令牌,用 RSA(RS256)或 ECDSA(ES256)。这样你只需要把公钥给出去,私钥永远留在自己手里。
  • 绝对不要用 none 算法。JWT 标准允许不签名,但生产环境必须禁用。我见过攻击者把算法改成 none,然后服务端没校验,直接放行——这相当于把大门敞开了。
算法混淆攻击: 我曾经在渗透测试中遇到过,服务端用 RS256 签发令牌,但验证时却允许 HS256。攻击者拿到公钥后,用公钥作为 HMAC 的密钥来签名,服务端竟然验证通过了。为什么?因为公钥是公开的,攻击者可以用它来伪造令牌。解决方案:验证时强制指定算法,不要依赖 JWT 头部的 alg 字段。

3.4 令牌过期时间设计:短命令牌 + 刷新机制

令牌过期时间,是安全性和用户体验的平衡点。时间太短,用户频繁登录;时间太长,令牌泄露后风险窗口太大。

我的经验法则:

  • 访问令牌(Access Token):15 分钟到 1 小时。用于 API 请求的凭证,越短越好。
  • 刷新令牌(Refresh Token):7 天到 30 天。用于获取新的访问令牌,可以长一些,但必须更安全地存储。

为什么这么设计?你想想看,如果访问令牌泄露了,攻击者最多能用 15 分钟。而刷新令牌泄露了,虽然窗口更长,但刷新令牌通常只用于后台交换,不会随请求到处传输,暴露面小得多。

滑动过期策略: 我习惯在刷新令牌时,同时检查用户活跃度。如果用户一直在用,每次刷新时把过期时间往后推。如果用户超过 N 天没活动,强制重新登录。这样既保证了安全,又不会让长时间在线的用户突然掉线。

过期时间的设计,还要考虑时区和服务器时钟偏差。我建议在令牌中存储 iat(签发时间)和 exp(过期时间),都用 UTC 时间戳。验证时,允许 30 秒的时钟偏差。

// JWT 负载示例
{
  "sub": "user_12345",
  "iat": 1700000000,   // 签发时间(UTC)
  "exp": 1700000900,   // 过期时间(15分钟后)
  "scope": "read write"
}

最后,别忘了在服务端维护一个令牌黑名单。用户主动登出、修改密码、或者检测到异常行为时,把对应的令牌加入黑名单。即使它还没过期,也不能再用了。

令牌生成安全核心流程 1. 随机数生成 CSPRNG(如 crypto.randomBytes) 2. 密钥管理 存储 / 轮换 / 访问控制 3. 签名算法 HMAC / RSA / ECDSA 4. 令牌组装(JWT 格式) Header + Payload + Signature 5. 过期时间设计 访问令牌(15min)+ 刷新令牌(7-30天) 6. 黑名单 / 吊销机制 每个环节都依赖前一个环节的正确性,任何一个环节出问题,整个令牌体系都不安全

总结一下:令牌生成不是写几行代码就完事的事。随机数要用 CSPRNG,密钥要藏好并定期换,签名算法要根据场景选,过期时间要短并配合刷新机制。这四个环节,环环相扣,缺一不可。