{T}

密码加密实践

概述

密码安全是应用安全的基础。密码泄露会导致用户隐私泄露、账号被盗、财产损失等严重后果。本文档介绍如何在 Node.js 应用中安全地存储和验证密码。

为什么密码加密如此重要

code
┌─────────────────────────────────────────────────────────────┐
│                     密码泄露的后果                            │
├─────────────────────────────────────────────────────────────┤
│  用户层面          │  企业层面          │  法律层面          │
├────────────────────┼────────────────────┼────────────────────┤
│  • 账号被盗        │  • 信任危机        │  • 数据保护法违规   │
│  • 隐私泄露        │  • 品牌受损        │  • 巨额罚款        │
│  • 财产损失        │  • 用户流失        │  • 刑事责任        │
│  • 身份盗用        │  • 赔偿责任        │  • 监管处罚        │
└─────────────────────────────────────────────────────────────┘

密码加密流程概览

code
┌──────────┐     ┌──────────┐     ┌──────────┐     ┌──────────┐
│ 用户注册  │ ──> │ 密码验证  │ ──> │ 加盐哈希  │ ──> │ 存储哈希  │
└──────────┘     └──────────┘     └──────────┘     └──────────┘
                      │
                      ▼
               ┌──────────┐     ┌──────────┐     ┌──────────┐
               │ 用户登录  │ ──> │ 读取哈希  │ ──> │ 比对验证  │
               └──────────┘     └──────────┘     └──────────┘

密码存储的安全原则

为什么不能明文存储密码

javascript
// ❌ 危险:明文存储密码
const users = [
  { username: 'john', password: '123456' },
  { username: 'jane', password: 'password' }
]

// 数据库泄露后,所有用户的密码直接暴露
// 攻击者可以用这些密码尝试用户的其他账号(密码复用问题)

明文存储的风险:

风险说明
数据库泄露所有密码直接暴露
内部人员泄露开发/运维人员可看到密码
密码复用用户可能在多个网站使用相同密码
合规问题违反数据保护法规

为什么不能使用可逆加密

javascript
// ❌ 不安全:可逆加密
const crypto = require('crypto')
const algorithm = 'aes-256-cbc'
const key = 'secret-key-32-characters-long'

function encrypt(password) {
  const cipher = crypto.createCipher(algorithm, key)
  return cipher.update(password, 'utf8', 'hex') + cipher.final('hex')
}

function decrypt(encrypted) {
  const decipher = crypto.createDecipher(algorithm, key)
  return decipher.update(encrypted, 'hex', 'utf8') + decipher.final('utf8')
}

// 问题:密钥泄露后,所有密码可被解密
// 密钥通常存储在服务器上,数据库和密钥可能同时泄露

可逆加密的问题:

问题说明
密钥管理复杂密钥需要安全存储和定期轮换
单点失效密钥泄露 = 所有密码泄露
内部威胁有密钥的人员可以解密所有密码

安全的密码存储方式

应该使用不可逆的哈希算法,并加入盐值(Salt) 防止彩虹表攻击:

code
┌─────────────────────────────────────────────────────────────┐
│                    安全密码存储三要素                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 不可逆性                                                 │
│     哈希后无法还原原始密码,即使数据库泄露也安全               │
│                                                             │
│  2. 加盐处理                                                 │
│     每个密码使用不同的随机盐值,防止彩虹表攻击                 │
│                                                             │
│  3. 慢哈希                                                   │
│     增加计算成本,大幅提高暴力破解的时间成本                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

bcrypt 原理

什么是 bcrypt

bcrypt 是专门为密码存储设计的哈希函数,基于 Blowfish 加密算法。它是目前业界推荐的密码哈希方案之一。

核心特点:

特点说明
内置盐值生成无需单独管理盐值
可配置成本支持调整计算复杂度
抗 GPU 破解内存密集型算法
业界标准广泛使用,经过充分验证

bcrypt 哈希格式

bcrypt 生成的哈希值格式:

code
$2b$10$N9qo8uLOickgx2ZMRZoMy.Mrqj7F0X/.aQ4JhON5vDvTlC8tJ7jOq
 │  │  │                     │
 │  │  │                     └─ 哈希值(31字符)
 │  │  └─ 盐值(22字符)
 │  └─ 成本因子(10 表示 2^10 = 1024 次迭代)
 └─ bcrypt 版本标识(2b 为最新版本)

版本标识说明:

版本说明
$2a早期版本,存在 Unicode 处理问题
$2b修复版本,推荐使用
$2yPHP crypt_blowfish 实现

bcrypt 工作流程

code
密码加密流程:
┌────────────┐     ┌────────────┐     ┌────────────┐     ┌────────────┐
│  原始密码   │ ──> │ 生成随机盐  │ ──> │ 多轮哈希   │ ──> │ 组合输出    │
│ "password" │     │ (22字符)   │     │ (2^cost次) │     │ (60字符)   │
└────────────┘     └────────────┘     └────────────┘     └────────────┘

