:密码到底怎么存?为什么 MD5 已经过时?)
上一篇我们讲了一个基础概念MD5 不是加密而是摘要 / 哈希。很多老项目里我们经常会看到这样的代码String password md5(rawPassword); user.setPassword(password);或者稍微复杂一点String password md5(rawPassword salt); user.setPassword(password);以前很多人会说密码用 MD5 加密一下再存数据库。但严格来说这句话有两个问题1. MD5 不是加密是哈希 / 摘要。 2. MD5 已经不适合作为现代密码存储方案。那密码到底应该怎么存这一篇就专门讲清楚1. 密码为什么不能明文存 2. 密码为什么不需要解密 3. MD5(password) 为什么不安全 4. MD5(password salt) 为什么仍然不够 5. bcrypt / Argon2id / PBKDF2 是什么 6. Spring Security 里应该怎么落地 7. 老项目里的 MD5 密码应该怎么迁移一、密码绝对不能明文存最错误的做法是username wu password 123456也就是数据库里直接保存用户原始密码。这种问题非常严重。一旦数据库泄漏攻击者拿到的就是所有用户的真实密码。更严重的是很多用户会在多个平台使用相同密码。所以一个系统泄漏可能会导致用户在其他平台也被撞库。因此后端不应该保存用户原始密码后端应该保存的是密码哈希后的结果也就是rawPassword ↓ password hash ↓ 存数据库二、密码为什么不需要“解密”很多人第一次接触密码哈希时会有一个疑问密码哈希之后无法还原那用户下次登录时怎么验证答案是密码验证不需要解密。注册时用户输入密码123456 ↓ 后端做密码哈希 ↓ 数据库保存 password_hash登录时用户再次输入密码123456 ↓ 后端用同样算法重新计算 ↓ 和数据库里的 password_hash 比较如果匹配说明密码正确。所以密码验证的核心不是数据库里的密码 → 解密成明文 → 比较而是用户输入的密码 → 再算一次 hash → 比较 hash也就是说密码存储的目标就是让系统自己也无法还原用户密码。这也是为什么正规系统一般不提供“找回原密码”而是提供“重置密码”。因为系统自己也不应该知道用户原密码。三、MD5(password) 的问题早期很多项目会这样存密码String encodedPassword md5(rawPassword);比如rawPassword 123456 MD5(rawPassword) e10adc3949ba59abbe56e057f20f883e数据库保存password e10adc3949ba59abbe56e057f20f883e登录时String inputPassword request.getPassword(); String inputMd5 md5(inputPassword); if (inputMd5.equals(user.getPassword())) { // 登录成功 }这套逻辑能跑。但问题是MD5 太快了。快在正常业务里是优点但在密码存储里反而是缺点。因为一旦数据库泄漏攻击者可以疯狂猜密码。比如攻击者拿到e10adc3949ba59abbe56e057f20f883e他不用“解密”他只需要提前准备常见密码表123456 - e10adc3949ba59abbe56e057f20f883e 111111 - 96e79218965eb72c92a549dd5a330112 password - 5f4dcc3b5aa765d61d8327deb882cf99 qwerty - d8578edf8458ce06fbc5bb76a58c5ca4一查就知道e10adc3949ba59abbe56e057f20f883e 123456这不是 MD5 被“解密”了而是密码太常见被猜中了。四、那加 salt 不就行了吗很多项目后来升级成String encodedPassword md5(rawPassword salt);比如password 123456 salt abc001 hash MD5(123456 abc001)这比单纯 MD5(password) 要好。因为如果两个用户密码一样但 salt 不一样最终 hash 也不一样。比如用户A password 123456 salt abc001 hash MD5(123456 abc001) 用户B password 123456 salt xyz999 hash MD5(123456 xyz999)这样至少解决了两个问题1. 相同密码不会得到相同 hash。 2. 通用彩虹表不能直接套所有用户。但是MD5 salt 仍然不够。为什么因为 MD5 还是太快。攻击者拿到数据库后通常也能看到 salt。比如username wu salt abc001 password_hash xxxxxx攻击者可以针对这个 salt 重新猜MD5(123456 abc001) MD5(111111 abc001) MD5(password abc001) MD5(qwerty abc001)salt 不需要保密它只是让每个用户的 hash 独立。真正的问题是攻击者每猜一次的成本太低。所以现代密码存储不能只靠 MD5 salt。五、密码存储真正需要什么特性密码存储需要的不是“快”而是“慢”。准确说需要这些特性1. 不可逆 2. 每个用户不同 salt 3. 计算成本可调 4. 抗暴力猜测 5. 抗 GPU / 专用硬件批量破解MD5 的问题不是不能哈希。MD5 的问题是太快了。密码存储算法应该故意慢。正常用户登录一次慢几十毫秒、几百毫秒用户几乎无感。但攻击者要猜几千万、几亿个密码时成本就会被放大。这就是 bcrypt、Argon2id、PBKDF2 这类算法的意义。六、bcrypt 是什么bcrypt 是一种专门用于密码存储的哈希算法。它不是加密不能解密。它的特点是1. 不可逆 2. 自带 salt 3. 有 cost 成本因子 4. 可以故意变慢 5. 适合密码存储bcrypt 生成的结果大概长这样$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy这串里面包含了算法版本 cost 成本因子 salt 最终 hash所以使用 bcrypt 时通常不需要自己单独设计 salt 字段。登录验证时框架会从这串结果中解析出 salt 和 cost然后用用户输入的密码重新计算并比较。七、Argon2id 是什么Argon2id 是更新一代的密码哈希算法。你可以简单理解为bcrypt成熟、老牌、兼容性强 Argon2id更新、更强强调内存成本Argon2id 不只是让计算变慢还会增加内存消耗。这对攻击者批量破解更不友好。因为攻击者不只是要拼 CPU还要拼内存成本。所以在安全要求更高的新系统里Argon2id 是很好的选择。不过在很多 Java / Spring 项目里bcrypt 仍然非常常见。原因是1. Spring Security 支持简单 2. 生态成熟 3. 团队接受度高 4. 上手成本低 5. 线上兼容性好所以实际项目里可以先选 bcrypt把体系跑通。八、PBKDF2 是什么PBKDF2 也是一种常见的密码派生算法。它的思路是对密码做多轮迭代计算让计算变慢。比如不是 hash 一次而是 hash 很多次。PBKDF2 标准化时间久兼容性强在一些系统和合规场景里也经常见到。所以常见推荐方案是Argon2id bcrypt PBKDF2这三个都比直接 MD5(password) 更适合密码存储。九、MD5、bcrypt、Argon2id 的区别可以用这张表理解。算法能不能还原是否适合密码存储主要问题 / 特点MD5不能不推荐太快容易被暴力猜测SHA-256不能不推荐直接用于密码也是快速哈希MD5 salt不能不推荐解决相同密码同 hash但仍然太快bcrypt不能推荐自带 saltcost 可调成熟Argon2id不能推荐更新内存成本更强PBKDF2不能可用多轮迭代兼容性好注意不能还原不是问题。密码本来就不应该能还原。真正要看的是攻击者猜密码的成本够不够高。十、Spring Security 里怎么落地在 Spring Security 里不建议自己手写 MD5。更推荐使用 PasswordEncoder。比如使用 bcryptBean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }注册时public void register(RegisterRequest request) { String rawPassword request.getPassword(); String encodedPassword passwordEncoder.encode(rawPassword); User user new User(); user.setUsername(request.getUsername()); user.setPassword(encodedPassword); userMapper.insert(user); }登录时public boolean login(LoginRequest request) { User user userMapper.findByUsername(request.getUsername()); if (user null) { return false; } return passwordEncoder.matches( request.getPassword(), user.getPassword() ); }这里的关键是passwordEncoder.matches(rawPassword, encodedPassword)matches()不是解密。它是用用户输入的原始密码结合 encodedPassword 中的算法信息、salt、cost重新计算并比较。十一、为什么不建议客户端 MD5 后再传很多 App 老项目里会这样val passwordMd5 md5(password) api.login(username, passwordMd5)看起来好像安全因为没有直接传原始密码。但问题是passwordMd5 本身变成了等价密码。也就是说后端如果认可这个 MD5 值登录那么攻击者只要拿到这个 MD5 值就不需要知道原始密码也可以直接登录。所以客户端 MD5 不是安全方案。正确做法通常是客户端输入原始密码 ↓ 通过 HTTPS/TLS 传输 ↓ 后端用 bcrypt / Argon2id / PBKDF2 校验注意这里的“原始密码”不是 HTTP 明文裸奔。它必须走 HTTPS/TLS 加密通道。客户端要做的是不保存密码 不打印密码 不把密码放 URL 不自己用 MD5 伪装成安全十二、密码和 Token 的处理方式不一样这个点非常关键。密码和 Token 不是一类东西。1. 密码密码后端只需要验证不需要还原。所以用bcrypt / Argon2id / PBKDF2也就是不可逆哈希。流程用户输入密码 ↓ 密码哈希 ↓ 数据库保存 hash2. TokenToken 后续还要拿出来请求接口。比如Authorization: Bearer accessToken所以 Token 不能用 MD5。如果你写MD5(token)那后面就拿不回原始 Token 了。Token 本地存储应该用AES-GCM 加密 Android Keystore 保护 AES key流程Token 明文 ↓ AES-GCM 加密 ↓ Token 密文 ↓ 本地保存请求时Token 密文 ↓ AES-GCM 解密 ↓ Token 明文 ↓ Authorization Header所以一句话密码用哈希因为不需要还原Token 用加密因为后面还要还原出来使用。十三、老项目已经用了 MD5怎么办如果老项目数据库里已经存了password MD5(password)或者password MD5(password salt)不要直接全部改成 bcrypt。因为你没有用户原始密码无法直接重新计算 bcrypt。正确方式是兼容旧密码 登录成功后升级流程用户登录 ↓ 发现数据库里是旧 MD5 格式 ↓ 用旧 MD5 逻辑验证 ↓ 验证成功 ↓ 拿到用户这次输入的原始密码 ↓ 重新生成 bcrypt ↓ 更新数据库这样用户无感知系统逐步迁移。十四、密码算法最好带版本标识为了支持迁移数据库里的密码字段最好能看出算法。比如{md5}e10adc3949ba59abbe56e057f20f883e {bcrypt}$2a$10$xxxxxxxxxxxxxxxx或者单独加字段password_hash password_algo我更推荐第一种风格因为很多框架也支持类似格式。例如{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMye...这样登录时可以根据前缀判断{md5} → 用旧 MD5 校验 {bcrypt} → 用 BCryptPasswordEncoder 校验十五、MD5 到 bcrypt 的迁移示例示例代码public boolean login(String username, String rawPassword) { User user userMapper.findByUsername(username); if (user null) { return false; } String storedPassword user.getPassword(); if (storedPassword.startsWith({md5})) { String oldHash storedPassword.replace({md5}, ); String inputHash md5(rawPassword); if (oldHash.equalsIgnoreCase(inputHash)) { String newHash {bcrypt} passwordEncoder.encode(rawPassword); userMapper.updatePassword(user.getId(), newHash); return true; } return false; } if (storedPassword.startsWith({bcrypt})) { String bcryptHash storedPassword.replace({bcrypt}, ); return passwordEncoder.matches(rawPassword, bcryptHash); } return false; }这个逻辑的核心是旧用户第一次登录时顺手升级密码算法。这样不需要强制所有用户改密码。但如果安全风险较高也可以要求用户重置密码。十六、Spring Security 的 DelegatingPasswordEncoderSpring Security 里还有一个比较适合迁移场景的设计DelegatingPasswordEncoder它的思想就是一个 PasswordEncoder 支持多种算法。数据库密码格式类似{bcrypt}$2a$10$xxxx {noop}123456 {pbkdf2}xxxx前面的{bcrypt}、{pbkdf2}就是算法标识。登录时根据标识选择对应的 PasswordEncoder。这个思想很适合老系统迁移。你也可以自己实现类似逻辑。十七、密码存储还要配合哪些安全措施密码哈希不是唯一安全措施。完整登录安全还包括1. 登录接口必须走 HTTPS。 2. 密码不能打印日志。 3. 登录失败要限流。 4. 多次失败可以加验证码。 5. 高风险登录可以短信 / 邮箱验证。 6. 修改密码后让旧 Token 失效。 7. 数据库密码字段不能返回给前端。 8. 管理后台不能展示用户密码。 9. 生产环境不能把请求体完整打到日志里。密码哈希解决的是数据库泄漏后密码不容易被还原。但它不解决接口暴力破解 日志泄漏密码 弱密码 撞库攻击 Token 泄漏所以密码安全要放在整个认证体系里看。十八、常见错误总结错误 1密码明文存数据库严重错误。password 123456错误 2认为 MD5 是加密不准确。MD5 是摘要不能解密。错误 3客户端 MD5 后再传就安全了不对。MD5 值会变成等价密码。错误 4MD5 salt 就足够了不够。salt 解决相同密码同 hash 和彩虹表问题但 MD5 仍然太快。错误 5登录时把数据库密码解密出来比较不应该这样设计。密码应该不可逆登录时重新计算 hash 后比较。错误 6用 AES 加密密码存数据库也不推荐。如果用 AES说明后端理论上可以解密出用户原始密码。密码存储不应该可逆。密码应该使用专门的密码哈希算法。十九、最终建议如果是新项目建议直接bcrypt / Argon2id / PBKDF2如果是 Spring Boot / Spring Security 项目先用 bcrypt 就够了Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }如果是老项目已经用了 MD5不要直接强行替换。 先兼容旧 MD5。 用户登录成功后升级成 bcrypt。如果安全要求更高可以考虑 Argon2id。 可以考虑 pepper。 可以增加登录限流、验证码、MFA、风控。二十、总结密码到底怎么存一句话密码不应该明文存也不应该用 MD5 简单摘要存而应该使用 bcrypt / Argon2id / PBKDF2 这类专门的密码哈希算法。再展开一点密码不需要还原所以不要用可逆加密。 MD5 不是加密是摘要。 MD5 太快不适合现代密码存储。 salt 可以让相同密码 hash 不一样但不能解决 MD5 太快的问题。 bcrypt / Argon2id / PBKDF2 会故意提高计算成本让攻击者批量猜密码变得更困难。 登录验证不是解密密码而是用用户输入的密码重新计算后比较。 老项目 MD5 密码可以通过“登录成功后升级”的方式平滑迁移。如果用一句话串起来密码用哈希因为不需要还原Token 用加密因为后面还要使用。密码哈希要慢Token 加密要可逆。这句话理解了密码存储和 Token 存储就不会再混了。