跳到主要内容

密码安全最佳实践

密码是最常见的认证因素,但也是最容易出问题的环节。正确实现密码安全需要从密码策略、存储方式、传输安全等多个方面综合考虑。本章基于 NIST SP 800-63B 和 OWASP 最佳实践,深入讲解密码安全的完整实现方案。

密码策略设计

长度要求

密码长度是影响密码强度的最重要因素。NIST SP 800-63B 给出了明确建议:

场景最小长度推荐最大长度
启用 MFA8 字符至少 64 字符
未启用 MFA15 字符至少 64 字符

为什么长度如此重要?假设密码只使用小写字母(26个字符),密码可能性的计算公式为:

N=26LN = 26^L

其中 LL 是密码长度。长度每增加 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 属性(textpasswordemail
  • 设置正确的 autocomplete 属性
  • 允许用户在输入框之间使用 Tab 键导航
  • 允许用户粘贴密码(不要禁用粘贴功能)

小结

本章学习了密码安全的核心实践:

  1. 密码策略:长度优先、避免复杂规则、使用强度评估
  2. 密码存储:使用 Argon2id 或 bcrypt、禁止明文和通用哈希
  3. 密码重置:安全令牌设计、防止用户枚举、会话失效
  4. 密码更改:验证当前密码、通知用户
  5. 常见错误:信息泄露、计时攻击、密码截断

练习

  1. 实现一个完整的密码注册和登录系统,包含强度评估和泄露检测
  2. 实现安全的密码重置流程
  3. 将现有的 MD5/SHA 哈希密码迁移到 bcrypt/Argon2

参考资料