密码验证流程:
┌────────────┐     ┌────────────┐     ┌────────────┐     ┌────────────┐
│  用户密码   │ ──> │ 提取盐值   │ ──> │ 相同哈希   │ ──> │ 比对结果    │
│ "password" │     │ (从存储中)  │     │ 计算过程   │     │ 相同=正确   │
└────────────┘     └────────────┘     └────────────┘     └────────────┘

bcrypt 基本使用

安装

bash
npm install bcrypt

# 如果遇到编译问题,可以使用纯 JS 实现
npm install bcryptjs

注意bcryptjs 是纯 JavaScript 实现,无需编译,但性能略低于原生 bcrypt。两者 API 完全一致。

API 概览

方法说明参数返回值
bcrypt.hash(password, saltRounds)生成密码哈希密码, 成本因子Promise<string>
bcrypt.compare(password, hash)验证密码密码, 哈希值Promise<boolean>
bcrypt.genSalt(saltRounds)生成盐值成本因子Promise<string>
bcrypt.hashSync(password, saltRounds)同步生成哈希密码, 成本因子string
bcrypt.compareSync(password, hash)同步验证密码密码, 哈希值boolean

密码加密

javascript
const bcrypt = require('bcrypt')

// 方式一:一步完成(推荐)
async function hashPassword(plainPassword) {
  const saltRounds = 10 // 成本因子
  const hashedPassword = await bcrypt.hash(plainPassword, saltRounds)
  
  console.log('原始密码:', plainPassword)
  console.log('加密后:', hashedPassword)
  // 输出: $2b$10$xJwL5v5dFHzG6JgC8xM/Qe.Z6KxQ5yR7zYFQJ.HFJ8xQFQJ.HFJ
  
  return hashedPassword
}

// 方式二:分步完成
async function hashPasswordStepByStep(plainPassword) {
  // 1. 生成盐值
  const salt = await bcrypt.genSalt(10)
  console.log('盐值:', salt)
  // 输出: $2b$10$N9qo8uLOickgx2ZMRZoMy.
  
  // 2. 使用盐值加密
  const hashedPassword = await bcrypt.hash(plainPassword, salt)
  
  return hashedPassword
}

// 示例
hashPassword('mypassword123')

密码验证

javascript
const bcrypt = require('bcrypt')

// 登录时验证密码
async function verifyPassword(plainPassword, hashedPassword) {
  const isMatch = await bcrypt.compare(plainPassword, hashedPassword)
  
  if (isMatch) {
    console.log('✓ 密码正确')
  } else {
    console.log('✗ 密码错误')
  }
  
  return isMatch
}

// 示例
const hashedPassword = await bcrypt.hash('mypassword123', 10)
await verifyPassword('mypassword123', hashedPassword) // true
await verifyPassword('wrongpassword', hashedPassword)  // false

同步方法

javascript
const bcrypt = require('bcrypt')

// 同步加密(会阻塞事件循环,不推荐在高并发场景使用)
const saltRounds = 10
const hashedPassword = bcrypt.hashSync('mypassword123', saltRounds)

// 同步验证
const isMatch = bcrypt.compareSync('mypassword123', hashedPassword)
console.log('验证结果:', isMatch) // true

性能提示:异步方法不会阻塞事件循环,推荐在 Web 服务器中使用异步版本。


完整的认证示例

项目结构

code
project/
├── models/
│   └── User.js         # 用户模型
├── routes/
│   └── auth.js         # 认证路由
├── middleware/
│   └── auth.js         # 认证中间件
├── utils/
│   └── password.js     # 密码工具函数
└── app.js              # 应用入口

用户注册

javascript
const bcrypt = require('bcrypt')
const express = require('express')
const app = express()

app.use(express.json())

// 模拟数据库
const users = []

// 密码强度验证
function validatePasswordStrength(password) {
  const errors = []
  
  if (password.length < 8) {
    errors.push('密码至少 8 个字符')
  }
  
  if (password.length > 128) {
    errors.push('密码不能超过 128 个字符')
  }
  
  if (!/\d/.test(password)) {
    errors.push('密码必须包含数字')
  }
  
  if (!/[a-z]/.test(password)) {
    errors.push('密码必须包含小写字母')
  }
  
  if (!/[A-Z]/.test(password)) {
    errors.push('密码必须包含大写字母')
  }
  
  // 检查常见弱密码
  const weakPasswords = ['password', '123456', 'qwerty', 'admin', 'letmein']
  if (weakPasswords.includes(password.toLowerCase())) {
    errors.push('密码过于简单,请使用更强的密码')
  }
  
  return {
    valid: errors.length === 0,
    errors
  }
}

