为什么数据库里的密码是一串乱码?

写在三部曲收官
写这个博客的时候,我在 seed 脚本里给管理员设了个初始密码 admin123。存进数据库之前,我顺手查了眼 users 表,看到 password 字段是这样的:
$2a$10$N9qo8uLOickgx2ZMRZoMye...
一串乱码。当时脑子里冒出三个问题:
- 这串乱码是怎么来的?
- 都说它「不可逆」,那登录的时候系统怎么知道我密码对不对?
- 为什么不干脆加密存,需要的时候解密比对?
这是登录三部曲的最后一篇。第一篇讲传输(HTTPS 为什么能防抓包),第二篇讲会话(JWT 鉴权到底做了什么),这一篇补上最后一环——密码落到数据库那一刻。前两篇防的是「传输途中被偷」,这一篇防的是「数据库被整个端走」。
1)先排除两个错误答案
错误答案一:明文存
最直观,也最危险。数据库一旦被拖走(SQL 注入、备份泄露、离职员工带走了导出文件),所有用户的密码当场全部暴露。
很多人对拖库没体感,因为它很少上新闻。但想一想:你在一个小网站注册过的密码,和你的邮箱、支付密码,是同一个吗?攻击者拿到 A 站的明文密码,第一件事就是去 B 站试——这叫撞库。你泄露的不只是一个账号,是所有复用这个密码的地方。
错误答案二:「那我加密存总行了吧」
用 AES 把密码加密后存进数据库,需要时解密出来比对——听起来更高级,其实是安全幻觉:
- 加密是可逆的:只要有密钥,就能还原出明文
- 而密钥通常就放在同一台服务器的环境变量或配置文件里
- 能拖走数据库的人,大概率也能拿到配置——两把钥匙挂在同一扇门上
判断标准很简单:验证密码根本不需要「还原」它。
正确的思路转折
系统从来不需要知道你的密码是什么。它只需要知道:你这次输入的,和当初注册时输入的,是不是同一个。
那就够了——把密码「压」成一个指纹(哈希)存起来,登录时把你输入的重新压一遍,比对两个指纹是否一致。指纹本身不可逆,拖走了也算不出原密码。
从此,问题从「怎么藏」变成了另一个问题:怎么让这个指纹算得足够慢。慢到合法服务器算一次无所谓,攻击者暴力试十亿次试不起。
2)哈希演进史:每一代都是被攻击逼出来的
密码哈希不是某个天才的一次性设计,而是三十年攻防对抗的接力。看懂「上一代怎么死的」,每一代的设计就都顺理成章了。
第一代:快哈希(MD5 / SHA-256)
它们本来是为文件校验设计的——下载一个 2GB 的镜像,算个哈希对一下没没损坏。快,是它们的设计目标。
但用在密码上,快变成了灾难:攻击者拿到哈希后,可以高速试错。更狠的是彩虹表:有人提前把常见密码的哈希全部算好,做成一张几十亿条的对照表,你的哈希一查就出原码——连实时计算都不用。
补丁:加盐。 注册时给每个用户生成一段随机数(盐),拼在密码前面一起哈希。同一个密码 admin123,张三和李四算出的哈希完全不同——预计算的彩虹表瞬间作废。
但盐救不了速度。现代 GPU 每秒能算几十亿次 SHA-256,把字典里几百万个常见密码挨个加盐试一遍,成本依然低到离谱。
第二代:慢哈希(bcrypt / scrypt)
思路反过来:故意设计得慢。bcrypt 内部跑几千轮加密运算,单次哈希耗时约 100 毫秒——对登录场景毫无影响(用户等 100ms 没感觉),但对攻击者是灭顶之灾:
SHA-256:每秒几十亿次 → bcrypt:单服务器每秒几千次。攻击成本直接涨了六个数量级。
bcrypt 还有一个贴心的设计:盐是自动生成、自动内置在哈希字符串里的(下一节拆开看),想忘都忘不掉。
第三代:内存困难(Argon2)
慢还不够——GPU 算力便宜,堆显卡照样划算。2015 年密码哈希竞赛(PHC)的冠军 Argon2 加了第二道门槛:吃内存。每次哈希要占用几百 MB 内存,而 GPU 的显存又贵又小,大规模并行直接不经济。
| 方案 | 速度 | 内存 | 盐 | 现状 |
|---|---|---|---|---|
| MD5 | 极快 | 极低 | 无 | ❌ 密码场景已判死刑 |
| SHA-256 | 极快 | 极低 | 需自己拼 | ❌ 快就是原罪 |
| bcrypt | 慢(可调) | 低 | 内置 | ✅ 存量最大,依然合格 |
| scrypt | 慢 | 高 | 内置 | ✅ 过渡角色 |
| Argon2id | 慢(可调) | 高(可调) | 内置 | ✅ 新系统首选 |
一句话总结:快哈希输在快,bcrypt 赢在慢,Argon2 赢在又慢又贵。
3)回到我的项目:解剖那串 $2a$10$...
这个博客真实的密码存储代码(lib/auth.ts,总共两行核心):
import bcrypt from "bcryptjs"
// 注册时:存指纹
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10)
}
// 登录时:重算比对
export async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
return bcrypt.compare(password, hashedPassword)
}
现在把数据库里那串乱码拆开看,每一截都有意义:
四个「原来如此」:
① 盐就存在字符串里。 所以验证时不需要额外的盐字段——从哈希串里切出来就行。这也是为什么加盐这件事在 bcrypt 里不可能被忘记。
② compare 不是解密。 bcrypt.compare(输入, 哈希) 做的事是:从哈希串里取出盐和 cost → 用同样的参数把用户输入重算一遍 → 比对两段哈希。全程没有任何「还原」动作。
③ cost=10 意味着 2¹⁰ = 1024 轮内部运算。 登录时那 100ms 左右的耗时不是性能问题,是防线本身——服务器嫌慢的,攻击者更嫌慢。
④ cost 也在字符串里。 明天你把 cost 调到 12,老用户验证通过后 rehash 存回去,新串自动带新 cost。算法升级可以平滑滚动。
顺带一提:这个博客的「加密文章解锁」接口里,我用了恒定时间比较(先比长度再 Buffer.equals)来防计时攻击——和密码比对是同一个思想:不让攻击者通过响应快慢推测出任何信息。
4)工程决策:cost 怎么选、老系统怎么迁
原理之外的四个实战问题:
cost 的权衡
cost 每加 1,耗时翻倍。它同时压着三方:
| cost | 服务器单次耗时(量级) | 登录体感 | 攻击者单卡每秒 |
|---|---|---|---|
| 8 | ~25ms | 无感 | 数万次 |
| 10 | ~100ms | 无感 | 数千次 |
| 12 | ~400ms | 可感知 | 数百次 |
| 14 | ~1.6s | 太慢 | 几十次 |
经验值:个人项目 10,生产服务 11-12,先在本机实测别照抄。 新系统直接上 Argon2id,参数调到登录耗时落在 100-300ms 区间即可。
存量 MD5 怎么迁移
老系统库里躺着五百万条 MD5,不可能让全体用户改密码。标准做法是登录时透明升级:
用户登录 → 先按 MD5 算一遍比对
→ 通过?立刻用 bcrypt 重新哈希,覆盖旧值
→ 不通过?照常拒绝
用户无感知,存量密码在一次次登录中自然换代。长期不登录的账号,强制下次登录时走「忘记密码」重置即可。
密码策略:长度 > 复杂度
「必须大小写 + 数字 + 特殊字符」这套老规矩正在被抛弃(NIST 800-63B 早已转向)。它逼出一堆 Admin123! 这种伪强密码,而真正防爆破的是长度。更有效的做法是:
- 最小长度设 8-10 位,不设上限(截断上限反而伤真强密码)
- 维护一份常见弱密码黑名单,注册时拦截
admin123、123456789、password - 不再强制定期改密码(NIST:除非有泄露证据)
bcrypt 的 72 字节坑
bcrypt 只处理密码的前 72 字节,超出部分静默截断。英文场景几乎碰不到,但中文场景要留心:UTF-8 里一个汉字 3 字节,72 字节只装得下 24 个汉字——对登录密码来说够用,但如果你把它拿去哈希长文本(比如 API token、文件摘要),就是 bug。另外不要试图「先 SHA-256 再 bcrypt」自己造混合方案,Argon2 已经把这件事做对了。
5)三部曲合体:一次登录的完整防线
三篇拼起来,才是「登录安全」的完整答案:
每一层各防一种攻击,谁也替代不了谁:
| 攻击方式 | 防线 | 出自 |
|---|---|---|
| 公共 WiFi / 中间人抓包 | TLS 传输加密 | 第一篇 |
| 数据库被拖走 | 单向哈希 + 每用户独立盐 | 本篇 |
| 字典 / 暴力破解 | 慢哈希(cost)+ 登录限流 | 本篇 |
| 撞库(他站密码复用) | 弱密码黑名单 | 本篇 |
| token 被偷 | 短 access + refresh 可撤销 | 第二篇 |
| 内部人员作案 | 库里无明文可看 | 本篇 |
删掉任何一层,剩下的层都补不上那个洞——这就是为什么它们值得写成三部曲。
结尾:密码存储自检清单
上线前过一遍:
- 存的是单向哈希(bcrypt / Argon2id),不是明文、不是可逆加密
- 每个用户的盐独立且随机(bcrypt 内置,别自己拼)
- cost 实测过,登录耗时在 100ms 量级,且随硬件升级重评
- 登录 / 注册接口有限流(我这个博客是每分钟 3 次)
- 存量弱哈希(MD5/SHA)有登录时透明升级方案
- 日志和错误上报里永不出现密码字段
- 常见弱密码黑名单已启用(
admin123就不该能注册成功)
最后用三句话收束整个系列。我现在判断一个系统的登录链路合不合格:
传输加密吗?密码算得慢吗?会话能撤销吗?
三个都点头,才算从「能跑」走到了「能扛」。
评论
登录后即可发表评论