密码加密与安全存储

要点

  • 密码绝不能明文存储,必须用单向哈希函数加密
  • bcrypt 和 argon2 是主流的密码哈希算法,不建议使用 MD5 或 SHA-256
  • 加盐(salt)防止彩虹表攻击,现代哈希算法自动处理盐值
  • 工作因子(cost factor)决定计算成本,需要根据硬件性能调整
  • 密码策略应该平衡安全性和用户体验,强制复杂度但不过度
  • 密码泄露检测(Have I Been Pwned API)可以在注册时阻止弱密码

1. 为什么需要哈希

密码存储的核心原则:即使数据库泄露,攻击者也无法恢复原始密码。

哈希函数(Hash Function)把任意长度的输入转换为固定长度的输出:

import { createHash } from 'crypto'
 
const hash = createHash('sha256').update('password123').digest('hex')
// a1b2c3d4... (64 字符)

哈希的特性:

  • 单向性:无法从哈希值反推原始输入
  • 确定性:相同输入总是得到相同输出
  • 雪崩效应:输入微小变化导致输出巨大变化
  • 抗碰撞:很难找到两个不同的输入产生相同输出

2. 为什么不用 MD5 或 SHA-256

MD5 和 SHA-256 设计目标是快速计算,不适合密码哈希:

// ❌ 不安全:太快,容易被暴力破解
import { createHash } from 'crypto'
 
const hash = createHash('sha256').update('password123').digest('hex')

现代 GPU 每秒可以计算数十亿次 SHA-256,暴力破解一个 8 位密码只需要几小时。

密码哈希算法故意设计得很慢,增加暴力破解的成本。

3. bcrypt

bcrypt 是基于 Blowfish 密码的哈希算法,是最成熟的密码哈希方案。

3.1 基本用法

import { hash, compare } from 'bcrypt'
 
// 哈希密码
const SALT_ROUNDS = 12
const passwordHash = await hash('password123', SALT_ROUNDS)
// $2b$12$K7L1OJ45/4Y2nIcbRbKZZeOZQqZqZqZqZqZqZqZqZqZqZqZq
 
// 验证密码
const isValid = await compare('password123', passwordHash)
// true

输出格式:$2b$12$<22字符盐值><53字符哈希值>

  • $2b$:bcrypt 版本
  • 12:工作因子(2^12 = 4096 轮迭代)
  • 后面是盐值和哈希值的 Base64 编码

3.2 工作因子

工作因子(cost factor)决定计算成本:

// 工作因子 10:约 100ms
const hash10 = await hash('password', 10)
 
// 工作因子 12:约 400ms
const hash12 = await hash('password', 12)
 
// 工作因子 14:约 1.6s
const hash14 = await hash('password', 14)

每增加 1,计算时间翻倍。选择工作因子需要权衡:

  • 安全性:工作因子越高,暴力破解越慢
  • 性能:工作因子越高,服务器响应越慢
  • 推荐值:12 是当前推荐值(2024 年)

可以测试服务器性能,选择哈希时间在 200-500ms 之间的工作因子:

import { hashSync } from 'bcrypt'
 
const start = Date.now()
hashSync('test', 12)
const duration = Date.now() - start
 
console.log(`Cost 12 takes ${duration}ms`)

3.3 盐值

bcrypt 自动生成盐值,每次哈希都不同:

const hash1 = await hash('password', 12)
const hash2 = await hash('password', 12)
 
console.log(hash1 !== hash2)  // true,因为盐值不同

盐值防止彩虹表攻击(预先计算的哈希表)。

4. argon2

argon2 是 2015 年 Password Hashing Competition 的获胜者,比 bcrypt 更安全。

4.1 基本用法

import { hash, verify } from '@node-rs/argon2'
 
// 哈希密码
const passwordHash = await hash('password123')
 
// 验证密码
const isValid = await verify(passwordHash, 'password123')

4.2 参数调优

argon2 有三个参数:

import { hash, AlgorithmType } from '@node-rs/argon2'
 
const passwordHash = await hash('password123', {
  algorithm: AlgorithmType.Argon2id,  // 推荐 id 变体
  memoryCost: 65536,  // 内存使用 64MB
  timeCost: 3,  // 迭代 3 次
  parallelism: 4,  // 4 个线程
})
  • memoryCost:内存使用量,防止 GPU 并行攻击
  • timeCost:迭代次数
  • parallelism:并行线程数

4.3 argon2 vs bcrypt

特性bcryptargon2
成熟度非常成熟较新但广泛支持
抗 GPU一般优秀(内存硬)
抗 ASIC一般优秀
参数调优工作因子内存、时间、并行度
库支持所有语言主流语言

推荐:新项目用 argon2,老项目用 bcrypt 也可以。

5. 密码策略

5.1 最小长度

NIST 建议密码最小长度为 8 个字符:

const passwordSchema = z.string().min(8, 'Password must be at least 8 characters')

5.2 复杂度要求

强制复杂度可以防止弱密码,但过度要求会降低用户体验:

// ✅ 合理的要求
const passwordSchema = z.string()
  .min(8, 'At least 8 characters')
  .regex(/[A-Z]/, 'Must contain uppercase letter')
  .regex(/[a-z]/, 'Must contain lowercase letter')
  .regex(/[0-9]/, 'Must contain number')
 
// ❌ 过度要求
const passwordSchema = z.string()
  .min(16, 'At least 16 characters')
  .regex(/[A-Z]/, 'Must contain uppercase')
  .regex(/[a-z]/, 'Must contain lowercase')
  .regex(/[0-9]/, 'Must contain number')
  .regex(/[^A-Za-z0-9]/, 'Must contain special character')
  .refine(p => !p.includes('password'), 'Cannot contain "password"')
  .refine(p => !p.includes('123'), 'Cannot contain "123"')