// 用户注册
app.post('/register', async (req, res) => {
  try {
    const { username, password, email } = req.body
    
    // 1. 验证输入
    if (!username || !password || !email) {
      return res.status(400).json({ 
        error: '请填写所有字段',
        code: 'MISSING_FIELDS'
      })
    }
    
    // 2. 验证邮箱格式
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    if (!emailRegex.test(email)) {
      return res.status(400).json({ 
        error: '邮箱格式不正确',
        code: 'INVALID_EMAIL'
      })
    }
    
    // 3. 检查用户是否已存在
    const existingUser = users.find(u => u.username === username)
    if (existingUser) {
      return res.status(409).json({ 
        error: '用户名已存在',
        code: 'USERNAME_EXISTS'
      })
    }
    
    // 4. 验证密码强度
    const passwordValidation = validatePasswordStrength(password)
    if (!passwordValidation.valid) {
      return res.status(400).json({ 
        error: '密码不符合要求',
        details: passwordValidation.errors,
        code: 'WEAK_PASSWORD'
      })
    }
    
    // 5. 加密密码
    const saltRounds = parseInt(process.env.BCRYPT_SALT_ROUNDS) || 10
    const hashedPassword = await bcrypt.hash(password, saltRounds)
    
    // 6. 保存用户
    const newUser = {
      id: Date.now().toString(),
      username,
      email,
      password: hashedPassword,
      createdAt: new Date().toISOString(),
      passwordChangedAt: new Date().toISOString()
    }
    
    users.push(newUser)
    
    // 7. 返回结果(不包含密码)
    res.status(201).json({
      success: true,
      message: '注册成功',
      user: {
        id: newUser.id,
        username: newUser.username,
        email: newUser.email,
        createdAt: newUser.createdAt
      }
    })
    
  } catch (error) {
    console.error('注册错误:', error)
    res.status(500).json({ 
      error: '注册失败,请稍后重试',
      code: 'INTERNAL_ERROR'
    })
  }
})

用户登录

javascript
// 登录失败计数器(生产环境应使用 Redis)
const loginAttempts = new Map()

// 登录接口
app.post('/login', async (req, res) => {
  try {
    const { username, password } = req.body
    const clientIP = req.ip
    
    // 1. 验证输入
    if (!username || !password) {
      return res.status(400).json({ 
        error: '请输入用户名和密码',
        code: 'MISSING_CREDENTIALS'
      })
    }
    
    // 2. 检查登录尝试次数
    const attemptKey = `${username}:${clientIP}`
    const attempts = loginAttempts.get(attemptKey) || 0
    
    if (attempts >= 5) {
      return res.status(429).json({ 
        error: '登录尝试次数过多,请 15 分钟后重试',
        code: 'TOO_MANY_ATTEMPTS'
      })
    }
    
    // 3. 查找用户(防止计时攻击,总是执行密码比较)
    const user = users.find(u => u.username === username)
    const dummyHash = '$2b$10$dummyhashfordummyhashdoeny' // 假的哈希值
    const hashToCompare = user ? user.password : dummyHash
    
    // 4. 验证密码
    const isPasswordValid = await bcrypt.compare(password, hashToCompare)
    
    if (!user || !isPasswordValid) {
      // 记录失败尝试
      loginAttempts.set(attemptKey, attempts + 1)
      
      // 15 分钟后清除记录
      setTimeout(() => {
        loginAttempts.delete(attemptKey)
      }, 15 * 60 * 1000)
      
      return res.status(401).json({ 
        error: '用户名或密码错误',
        code: 'INVALID_CREDENTIALS',
        attemptsRemaining: 5 - attempts - 1
      })
    }
    
    // 5. 登录成功,清除失败记录
    loginAttempts.delete(attemptKey)
    
    // 6. 返回用户信息(这里可以生成 JWT 或创建 Session)
    res.json({
      success: true,
      message: '登录成功',
      user: {
        id: user.id,
        username: user.username,
        email: user.email
      }
      // token: generateJWT(user) // 生成 JWT
    })
    
  } catch (error) {
    console.error('登录错误:', error)
    res.status(500).json({ 
      error: '登录失败,请稍后重试',
      code: 'INTERNAL_ERROR'
    })
  }
})

修改密码

