密码加密实践
概述
密码安全是应用安全的基础。密码泄露会导致用户隐私泄露、账号被盗、财产损失等严重后果。本文档介绍如何在 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 | 修复版本,推荐使用 |
$2y | PHP 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其他哈希算法
算法对比
| 算法 | 发布年份 | 适用场景 | 特点 | 推荐度 |
|---|---|---|---|---|
| bcrypt | 1999 | 密码存储 | 内置盐值,抗 GPU | ⭐⭐⭐⭐⭐ |
| Argon2 | 2015 | 密码存储 | 最新标准,抗 ASIC | ⭐⭐⭐⭐⭐ |
| scrypt | 2009 | 密码存储 | 内存密集,抗 ASIC | ⭐⭐⭐⭐ |
| PBKDF2 | 2000 | 密码存储 | NIST 标准,较老 | ⭐⭐⭐ |
| SHA-256 | 2001 | 数据完整性 | 太快,不适合密码 | ⭐ |
| MD5 | 1991 | 已不安全 | 已被破解 | ❌ |
Argon2 使用示例
Argon2 是 2015 年密码哈希竞赛的获胜者,是目前最安全的密码哈希算法。
bash
npm install argon2javascript
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 配置建议
| 参数 | 开发环境 | 生产环境 | 说明 |
|---|---|---|---|
memoryCost | 65536 | 262144+ | 内存使用(KB),越大越安全 |
timeCost | 2 | 3+ | 迭代次数,越多越安全 |
parallelism | 1 | 4 | 并行线程数 |
安全最佳实践
完整安全配置示例
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。
| 对比项 | bcrypt | Argon2 |
|---|---|---|
| 成熟度 | 1999年,广泛验证 | 2015年,较新 |
| GPU 防护 | 好 | 更好 |
| ASIC 防护 | 一般 | 优秀 |
| 配置复杂度 | 简单(单一参数) | 复杂(多个参数) |
| 兼容性 | 广泛支持 | 需要额外安装 |
Q2:忘记密码时,是否应该让用户看到密码?
不应该。原因:
- 密码是哈希存储,无法还原
- 即使能还原,也不应向用户展示其密码
- 正确做法:发送重置链接,让用户设置新密码
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:数据库泄露后应该如何应对?
应急响应流程:
- 立即通知用户 强制修改密码
- 使所有 Token 失效 强制重新登录
- 评估风险 确定泄露范围
- 发布公告 告知用户情况
- 加强安全措施 防止再次泄露
技术措施:
javascript
// 强制所有用户修改密码
await User.updateMany({}, {
$set: { mustChangePassword: true }
})
// 使所有会话失效
await Session.deleteMany({})
await RefreshToken.deleteMany({})