密码安全与 Argon2 原理

认证系统首先要安全保存密码。JWT 只能在登录成功后表示身份,不能代替密码哈希。

1. 为什么不能保存明文密码

错误的数据结构:

{
  "username": "zhangsan",
  "password": "123456"
}

一旦数据库、备份或日志泄露,攻击者会立即得到所有密码。用户还可能在多个网站复用密码,影响范围会扩散到其他系统。

数据库应该保存:

{
  "username": "zhangsan",
  "passwordHash": "$argon2id$v=19$m=65536,t=3,p=4$..."
}

2. 为什么不能只用 SHA-256

下面的代码看似没有保存明文,但仍不适合密码:

createHash("sha256").update(password).digest("hex");

SHA-256 很快,攻击者可以用 GPU 或专用硬件每秒尝试海量候选密码。密码哈希需要故意变慢,并增加内存成本。

3. Argon2 的三种类型

node-argon2 官方 README 明确说明默认类型是 Argon2id,因此下面两种写法在当前版本中目标一致:

await argon2.hash(password);

await argon2.hash(password, {
  type: argon2.argon2id,
});

教程显式写 argon2.argon2id,便于学习者看懂选择。

4. 成本参数

Argon2 常见参数:

成本越高,不代表无条件越安全。它也会占用服务端资源,若配置过高,攻击者甚至可以通过大量登录请求造成拒绝服务。

官方 README 建议密码哈希通常直接采用库提供的安全默认参数,不需要初学者随意修改。生产调优时应在实际服务器压测,并结合登录限流。

5. PHC 字符串

一个编码后的 Argon2id 哈希类似:

$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$SGFzaA

可分为:

$算法$v=版本$参数$盐$哈希结果

这意味着验证时只需提供完整哈希和用户输入的密码:

await argon2.verify(storedHash, inputPassword);

库能从哈希字符串读取原来的盐和成本参数。

6. 为什么相同密码得到不同哈希

const first = await argon2.hash("same-password");
const second = await argon2.hash("same-password");

console.log(first === second); // false

每次使用了不同的随机盐。不能在登录时重新 hash() 后通过字符串相等判断密码:

// 错误:新盐会产生不同字符串
const inputHash = await argon2.hash(inputPassword);
const matched = inputHash === user.passwordHash;

正确方式:

const matched = await argon2.verify(
  user.passwordHash,
  inputPassword
);

7. 参数升级

硬件会变快,安全建议也会更新。若数据库中的旧哈希成本低于当前策略,可以在用户登录成功后升级:

verify 成功
  -> needsRehash 判断旧参数
  -> 用本次已经验证正确的明文密码重新 hash
  -> 更新 passwordHash

这叫渐进式迁移:不要求所有用户同时重置密码,活跃用户会在登录时逐步升级。

8. 业务层仍需做什么

Argon2 不能独自解决全部密码安全问题。还需要:

9. Argon2 与 JWT 的对比

问题 Argon2 JWT 签名
保护对象 用户密码 Token 内容完整性和来源
是否可逆 不可逆 Payload 可读取,不存在“解密”
是否使用随机盐 不适用
典型调用 hashverify signverify
使用阶段 注册、登录、改密 登录成功后和后续请求