javascript
// 修改密码(需要用户已登录)
app.post('/change-password', async (req, res) => {
  try {
    const { oldPassword, newPassword } = req.body
    const userId = req.session?.userId // 假设使用 Session
    
    // 1. 验证登录状态
    if (!userId) {
      return res.status(401).json({ 
        error: '请先登录',
        code: 'UNAUTHORIZED'
      })
    }
    
    // 2. 验证输入
    if (!oldPassword || !newPassword) {
      return res.status(400).json({ 
        error: '请填写所有字段',
        code: 'MISSING_FIELDS'
      })
    }
    
    // 3. 查找用户
    const user = users.find(u => u.id === userId)
    
    if (!user) {
      return res.status(404).json({ 
        error: '用户不存在',
        code: 'USER_NOT_FOUND'
      })
    }
    
    // 4. 验证旧密码
    const isPasswordValid = await bcrypt.compare(oldPassword, user.password)
    
    if (!isPasswordValid) {
      return res.status(401).json({ 
        error: '旧密码错误',
        code: 'INVALID_OLD_PASSWORD'
      })
    }
    
    // 5. 新密码不能与旧密码相同
    const isSamePassword = await bcrypt.compare(newPassword, user.password)
    
    if (isSamePassword) {
      return res.status(400).json({ 
        error: '新密码不能与旧密码相同',
        code: 'SAME_PASSWORD'
      })
    }
    
    // 6. 验证新密码强度
    const passwordValidation = validatePasswordStrength(newPassword)
    if (!passwordValidation.valid) {
      return res.status(400).json({ 
        error: '新密码不符合要求',
        details: passwordValidation.errors,
        code: 'WEAK_PASSWORD'
      })
    }
    
    // 7. 加密新密码
    const hashedPassword = await bcrypt.hash(newPassword, 10)
    
    // 8. 更新密码
    user.password = hashedPassword
    user.passwordChangedAt = new Date().toISOString()
    
    res.json({
      success: true,
      message: '密码修改成功'
    })
    
  } catch (error) {
    console.error('修改密码错误:', error)
    res.status(500).json({ 
      error: '修改密码失败,请稍后重试',
      code: 'INTERNAL_ERROR'
    })
  }
})

密码重置

javascript
// 密码重置令牌存储(生产环境应使用 Redis)
const resetTokens = new Map()

// 生成随机令牌
const crypto = require('crypto')

function generateResetToken() {
  return crypto.randomBytes(32).toString('hex')
}

// 请求重置密码
app.post('/forgot-password', async (req, res) => {
  try {
    const { email } = req.body
    
    // 1. 查找用户
    const user = users.find(u => u.email === email)
    
    // 2. 无论用户是否存在,都返回成功(防止枚举攻击)
    if (!user) {
      return res.json({
        success: true,
        message: '如果该邮箱已注册,您将收到重置密码的邮件'
      })
    }
    
    // 3. 生成重置令牌
    const token = generateResetToken()
    
    // 4. 存储令牌(1 小时有效)
    resetTokens.set(token, {
      userId: user.id,
      expiresAt: Date.now() + 60 * 60 * 1000
    })
    
    // 5. 发送邮件(示例)
    console.log(`重置链接: https://example.com/reset-password?token=${token}`)
    // await sendEmail(user.email, '重置密码', `点击链接重置密码: ...`)
    
    res.json({
      success: true,
      message: '如果该邮箱已注册,您将收到重置密码的邮件'
    })
    
  } catch (error) {
    console.error('请求重置密码错误:', error)
    res.status(500).json({ 
      error: '请求失败,请稍后重试',
      code: 'INTERNAL_ERROR'
    })
  }
})

// 重置密码
app.post('/reset-password', async (req, res) => {
  try {
    const { token, newPassword } = req.body
    
    // 1. 验证令牌
    const tokenData = resetTokens.get(token)
    
    if (!tokenData || tokenData.expiresAt < Date.now()) {
      return res.status(400).json({ 
        error: '重置链接无效或已过期',
        code: 'INVALID_TOKEN'
      })
    }
    
    // 2. 查找用户
    const user = users.find(u => u.id === tokenData.userId)
    
    if (!user) {
      return res.status(404).json({ 
        error: '用户不存在',
        code: 'USER_NOT_FOUND'
      })
    }
    
    // 3. 验证新密码强度
    const passwordValidation = validatePasswordStrength(newPassword)
    if (!passwordValidation.valid) {
      return res.status(400).json({ 
        error: '密码不符合要求',
        details: passwordValidation.errors,
        code: 'WEAK_PASSWORD'
      })
    }
    
    // 4. 加密新密码
    const hashedPassword = await bcrypt.hash(newPassword, 10)
    
    // 5. 更新密码
    user.password = hashedPassword
    user.passwordChangedAt = new Date().toISOString()
    
    // 6. 删除令牌
    resetTokens.delete(token)
    
    res.json({
      success: true,
      message: '密码重置成功,请使用新密码登录'
    })
    
  } catch (error) {
    console.error('重置密码错误:', error)
    res.status(500).json({ 
      error: '重置密码失败,请稍后重试',
      code: 'INTERNAL_ERROR'
    })
  }
})

配合数据库使用

MongoDB (Mongoose)

javascript
const mongoose = require('mongoose')
const bcrypt = require('bcrypt')

