密码安全最佳实践
密码是最常见的认证因素,但也是最容易出问题的环节。正确实现密码安全需要从密码策略、存储方式、传输安全等多个方面综合考虑。本章基于 NIST SP 800-63B 和 OWASP 最佳实践,深入讲解密码安全的完整实现方案。
密码策略设计
长度要求
密码长度是影响密码强度的最重要因素。NIST SP 800-63B 给出了明确建议:
| 场景 | 最小长度 | 推荐最大长度 |
|---|---|---|
| 启用 MFA | 8 字符 | 至少 64 字符 |
| 未启用 MFA | 15 字符 | 至少 64 字符 |
为什么长度如此重要?假设密码只使用小写字母(26个字符),密码可能性的计算公式为:
其中 是密码长度。长度每增加 1 位,可能的密码数量就增加 26 倍。一个 8 位密码有约 2088 亿种可能,而 12 位密码则有约 95 万亿种可能。
复杂度规则的误区
传统密码策略要求用户必须混合使用大小写字母、数字和特殊字符。然而,研究表明这种规则往往适得其反:
- 用户会在可预测的位置添加大写字母和数字(如首字母大写、末尾加"1")
- 用户难以记忆复杂密码,倾向于写在纸上或重复使用
- 强制规则增加了用户的挫败感
NIST 现在建议:
- 不设置密码组成规则(不强制要求大小写、数字、特殊字符)
- 允许所有可打印字符,包括空格和 Unicode 字符
- 鼓励使用密码短语(Passphrase),如"correct horse battery staple"
密码强度评估
与其使用复杂的规则,不如使用密码强度评估工具,给用户实时反馈。推荐使用 zxcvbn 库,它通过模式匹配和字典攻击模拟来评估密码强度:
// Node.js - 使用 zxcvbn 评估密码强度
const zxcvbn = require('zxcvbn');
function evaluatePassword(password) {
const result = zxcvbn(password);
return {
score: result.score, // 0-4,4 表示最强
crackTime: result.crack_times_display.offline_slow_hashing_1e4_per_second,
warnings: result.feedback.warning,
suggestions: result.feedback.suggestions,
isWeak: result.score < 3,
};
}
// 示例
console.log(evaluatePassword('P@ssw0rd'));
// { score: 0, crackTime: 'less than a second', warnings: '...' }
// 常见密码,非常弱
console.log(evaluatePassword('correct horse battery staple'));
// { score: 4, crackTime: 'centuries', warnings: '' }
// 密码短语,很强
Python 版本:
# Python - 使用 zxcvbn-python
from zxcvbn import zxcvbn
def evaluate_password(password: str) -> dict:
result = zxcvbn(password)
return {
'score': result['score'],
'crack_time': result['crack_times_display']['offline_slow_hashing_1e4_per_second'],
'warnings': result['feedback']['warning'],
'suggestions': result['feedback']['suggestions'],
'is_weak': result['score'] < 3,
}
阻止弱密码
应维护一个黑名单,阻止用户使用已泄露或常见的密码。可以使用 Have I Been Pwned 的密码数据库:
// 检查密码是否已泄露
const crypto = require('crypto');
async function isPasswordBreached(password) {
// 计算密码的 SHA-1 哈希
const hash = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const prefix = hash.substring(0, 5);
const suffix = hash.substring(5);
// 查询 Have I Been Pwned API(k-匿名模式,不发送完整密码)
const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
const text = await response.text();
// 检查后缀是否在返回的列表中
const lines = text.split('\n');
for (const line of lines) {
const [suffixInDb, count] = line.split(':');
if (suffixInDb === suffix) {
return { breached: true, count: parseInt(count) };
}
}
return { breached: false, count: 0 };
}
// 在注册时使用
async function validatePassword(password) {
const { breached, count } = await isPasswordBreached(password);
if (breached) {
return {
valid: false,
error: `该密码已出现在 ${count.toLocaleString()} 次数据泄露中,请选择其他密码`,
};
}
const strength = evaluatePassword(password);
if (strength.isWeak) {
return {
valid: false,
error: '密码强度不足,请使用更长的密码或密码短语',
suggestions: strength.suggestions,
};
}
return { valid: true };
}
密码轮换策略
重要:NIST 不再推荐强制定期更改密码,除非有证据表明密码可能已泄露。
原因如下:
- 用户倾向于使用简单模式轮换密码(如 Password1 → Password2)
- 频繁更改增加用户的密码疲劳
- 没有证据表明定期更改能提高安全性
正确的做法:
- 仅在密码可能泄露时强制更改
- 鼓励用户使用密码管理器
- 启用多因素认证作为替代保护
密码存储
绝对禁止明文存储密码。即使数据库只有内部人员可以访问,也应使用密码哈希。密码哈希算法的选择至关重要。
哈希算法对比
| 算法 | 类型 | 内存硬度 | GPU 抵抗 | 推荐度 |
|---|---|---|---|---|
| Argon2id | 密码哈希 | 高 | 强 | ★★★★★ |
| bcrypt | 密码哈希 | 中 | 中 | ★★★★☆ |
| scrypt | 密码哈希 | 高 | 强 | ★★★★☆ |
| PBKDF2 | 密钥派生 | 低 | 弱 | ★★★☆☆ |
| SHA-256/MD5 | 通用哈希 | 无 | 无 | ✗ 禁止 |
禁止使用的算法:MD5、SHA-1、SHA-256 等通用哈希算法。这些算法设计用于快速计算,正好与密码存储的需求相反——我们希望哈希计算尽可能慢,以增加暴力破解的成本。
Argon2id 实现
Argon2 是 2015 年密码哈希竞赛的获胜者,Argon2id 是推荐的变体:
# Python - 使用 argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError
# 创建哈希器(默认参数已经足够安全)
ph = PasswordHasher(
time_cost=3, # 迭代次数
memory_cost=65536, # 内存使用(KB)
parallelism=4, # 并行线程数
hash_len=32, # 哈希长度
salt_len=16, # 盐值长度
)
def hash_password(password: str) -> str:
"""哈希密码"""
return ph.hash(password)
def verify_password(hash: str, password: str) -> bool:
"""验证密码"""
try:
ph.verify(hash, password)
return True
except VerifyMismatchError:
return False
except VerificationError:
return False
def needs_rehash(hash: str) -> bool:
"""检查是否需要重新哈希(参数已更新)"""
return ph.check_needs_rehash(hash)
# 使用示例
hashed = hash_password('user_password_123')
print(hashed) # $argon2id$v=19$m=65536,t=3,p=4$...
if verify_password(hashed, 'user_password_123'):
print('密码正确')
# 检查是否需要重新哈希
if needs_rehash(hashed):
new_hash = hash_password('user_password_123')
# 更新数据库中的哈希值
// Node.js - 使用 argon2
const argon2 = require('argon2');
// 哈希密码
async function hashPassword(password) {
return await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
}
// 验证密码
async function verifyPassword(hash, password) {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
// 使用示例
(async () => {
const hash = await hashPassword('user_password_123');
console.log(hash);
const valid = await verifyPassword(hash, 'user_password_123');
console.log(valid); // true
})();
bcrypt 实现
bcrypt 是使用最广泛的密码哈希算法,计算成本可调:
# Python - 使用 bcrypt
import bcrypt
def hash_password(password: str) -> str:
"""哈希密码(cost factor: 12)"""
# cost factor 范围: 4-31,推荐 12
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
def verify_password(hash: str, password: str) -> bool:
"""验证密码"""
return bcrypt.checkpw(password.encode('utf-8'), hash.encode('utf-8'))
# 使用示例
hashed = hash_password('user_password_123')
print(hashed) # $2b$12$...
if verify_password(hashed, 'user_password_123'):
print('密码正确')
// Node.js - 使用 bcrypt
const bcrypt = require('bcrypt');
// 哈希密码
async function hashPassword(password) {
// cost factor: 12 (推荐范围 10-12)
return await bcrypt.hash(password, 12);
}
// 验证密码
async function verifyPassword(hash, password) {
return await bcrypt.compare(password, hash);
}
// 使用示例
(async () => {
const hash = await hashPassword('user_password_123');
const valid = await verifyPassword(hash, 'user_password_123');
console.log(valid); // true
})();
Java 实现
// Java - 使用 Spring Security 的 BCryptPasswordEncoder
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
PasswordEncoder encoder = new BCryptPasswordEncoder(12);
// 哈希密码
String hash = encoder.encode("user_password_123");
// 验证密码
boolean matches = encoder.matches("user_password_123", hash);
// Java - 使用 Argon2 (需要额外依赖)
import de.mkammerer.argon2.Argon2;
import de.mkammerer.argon2.Argon2Factory;
Argon2 argon2 = Argon2Factory.create(Argon2Factory.Argon2Types.ARGON2id);
// 哈希密码
String hash = argon2.hash(3, // 迭代次数
65536, // 内存(KB)
4, // 并行度
password.toCharArray());
// 验证密码
boolean matches = argon2.verify(hash, password.toCharArray());
安全存储检查清单
- 使用专用密码哈希算法(Argon2id 或 bcrypt)
- 每个密码使用唯一的随机盐值
- 设置足够的计算成本(bcrypt: rounds >= 12)
- 处理长密码时避免静默截断(先检查长度)
- 使用恒定时间比较函数防止计时攻击
- 定期检查是否需要重新哈希(算法参数更新时)
密码重置流程
安全的密码重置流程需要平衡用户体验和安全性。以下是最佳实践:
基本流程
重置令牌设计
import secrets
import time
from datetime import datetime, timedelta
def generate_reset_token(user_id: str) -> dict:
"""生成密码重置令牌"""
# 使用加密安全的随机数生成器
token = secrets.token_urlsafe(32)
# 存储到数据库
reset_record = {
'user_id': user_id,
'token_hash': hash_token(token), # 存储 token 的哈希,不存原文
'created_at': datetime.utcnow(),
'expires_at': datetime.utcnow() + timedelta(minutes=15),
'used': False,
}
db.password_resets.insert(reset_record)
return {
'token': token,
'expires_in': 900, # 15 分钟
}
def hash_token(token: str) -> str:
"""对令牌进行哈希(用于存储)"""
import hashlib
return hashlib.sha256(token.encode()).hexdigest()
def verify_reset_token(token: str) -> dict:
"""验证重置令牌"""
token_hash = hash_token(token)
record = db.password_resets.find_one({
'token_hash': token_hash,
'used': False,
'expires_at': {'$gt': datetime.utcnow()},
})
if not record:
return {'valid': False, 'error': '令牌无效或已过期'}
return {'valid': True, 'user_id': record['user_id']}
def use_reset_token(token: str) -> bool:
"""标记令牌为已使用"""
token_hash = hash_token(token)
result = db.password_resets.update_one(
{'token_hash': token_hash},
{'$set': {'used': True, 'used_at': datetime.utcnow()}}
)
return result.modified_count > 0
重置邮件设计
def send_reset_email(email: str, token: str):
"""发送密码重置邮件"""
reset_url = f"https://example.com/reset-password?token={token}"
# 重要:使用模糊措辞防止用户枚举
# 不要在邮件中暗示账户是否存在
send_email(
to=email,
subject="密码重置请求",
html=f"""
<p>我们收到了重置您密码的请求。</p>
<p>点击下方链接重置密码:</p>
<p><a href="{reset_url}">{reset_url}</a></p>
<p>此链接将在 15 分钟后失效。</p>
<p>如果您没有请求重置密码,请忽略此邮件。</p>
"""
)
重置 API 实现
// Node.js - 密码重置 API
const express = require('express');
const router = express.Router();
// 请求重置
router.post('/forgot-password', async (req, res) => {
const { email } = req.body;
// 重要:无论邮箱是否存在,都返回相同响应
// 防止用户枚举攻击
const genericResponse = {
message: '如果该邮箱存在于我们的系统中,您将收到重置邮件',
};
const user = await User.findByEmail(email);
if (user) {
// 速率限制:每个邮箱每小时最多 3 次
const recentCount = await PasswordReset.countRecent(email, 3600);
if (recentCount >= 3) {
// 静默失败,不暴露信息
return res.json(genericResponse);
}
// 生成并发送重置令牌
const { token } = await generateResetToken(user.id);
await sendResetEmail(email, token);
}
res.json(genericResponse);
});
// 验证令牌(前端在显示重置表单前调用)
router.get('/reset-password/verify', async (req, res) => {
const { token } = req.query;
const result = await verifyResetToken(token);
if (result.valid) {
res.json({ valid: true });
} else {
res.json({ valid: false, error: result.error });
}
});
// 执行重置
router.post('/reset-password', async (req, res) => {
const { token, new_password } = req.body;
// 验证令牌
const result = await verifyResetToken(token);
if (!result.valid) {
return res.status(400).json({ error: result.error });
}
// 验证新密码
const validation = await validatePassword(new_password);
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
// 更新密码
const hashedPassword = await hashPassword(new_password);
await User.updatePassword(result.user_id, hashedPassword);
// 使令牌失效
await useResetToken(token);
// 使所有现有会话失效(安全起见)
await Session.invalidateAllForUser(result.user_id);
// 发送通知
await sendPasswordChangedNotification(result.user_id);
res.json({ message: '密码已重置' });
});
安全要点
令牌安全:
- 令牌应足够长(至少 32 字节)
- 使用加密安全的随机数生成器(
secrets模块) - 令牌应设置短期有效期(15-30 分钟)
- 令牌使用后立即失效
- 存储令牌的哈希值,而非原文
防止攻击:
- 实施速率限制防止滥用
- 使用模糊响应防止用户枚举
- 重置后使所有现有会话失效
- 发送安全通知(邮件或短信)
用户体验:
- 提供清晰的错误提示
- 支持重新发送重置邮件
- 考虑提供备用重置方式(如安全问题)
密码更改功能
用户主动更改密码时,需要验证当前密码:
from flask import request, g, jsonify
@app.route('/api/change-password', methods=['POST'])
def change_password():
user = g.current_user
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
# 1. 验证当前密码
if not verify_password(user.password_hash, current_password):
return jsonify({'error': '当前密码错误'}), 400
# 2. 新密码不能与当前密码相同
if verify_password(user.password_hash, new_password):
return jsonify({'error': '新密码不能与当前密码相同'}), 400
# 3. 验证新密码强度
validation = validate_password(new_password)
if not validation['valid']:
return jsonify({'error': validation['error']}), 400
# 4. 更新密码
user.password_hash = hash_password(new_password)
user.password_changed_at = datetime.utcnow()
db.session.commit()
# 5. 发送通知
send_password_changed_notification(user)
# 6. 可选:使其他会话失效
# session.invalidate_others(user.id)
return jsonify({'message': '密码已更新'})
常见错误与防御
错误 1:泄露用户存在性
# 错误示例
@app.route('/login', methods=['POST'])
def login():
user = User.query.filter_by(email=request.form['email']).first()
if not user:
return jsonify({'error': '用户不存在'}), 401
if not verify_password(user.password_hash, request.form['password']):
return jsonify({'error': '密码错误'}), 401
return jsonify({'token': generate_token(user)})
# 正确示例
@app.route('/login', methods=['POST'])
def login():
user = User.query.filter_by(email=request.form['email']).first()
# 使用相同的错误消息和响应时间
if not user or not verify_password(user.password_hash, request.form['password']):
return jsonify({'error': '邮箱或密码错误'}), 401
return jsonify({'token': generate_token(user)})
错误 2:计时攻击
# 错误示例:直接比较
if password == stored_password:
return True
# 正确示例:使用恒定时间比较
import hmac
def verify_password_constant_time(provided: str, stored: str) -> bool:
return hmac.compare_digest(provided.encode(), stored.encode())
错误 3:密码截断
# 错误示例:静默截断
password = request.form['password'][:72] # bcrypt 限制
# 正确示例:显式检查
def validate_password_length(password: str) -> tuple:
if len(password) < 8:
return False, '密码长度不能少于 8 个字符'
if len(password) > 128:
return False, '密码长度不能超过 128 个字符'
return True, None
# 或者使用预哈希处理超长密码
import hashlib
def preprocess_long_password(password: str) -> str:
"""对超长密码进行预哈希"""
if len(password) > 72: # bcrypt 限制
return hashlib.sha256(password.encode()).hexdigest()
return password
密码管理器兼容性
为了支持密码管理器,登录表单应遵循以下最佳实践:
<!-- 正确的登录表单 -->
<form action="/login" method="POST">
<!-- 使用标准 input 类型 -->
<input
type="text"
name="username"
autocomplete="username"
placeholder="用户名或邮箱"
required
>
<input
type="password"
name="password"
autocomplete="current-password"
placeholder="密码"
required
>
<button type="submit">登录</button>
</form>
<!-- 注册表单 -->
<form action="/register" method="POST">
<input
type="text"
name="username"
autocomplete="username"
required
>
<input
type="email"
name="email"
autocomplete="email"
required
>
<input
type="password"
name="new_password"
autocomplete="new-password"
required
>
<input
type="password"
name="confirm_password"
autocomplete="new-password"
required
>
<button type="submit">注册</button>
</form>
关键要点:
- 使用标准的
<form>元素,不要使用 JavaScript 提交 - 使用正确的
type属性(text、password、email) - 设置正确的
autocomplete属性 - 允许用户在输入框之间使用 Tab 键导航
- 允许用户粘贴密码(不要禁用粘贴功能)
小结
本章学习了密码安全的核心实践:
- 密码策略:长度优先、避免复杂规则、使用强度评估
- 密码存储:使用 Argon2id 或 bcrypt、禁止明文和通用哈希
- 密码重置:安全令牌设计、防止用户枚举、会话失效
- 密码更改:验证当前密码、通知用户
- 常见错误:信息泄露、计时攻击、密码截断
练习
- 实现一个完整的密码注册和登录系统,包含强度评估和泄露检测
- 实现安全的密码重置流程
- 将现有的 MD5/SHA 哈希密码迁移到 bcrypt/Argon2