3、令牌生成安全:随机数生成器安全、密钥管理策略、令牌签名算法选择、令牌过期时间设计
令牌生成,是整个安全体系的起点。起点要是歪了,后面再多的防护措施都是白搭。我见过太多团队把精力全放在传输和存储上,结果令牌本身生成得就漏洞百出——嗯,这就像你修了个金库大门,结果钥匙是用橡皮泥捏的。
这一章,我们就把令牌生成拆成四个核心环节来讲。每个环节都踩过坑,也填过坑,希望能帮你少走弯路。
3.1 随机数生成器安全:别让“随机”变成“可预测”
令牌的本质,是一个让服务端和客户端都能信任的随机凭证。如果这个“随机”不够随机,那攻击者就能猜出来。
核心原则:永远使用密码学安全的伪随机数生成器(CSPRNG)。
普通语言的 Math.random() 或 rand() 是给游戏、模拟用的,不是给安全用的。它们基于线性同余算法,周期短、可预测。我曾在一次代码审计中看到,某团队用 Java 的 java.util.Random 生成 API 令牌。结果呢?攻击者只要拿到两个连续令牌,就能推算出种子,进而预测后续所有令牌。那次事故,直接导致全站令牌批量失效。
正确的做法,是使用操作系统提供的 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字符)
3.2 密钥管理策略:密钥泄露 = 令牌系统崩塌
令牌签名需要密钥。密钥一旦泄露,攻击者就能伪造任意令牌。这道理谁都懂,但实际操作中,我见过太多把密钥硬编码在代码里、上传到 GitHub、或者放在配置文件里不加保护的情况。
密钥管理的三个层次:
- 存储安全:密钥不能出现在代码仓库中。使用环境变量、密钥管理服务(如 AWS KMS、HashiCorp Vault)或硬件安全模块(HSM)。
- 轮换策略:密钥不能一成不变。我建议每 90 天轮换一次。轮换时,旧密钥要保留一个缓冲期(比如 24 小时),让已签发的令牌能正常验证通过。
- 访问控制:谁有权限读取密钥?生产环境的密钥,只有 CI/CD 系统和运维人员能接触。开发者本地开发应该用独立的测试密钥。
密钥的生成,也要用 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,然后服务端没校验,直接放行——这相当于把大门敞开了。
alg 字段。
3.4 令牌过期时间设计:短命令牌 + 刷新机制
令牌过期时间,是安全性和用户体验的平衡点。时间太短,用户频繁登录;时间太长,令牌泄露后风险窗口太大。
我的经验法则:
- 访问令牌(Access Token):15 分钟到 1 小时。用于 API 请求的凭证,越短越好。
- 刷新令牌(Refresh Token):7 天到 30 天。用于获取新的访问令牌,可以长一些,但必须更安全地存储。
为什么这么设计?你想想看,如果访问令牌泄露了,攻击者最多能用 15 分钟。而刷新令牌泄露了,虽然窗口更长,但刷新令牌通常只用于后台交换,不会随请求到处传输,暴露面小得多。
过期时间的设计,还要考虑时区和服务器时钟偏差。我建议在令牌中存储 iat(签发时间)和 exp(过期时间),都用 UTC 时间戳。验证时,允许 30 秒的时钟偏差。
// JWT 负载示例
{
"sub": "user_12345",
"iat": 1700000000, // 签发时间(UTC)
"exp": 1700000900, // 过期时间(15分钟后)
"scope": "read write"
}
最后,别忘了在服务端维护一个令牌黑名单。用户主动登出、修改密码、或者检测到异常行为时,把对应的令牌加入黑名单。即使它还没过期,也不能再用了。
总结一下:令牌生成不是写几行代码就完事的事。随机数要用 CSPRNG,密钥要藏好并定期换,签名算法要根据场景选,过期时间要短并配合刷新机制。这四个环节,环环相扣,缺一不可。