const userSchema = new mongoose.Schema({
  username: {
    type: String,
    required: [true, '用户名不能为空'],
    unique: true,
    trim: true,
    minlength: [3, '用户名至少 3 个字符'],
    maxlength: [30, '用户名不能超过 30 个字符']
  },
  email: {
    type: String,
    required: [true, '邮箱不能为空'],
    unique: true,
    trim: true,
    lowercase: true,
    match: [/^[^\s@]+@[^\s@]+\.[^\s@]+$/, '邮箱格式不正确']
  },
  password: {
    type: String,
    required: [true, '密码不能为空'],
    minlength: [8, '密码至少 8 个字符'],
    select: false // 默认查询不返回密码字段
  },
  passwordChangedAt: {
    type: Date,
    default: Date.now
  },
  loginAttempts: {
    type: Number,
    default: 0
  },
  lockUntil: {
    type: Date
  }
})

// 保存前自动加密密码
userSchema.pre('save', async function(next) {
  // 仅当密码被修改时才重新加密
  if (!this.isModified('password')) {
    return next()
  }
  
  try {
    const salt = await bcrypt.genSalt(10)
    this.password = await bcrypt.hash(this.password, salt)
    this.passwordChangedAt = Date.now() - 1000 // 留一点时间差
    next()
  } catch (error) {
    next(error)
  }
})

// 密码验证方法
userSchema.methods.comparePassword = async function(candidatePassword) {
  // 需要先 select('+password') 获取密码
  return bcrypt.compare(candidatePassword, this.password)
}

// 检查密码是否在 JWT 签发后更改
userSchema.methods.changedPasswordAfter = function(JWTTimestamp) {
  if (this.passwordChangedAt) {
    const changedTimestamp = parseInt(this.passwordChangedAt.getTime() / 1000, 10)
    return JWTTimestamp < changedTimestamp
  }
  return false
}

// 账户锁定
userSchema.methods.incLoginAttempts = function() {
  // 如果锁定已过期,重置计数
  if (this.lockUntil && this.lockUntil < Date.now()) {
    return this.updateOne({
      $set: { loginAttempts: 1 },
      $unset: { lockUntil: 1 }
    })
  }
  
  // 增加尝试次数
  const updates = { $inc: { loginAttempts: 1 } }
  
  // 如果达到限制,锁定账户
  if (this.loginAttempts + 1 >= 5) {
    updates.$set = { lockUntil: Date.now() + 30 * 60 * 1000 } // 锁定 30 分钟
  }
  
  return this.updateOne(updates)
}

const User = mongoose.model('User', userSchema)

// 使用示例
async function register(username, email, password) {
  const user = new User({ username, email, password })
  await user.save() // 自动加密密码
  return user
}

async function login(email, password) {
  const user = await User.findOne({ email }).select('+password')
  
  if (!user) {
    throw new Error('邮箱或密码错误')
  }
  
  // 检查账户是否锁定
  if (user.lockUntil && user.lockUntil > Date.now()) {
    throw new Error('账户已被锁定,请稍后重试')
  }
  
  const isMatch = await user.comparePassword(password)
  
  if (!isMatch) {
    await user.incLoginAttempts()
    throw new Error('邮箱或密码错误')
  }
  
  // 登录成功,重置尝试次数
  if (user.loginAttempts > 0) {
    await user.updateOne({
      $set: { loginAttempts: 0 },
      $unset: { lockUntil: 1 }
    })
  }
  
  return user
}

MySQL (Sequelize)

javascript
const { Sequelize, DataTypes } = require('sequelize')
const bcrypt = require('bcrypt')

const sequelize = new Sequelize('database', 'user', 'password', {
  host: 'localhost',
  dialect: 'mysql'
})

const User = sequelize.define('User', {
  username: {
    type: DataTypes.STRING(30),
    allowNull: false,
    unique: true,
    validate: {
      len: [3, 30]
    }
  },
  email: {
    type: DataTypes.STRING(100),
    allowNull: false,
    unique: true,
    validate: {
      isEmail: true
    }
  },
  password: {
    type: DataTypes.STRING(60), // bcrypt 哈希长度为 60
    allowNull: false
  },
  passwordChangedAt: {
    type: DataTypes.DATE,
    defaultValue: DataTypes.NOW
  }
}, {
  hooks: {
    // 创建前加密密码
    beforeCreate: async (user) => {
      if (user.password) {
        const salt = await bcrypt.genSalt(10)
        user.password = await bcrypt.hash(user.password, salt)
      }
    },
    // 更新前加密密码
    beforeUpdate: async (user) => {
      if (user.changed('password')) {
        const salt = await bcrypt.genSalt(10)
        user.password = await bcrypt.hash(user.password, salt)
        user.passwordChangedAt = new Date()
      }
    }
  },
  defaultScope: {
    attributes: { exclude: ['password'] } // 默认不返回密码
  },
  scopes: {
    withPassword: {
      attributes: { include: ['password'] } // 需要密码时使用
    }
  }
})

// 添加实例方法
User.prototype.comparePassword = async function(password) {
  return bcrypt.compare(password, this.password)
}

