本文参考了《如何在数据库中安全地存储用户密码》的选题和层次,但按当前 OWASP 与 Spring Security 文档重新校准了算法选择、参数和迁移方式。

密码存储的目标不是“数据库里看不到明文”这么简单,而是:即使攻击者拿到了完整用户表和备份,也要让每一次离线猜测都足够昂贵。

因此密码通常不应被“加密后保存”。加密是可逆的,只要密钥泄露就能批量还原;密码验证并不需要取回原文,适合的是带随机盐、可调成本的单向密码哈希。

先排除四种错误方案

明文

数据库、备份、日志或只读账号任意一处泄露,所有密码都会立即暴露。这不是“内网系统可以接受”的折中,而是无法补救的设计缺陷。

可逆加密

如果应用能解密,攻击者在拿到密钥、环境变量或应用执行权限后也能解密。除非系统必须把这个密码转交给一个只接受原始密码的旧系统,否则应重新设计认证方式,而不是保存可逆密文。

MD5、SHA-1、SHA-256

这些通用摘要算法追求速度。文件校验时这是优点,密码存储时却意味着 GPU 可以更快尝试候选密码。即使加盐,快速哈希也只是阻止预计算表,不能显著提高单次猜测成本。

自己拼接多轮哈希

“SHA-256 三次 + 自定义 salt + 一段固定字符串”仍然缺少成熟方案的参数编码、内存成本、审查和升级机制。密码学中,难以读懂不等于难以破解。

当前算法怎么选

OWASP 当前的优先建议是:

  1. 优先使用 Argon2id,最低基线为 19 MiB 内存、2 次迭代、并行度 1;
  2. 无法使用 Argon2id 时选择 scrypt;
  3. bcrypt 更适合需要兼容的存量系统,work factor 至少为 10,并注意其 72 字节输入限制;
  4. 有 FIPS-140 要求时使用 PBKDF2-HMAC-SHA-256,并按当前指南设置迭代次数。

这些是起点,不是永远不变的常量。部署前应在生产同规格硬件上压测登录延迟和并发容量,再选择攻击成本与服务容量之间的平衡点。

盐、pepper 和工作因子分别解决什么

每个密码一个随机盐

盐让相同密码产生不同结果,也让攻击者无法用一份预计算结果同时匹配所有用户。成熟库通常会自动生成盐,并把盐、算法版本和成本参数一起编码进最终字符串;应用不需要再建一个“salt 字段”手工拼接。

盐不需要保密。它的价值来自唯一性,而不是秘密性。

工作因子

工作因子控制一次验证需要多少 CPU 和内存。参数应该能随硬件发展逐步提高,所以数据库中必须保留算法和参数信息,不能只存一段无法辨认版本的摘要。

pepper

pepper 是数据库之外的额外秘密,可以保存在密钥管理服务或 HSM 中,用于纵深防御。它不是 salt 的替代品,而且轮换困难:如果旧 pepper 泄露,通常无法在不知道用户原始密码的情况下直接重算,只能要求用户重置或在后续登录时迁移。

Spring Security 中的实现

Spring Security 提供 PasswordEncoder,不要自己实现盐生成、格式拼接和比较逻辑。若希望新密码使用符合 OWASP 最低内存基线的 Argon2id,可以显式配置参数:

@Bean
PasswordEncoder passwordEncoder() {
    // salt 16 bytes, hash 32 bytes, parallelism 1,
    // memory 19 MiB, iterations 2
    return new Argon2PasswordEncoder(
            16,
            32,
            1,
            19 * 1024,
            2
    );
}

Spring Security 当前的 Argon2 实现需要 Bouncy Castle。参数也不能只照抄:上线前要测单次验证耗时、峰值登录 QPS 和内存占用,避免认证接口被昂贵哈希反向拖垮。

注册与登录只需要 encodematches

String stored = passwordEncoder.encode(rawPassword);
userRepository.savePasswordHash(userId, stored);

boolean passed = passwordEncoder.matches(loginPassword, stored);

不要把原始密码或编码前后的中间值写进日志,也不要先在前端做一次 MD5 再把结果当作“加密密码”。前端传来的固定哈希本身会变成可重放的等价密码,传输安全仍应由 TLS 保证。

存量 MD5 或 bcrypt 怎样迁移

不能在离线脚本里把 MD5 直接“升级”为 Argon2id,因为系统并不知道原始密码。可行的是渐进迁移:

  1. 存储值包含算法标识,例如 {bcrypt}...{argon2}...
  2. 登录时根据标识选择旧 encoder 验证;
  3. 验证成功后,手中暂时拥有用户本次提交的原始密码;
  4. 用当前首选算法重新编码,并立即覆盖旧值;
  5. 长期未登录的账号通过密码重置完成迁移。

Spring Security 的 DelegatingPasswordEncoder 正是为多格式验证和未来升级准备的:

String idForEncode = "argon2";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("argon2", new Argon2PasswordEncoder(
        16, 32, 1, 19 * 1024, 2));
encoders.put("bcrypt", new BCryptPasswordEncoder(12));

PasswordEncoder encoder =
        new DelegatingPasswordEncoder(idForEncode, encoders);

对没有 {id} 前缀的历史数据,可以在受控迁移期配置明确的旧格式 matcher,或先批量补上已知前缀。不要把“未知格式”静默当成明文。

登录成功后还应检查 upgradeEncoding(storedHash);如果旧值使用了旧算法或成本过低,就在当前事务中重算并更新。

哈希安全不等于账号安全

密码哈希主要防御数据库泄露后的离线破解,无法代替登录入口的在线防护。完整基线至少还包括:

  • 全程使用 TLS;
  • 对账号、IP、设备等维度限制失败尝试;
  • 错误响应不要泄露“账号是否存在”;
  • 支持多因素认证和异常登录提醒;
  • 禁止常见或已泄露密码,而不是强迫用户机械组合字符;
  • 重置令牌一次性、短有效期,并在使用后立即失效;
  • 密码、验证码和重置令牌不得进入日志、埋点或异常上报。

上线前检查清单

  • 数据库中没有明文或可逆密文密码;
  • 没有直接使用 MD5、SHA-1、SHA-256 保存密码;
  • 使用成熟库的 Argon2id、scrypt、bcrypt 或 PBKDF2 实现;
  • 每个密码自动生成独立随机盐;
  • 参数经过生产同规格硬件压测;
  • 存储格式包含算法与参数,能够渐进升级;
  • 旧哈希在登录成功后重算,未知格式默认失败;
  • 登录限流、MFA、TLS 和日志脱敏没有缺席。

参考资料