5.3 禁止常见密码

阻止用户使用常见密码:

const COMMON_PASSWORDS = new Set([
  'password123',
  '12345678',
  'qwerty123',
  // ...
])
 
const passwordSchema = z.string()
  .min(8)
  .refine(p => !COMMON_PASSWORDS.has(p), 'Password is too common')

5.4 Have I Been Pwned API

使用 Have I Been Pwned API 检查密码是否已泄露:

import { createHash } from 'crypto'
 
async function isPasswordPwned(password: string): Promise<boolean> {
  // 计算 SHA-1 哈希
  const hash = createHash('sha1').update(password).digest('hex').toUpperCase()
  const prefix = hash.slice(0, 5)
  const suffix = hash.slice(5)
 
  // 查询 API(k-Anonymity 协议)
  const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`)
  const text = await response.text()
 
  // 检查后缀是否匹配
  return text.split('\n').some(line => line.startsWith(suffix))
}
 
// 使用
app.post('/auth/register', async (c) => {
  const { password } = await c.req.json()
 
  if (await isPasswordPwned(password)) {
    return c.json({
      error: 'This password has been compromised. Please choose a different one.',
    }, 400)
  }
 
  // ... 继续注册 ...
})

k-Anonymity 协议保证 API 无法知道查询的是哪个密码。

6. 密码存储实践

6.1 数据库 Schema

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  passwordHash: text('password_hash').notNull(),  // 存储完整哈希(含盐值)
  createdAt: timestamp('created_at').defaultNow().notNull(),
})

passwordHash 字段存储完整的哈希字符串(包括算法标识、工作因子、盐值和哈希值)。

6.2 密码重置

密码重置流程:

// 1. 请求重置
app.post('/auth/forgot-password', async (c) => {
  const { email } = await c.req.json()
 
  const user = await db.query.users.findFirst({
    where: eq(users.email, email),
  })
 
  if (!user) {
    // 不泄露用户是否存在
    return c.json({ message: 'If the email exists, a reset link has been sent.' })
  }
 
  // 生成重置 Token
  const resetToken = randomBytes(32).toString('hex')
  const expiresAt = new Date(Date.now() + 60 * 60 * 1000)  // 1 小时
 
  await db.insert(passwordResetTokens).values({
    userId: user.id,
    token: resetToken,
    expiresAt,
  })
 
  // 发送重置邮件
  const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`
  await sendEmail({
    to: user.email,
    subject: 'Reset your password',
    html: `<a href="${resetUrl}">Click here to reset</a>`,
  })
 
  return c.json({ message: 'If the email exists, a reset link has been sent.' })
})
 
// 2. 执行重置
app.post('/auth/reset-password', async (c) => {
  const { token, newPassword } = await c.req.json()
 
  // 查找 Token
  const record = await db.query.passwordResetTokens.findFirst({
    where: eq(passwordResetTokens.token, token),
  })
 
  if (!record || record.expiresAt < new Date()) {
    return c.json({ error: 'Invalid or expired token' }, 400)
  }
 
  // 更新密码
  const passwordHash = await hash(newPassword, 12)
  await db
    .update(users)
    .set({ passwordHash })
    .where(eq(users.id, record.userId))
 
  // 删除 Token
  await db
    .delete(passwordResetTokens)
    .where(eq(passwordResetTokens.id, record.id))
 
  return c.json({ message: 'Password reset successfully' })
})

6.3 密码修改

已登录用户修改密码:

app.post('/auth/change-password', async (c) => {
  const user = c.get('user')
  const { currentPassword, newPassword } = await c.req.json()
 
  // 验证当前密码
  const dbUser = await db.query.users.findFirst({
    where: eq(users.id, user.id),
  })
 
  const isValid = await compare(currentPassword, dbUser!.passwordHash)
  if (!isValid) {
    return c.json({ error: 'Current password is incorrect' }, 400)
  }
 
  // 更新密码
  const passwordHash = await hash(newPassword, 12)
  await db
    .update(users)
    .set({ passwordHash })
    .where(eq(users.id, user.id))
 
  return c.json({ message: 'Password changed successfully' })
})

7. 迁移旧系统

如果从旧系统迁移(例如 MD5 或 SHA-256),可以渐进式迁移:

// 登录时检查哈希算法
async function verifyPassword(password: string, hash: string): Promise<boolean> {
  // 旧系统:MD5
  if (hash.startsWith('$md5$')) {
    const isValid = md5Verify(password, hash)
    if (isValid) {
      // 升级到 bcrypt
      const newHash = await bcrypt.hash(password, 12)
      await updateUser(userId, { passwordHash: newHash })
    }
    return isValid
  }
 
  // 新系统:bcrypt
  return bcrypt.compare(password, hash)
}

用户登录时自动升级哈希算法,不需要强制所有用户重置密码。

总结

密码加密是用户安全的最后一道防线。bcrypt 和 argon2 是主流选择,不要自己发明算法。

这一节涉及到的几个实践:

  1. 哈希算法:bcrypt 或 argon2,不要用 MD5/SHA-256
  2. 工作因子:bcrypt 推荐 12,argon2 需要调优内存和时间参数
  3. 盐值:现代算法自动处理,防止彩虹表攻击
  4. 密码策略:最小 8 字符,适度复杂度要求
  5. 泄露检测:Have I Been Pwned API 阻止已泄露密码
  6. 密码重置:Token 有有效期,不泄露用户是否存在
  7. 渐进迁移:登录时自动升级旧哈希算法

密码安全的核心是:即使数据库泄露,攻击者也无法恢复原始密码。选择合适的算法,调整工作因子,配合密码策略,可以显著提高安全性。

下一篇看 JWT 认证——Token 结构、签发验证、刷新策略、撤销机制。