// 同步模型
sequelize.sync()

// 使用示例
async function login(email, password) {
  const user = await User.scope('withPassword').findOne({ where: { email } })
  
  if (!user) {
    throw new Error('邮箱或密码错误')
  }
  
  const isMatch = await user.comparePassword(password)
  
  if (!isMatch) {
    throw new Error('邮箱或密码错误')
  }
  
  return user
}

成本因子选择

成本因子原理

成本因子(cost factor)决定了哈希计算的迭代次数:iterations = 2^cost

code
成本因子 10 → 2^10 = 1,024 次迭代
成本因子 12 → 2^12 = 4,096 次迭代
成本因子 14 → 2^14 = 16,384 次迭代

选择原则

成本因子计算时间(约)适用场景
4~1ms开发测试环境
10~100ms生产环境推荐
12~400ms高安全要求
14~1.5s极高安全要求

性能测试

javascript
// 测试不同成本因子的性能
async function benchmarkCost() {
  const password = 'testpassword123'
  
  console.log('成本因子性能测试:')
  console.log('─'.repeat(40))
  
  for (let cost = 8; cost <= 14; cost++) {
    const start = Date.now()
    await bcrypt.hash(password, cost)
    const duration = Date.now() - start
    console.log(`Cost ${cost}: ${duration}ms`)
  }
}

// 输出示例:
// Cost 8:  26ms
// Cost 9:  51ms
// Cost 10: 100ms  ← 推荐
// Cost 11: 203ms
// Cost 12: 407ms
// Cost 13: 820ms
// Cost 14: 1640ms

推荐配置

javascript
// 使用环境变量配置
const SALT_ROUNDS = parseInt(process.env.BCRYPT_SALT_ROUNDS) || 10

// 开发环境配置
// .env.development
// BCRYPT_SALT_ROUNDS=4

// 生产环境配置
// .env.production
// BCRYPT_SALT_ROUNDS=12

其他哈希算法

算法对比

算法发布年份适用场景特点推荐度
bcrypt1999密码存储内置盐值,抗 GPU⭐⭐⭐⭐⭐
Argon22015密码存储最新标准,抗 ASIC⭐⭐⭐⭐⭐
scrypt2009密码存储内存密集,抗 ASIC⭐⭐⭐⭐
PBKDF22000密码存储NIST 标准,较老⭐⭐⭐
SHA-2562001数据完整性太快,不适合密码
MD51991已不安全已被破解

Argon2 使用示例

Argon2 是 2015 年密码哈希竞赛的获胜者,是目前最安全的密码哈希算法。

bash
npm install argon2
javascript
const argon2 = require('argon2')

// 加密密码
async function hashPasswordArgon2(password) {
  try {
    // 默认配置(推荐)
    const hash = await argon2.hash(password)
    // 输出: $argon2id$v=19$m=65536,t=3,p=4$...
    
    return hash
  } catch (err) {
    console.error('加密失败:', err)
    throw err
  }
}

// 自定义配置
async function hashPasswordArgon2Custom(password) {
  const hash = await argon2.hash(password, {
    type: argon2.argon2id,     // 推荐类型
    memoryCost: 65536,          // 内存使用(KB)
    timeCost: 3,                // 迭代次数
    parallelism: 4,             // 并行线程数
    hashLength: 32,             // 哈希长度
    saltLength: 16              // 盐值长度
  })
  
  return hash
}

// 验证密码
async function verifyPasswordArgon2(hash, password) {
  try {
    const isValid = await argon2.verify(hash, password)
    return isValid
  } catch (err) {
    // 哈希格式错误等
    return false
  }
}

// 检测是否需要重新哈希
async function needsRehash(hash) {
  return argon2.needsRehash(hash, {
    memoryCost: 65536,
    timeCost: 3,
    parallelism: 4
  })
}

// 完整示例
async function argon2Example() {
  const password = 'mySecurePassword123!'
  
  // 加密
  const hash = await hashPasswordArgon2(password)
  console.log('哈希值:', hash)
  
  // 验证
  const isValid = await verifyPasswordArgon2(hash, password)
  console.log('验证结果:', isValid) // true
  
  // 错误密码验证
  const isWrong = await verifyPasswordArgon2(hash, 'wrongPassword')
  console.log('错误密码验证:', isWrong) // false
}

Argon2 配置建议

参数开发环境生产环境说明
memoryCost65536262144+内存使用(KB),越大越安全
timeCost23+迭代次数,越多越安全
parallelism14并行线程数

安全最佳实践

完整安全配置示例

javascript
const express = require('express')
const bcrypt = require('bcrypt')
const rateLimit = require('express-rate-limit')
const helmet = require('helmet')

const app = express()

// 1. 安全中间件
app.use(helmet()) // 设置安全相关的 HTTP 头

// 2. 配置
const SALT_ROUNDS = parseInt(process.env.BCRYPT_SALT_ROUNDS) || 10

// 3. 登录速率限制
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 分钟
  max: 5, // 最多 5 次尝试
  message: {
    error: '登录尝试次数过多,请 15 分钟后重试',
    code: 'RATE_LIMIT_EXCEEDED'
  },
  standardHeaders: true,
  legacyHeaders: false
})

// 4. 全局速率限制
const globalLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 小时
  max: 100, // 每小时最多 100 次请求
  message: {
    error: '请求过于频繁,请稍后重试',
    code: 'RATE_LIMIT_EXCEEDED'
  }
})

app.use('/api/', globalLimiter)
app.post('/login', loginLimiter, loginHandler)

// 5. 密码强度验证(增强版)
function validatePasswordStrength(password) {
  const errors = []
  const warnings = []
  
  // 基本检查
  if (password.length < 8) {
    errors.push('密码至少 8 个字符')
  }
  
  if (password.length > 128) {
    errors.push('密码不能超过 128 个字符')
  }
  
  // 复杂度检查
  let complexity = 0
  if (/\d/.test(password)) complexity++
  if (/[a-z]/.test(password)) complexity++
  if (/[A-Z]/.test(password)) complexity++
  if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) complexity++
  
  if (complexity < 3) {
    errors.push('密码必须包含数字、大写字母、小写字母、特殊字符中的至少 3 种')
  }
  
  // 常见弱密码
  const weakPasswords = [
    'password', '123456', 'qwerty', 'admin', 'letmein',
    'welcome', 'monkey', 'dragon', 'master', 'login'
  ]
  if (weakPasswords.includes(password.toLowerCase())) {
    errors.push('密码过于常见,请使用更强的密码')
  }
  
  // 连续字符检查
  if (/(.)\1{2,}/.test(password)) {
    warnings.push('密码包含连续重复字符')
  }
  
  // 键盘序列检查
  const sequences = ['qwerty', 'asdfgh', 'zxcvbn', '123456', 'abcdef']
  if (sequences.some(seq => password.toLowerCase().includes(seq))) {
    warnings.push('密码包含键盘序列')
  }
  
  return {
    valid: errors.length === 0,
    errors,
    warnings,
    strength: complexity >= 4 ? 'strong' : complexity >= 3 ? 'medium' : 'weak'
  }
}

// 6. 安全的登录处理
async function loginHandler(req, res) {
  const { username, password } = req.body
  
  // 防止计时攻击
  const user = await User.findOne({ username })
  const dummyHash = '$2b$10$dummyhashfordummyhashdoeny'
  const hashToCompare = user ? user.password : dummyHash
  
  // 总是执行密码比较
  const isMatch = await bcrypt.compare(password, hashToCompare)
  
  // 检查用户和密码
  if (!user || !isMatch) {
    return res.status(401).json({ 
      error: '用户名或密码错误',
      code: 'INVALID_CREDENTIALS'
    })
  }
  
  // 检查密码是否需要升级哈希
  if (user.password.startsWith('$2a$') || user.password.startsWith('$2y$')) {
    // 升级到 $2b$ 版本
    const newHash = await bcrypt.hash(password, SALT_ROUNDS)
    user.password = newHash
    await user.save()
  }
  
  // 返回成功
  res.json({ success: true, userId: user.id })
}

// 7. 安全响应头
app.use((req, res, next) => {
  res.removeHeader('X-Powered-By')
  next()
})

安全检查清单

code
□ 密码存储
  ├─ ✅ 使用不可逆哈希算法(bcrypt/Argon2)
  ├─ ✅ 每个密码使用唯一盐值
  ├─ ✅ 使用适当的成本因子(≥10)
  └─ ✅ 不存储明文密码

□ 密码强度
  ├─ ✅ 最小长度 ≥8 字符
  ├─ ✅ 要求包含多种字符类型
  ├─ ✅ 检查常见弱密码
  └─ ✅ 最大长度限制(防止 DoS)

□ 传输安全
  ├─ ✅ 使用 HTTPS
  ├─ ✅ 设置安全 Cookie 属性
  └─ ✅ 不在 URL 中传输密码

□ 暴力破解防护
  ├─ ✅ 登录速率限制
  ├─ ✅ 账户锁定机制
  ├─ ✅ 验证码(多次失败后)
  └─ ✅ 防止计时攻击

□ 错误处理
  ├─ ✅ 不暴露用户是否存在
  ├─ ✅ 不泄露技术细节
  └─ ✅ 记录异常登录行为

□ 密码重置
  ├─ ✅ 使用一次性令牌
  ├─ ✅ 令牌有效期短(≤1小时)
  ├─ ✅ 令牌使用后立即失效
  └─ ✅ 新密码不能与旧密码相同

前端密码处理

密码输入安全

javascript
// 前端密码处理示例

// 1. 密码强度实时检测
function checkPasswordStrength(password) {
  let score = 0
  
  if (password.length >= 8) score++
  if (password.length >= 12) score++
  if (/[a-z]/.test(password)) score++
  if (/[A-Z]/.test(password)) score++
  if (/\d/.test(password)) score++
  if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) score++
  
  const levels = ['非常弱', '弱', '一般', '强', '非常强', '极强']
  return {
    score,
    level: levels[Math.min(score, levels.length - 1)]
  }
}

// 2. 注册表单处理
async function handleRegister(formData) {
  // 验证密码强度
  const strength = checkPasswordStrength(formData.password)
  if (strength.score < 3) {
    return { error: '密码强度不足' }
  }
  
  // 验证密码确认
  if (formData.password !== formData.confirmPassword) {
    return { error: '两次输入的密码不一致' }
  }
  
  // 发送请求(使用 HTTPS)
  const response = await fetch('/api/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      username: formData.username,
      email: formData.email,
      password: formData.password // 服务器会进行哈希
    })
  })
  
  return response.json()
}

// 3. 防止密码自动填充敏感字段
// HTML: <input type="password" autocomplete="new-password" />

密码显示切换

javascript
// 密码显示/隐藏切换
function setupPasswordToggle(inputId, toggleId) {
  const input = document.getElementById(inputId)
  const toggle = document.getElementById(toggleId)
  
  toggle.addEventListener('click', () => {
    const type = input.type === 'password' ? 'text' : 'password'
    input.type = type
    toggle.textContent = type === 'password' ? '显示' : '隐藏'
  })
}

常见问题解答

Q1:bcrypt 和 Argon2 应该选择哪个?

推荐: 新项目使用 Argon2,现有项目继续使用 bcrypt。

对比项bcryptArgon2
成熟度1999年,广泛验证2015年,较新
GPU 防护更好
ASIC 防护一般优秀
配置复杂度简单(单一参数)复杂(多个参数)
兼容性广泛支持需要额外安装

Q2:忘记密码时,是否应该让用户看到密码?

不应该。原因:

  1. 密码是哈希存储,无法还原
  2. 即使能还原,也不应向用户展示其密码
  3. 正确做法:发送重置链接,让用户设置新密码

Q3:密码哈希可以用于其他场景吗?

不推荐。密码哈希算法专为密码设计,特点:

  • 计算慢,不适合需要高性能的场景
  • 输出固定长度,不适合作为通用哈希

适用场景:

场景推荐算法
密码存储bcrypt / Argon2
文件完整性校验SHA-256
数据签名HMAC-SHA256
数据去重SHA-256

Q4:如何安全地存储密码重置令牌?

javascript
// ❌ 不安全:直接存储令牌
resetTokens.set(token, { userId, expiresAt })

// ✅ 安全:存储令牌哈希
const crypto = require('crypto')

async function createResetToken(userId) {
  // 生成随机令牌
  const token = crypto.randomBytes(32).toString('hex')
  
  // 存储令牌的哈希值
  const tokenHash = crypto
    .createHash('sha256')
    .update(token)
    .digest('hex')
  
  await ResetToken.create({
    userId,
    tokenHash, // 存储哈希
    expiresAt: Date.now() + 60 * 60 * 1000
  })
  
  // 返回原始令牌(仅此一次)
  return token
}

// 验证令牌时
async function verifyResetToken(token) {
  const tokenHash = crypto
    .createHash('sha256')
    .update(token)
    .digest('hex')
  
  return ResetToken.findOne({ tokenHash })
}

Q5:如何处理用户修改密码后已登录的其他设备?

javascript
// 方案一:使所有 Token 失效
async function changePassword(userId, newPassword) {
  const user = await User.findById(userId)
  user.password = await bcrypt.hash(newPassword, 10)
  user.passwordChangedAt = Date.now()
  await user.save()
  
  // 删除该用户所有 refresh token
  await RefreshToken.deleteMany({ userId })
  
  // 或者记录密码更改时间,验证 JWT 时检查
}

// JWT 验证时检查
function verifyToken(token) {
  const decoded = jwt.verify(token, SECRET)
  const user = await User.findById(decoded.userId)
  
  // 检查密码是否在 Token 签发后更改
  if (user.changedPasswordAfter(decoded.iat)) {
    throw new Error('密码已更改,请重新登录')
  }
  
  return decoded
}

Q6:数据库泄露后应该如何应对?

应急响应流程:

  1. 立即通知用户 强制修改密码
  2. 使所有 Token 失效 强制重新登录
  3. 评估风险 确定泄露范围
  4. 发布公告 告知用户情况
  5. 加强安全措施 防止再次泄露

技术措施:

javascript
// 强制所有用户修改密码
await User.updateMany({}, {
  $set: { mustChangePassword: true }
})

// 使所有会话失效
await Session.deleteMany({})
await RefreshToken.deleteMany({})

参考链接