密码存储最佳实践
2012 年,LinkedIn 的 6500 万用户密码被泄露。令人震惊的是,这些密码仅仅使用了 SHA-1 无盐哈希。结果是:不到 72 小时,黑客就破解了其中 90% 的密码。更讽刺的是,破解工具只是一台普通的家用电脑。
这个案例揭示了一个残酷的事实:密码存储的安全等级,直接决定用户数据泄露后的损失程度。好的密码存储策略,能让攻击者的破解成本从几小时飙升到几万年。
为什么不能明文存储密码
有些人认为:我只要保证数据库不被入侵就好了,何必多此一举哈希密码?
这个逻辑有三个致命漏洞:
内部威胁:数据库管理员、运维人员可能接触到数据库。明文密码让他们可以直接登录任何用户账户。
数据泄露:即使只有备份文件泄露,没有数据库本身,攻击者也能获得所有密码。
用户复用:研究表明,超过 80% 的用户在多个网站使用相同密码。如果你的密码被泄露,攻击者会立即用这个密码尝试其他网站。
明文存储 = 定时炸弹
任何时候都不要在数据库中存储明文密码。无论系统大小、用户多少,这是最基本的职业道德。
加盐的必要性
很多人知道「密码要加盐」,但未必理解为什么。
假设两个用户的密码都是 password123。不加盐时,数据库中存储的是:
用户A: hash("password123") = 2d90c98...(相同的哈希值)
用户B: hash("password123") = 2d90c98...(相同的哈希值)
攻击者只需要构建一次彩虹表(常见密码到哈希值的映射),就能破解所有使用相同密码的账户。
加盐后,每个用户使用不同的随机盐:
用户A: salt="x7kP2m" -> hash("password123" + "x7kP2m")
用户B: salt="m9nQ3r" -> hash("password123" + "m9nQ3r")
虽然原始密码相同,但由于盐值不同,存储的哈希值完全不同。攻击者必须为每个用户单独破解,彩虹表完全失效。
手动实现加盐哈希(仅演示概念)
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
public class SaltedHashDemo {
public static void main(String[] args) throws Exception {
String password = "MyPassword123!";
// 生成 16 字节随机盐
SecureRandom random = new SecureRandom();
byte[] salt = new byte[16];
random.nextBytes(salt);
String saltBase64 = Base64.getEncoder().encodeToString(salt);
// 简单拼接后哈希(实际使用专业算法)
String input = password + saltBase64;
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(input.getBytes());
System.out.println("盐值: " + saltBase64);
System.out.println("哈希: " + Base64.getEncoder().encodeToString(hash));
System.out.println("存储格式: " + saltBase64 + "$" + Base64.getEncoder().encodeToString(hash));
}
}
盐值的最佳实践:
- 足够长:至少 16 字节(128 位)的随机数据
- 真随机:使用
SecureRandom,不要用普通 Random
- 唯一:每个用户、每次密码修改都应使用新的盐值
- 独立存储:盐值与哈希值一起存储,不需要隐藏
迭代次数的作用
哈希函数的迭代次数(Iterations)是密码安全的核心参数。原始密码加盐哈希一次的安全性不足,攻击者可以用 GPU 高效地并行计算。
多次迭代的作用:将单次哈希变为重复计算 N 次。这显著增加了攻击者的计算成本,同时对正常用户登录的影响有限(多花几百毫秒)。
result = hash(password + salt)
for i = 1 to iterations:
result = hash(result)
迭代次数翻倍,暴力破解时间大约翻倍,但用户登录时间只增加几十毫秒。这是安全与用户体验之间的经典权衡。
PBKDF2
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.spec.KeySpec;
public class PBKDF2IterationsDemo {
public static void main(String[] args) throws Exception {
String password = "TestPassword123!";
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);
// 迭代次数:1000(已不安全)
long time1000 = benchmarkPBKDF2(password, salt, 1000);
System.out.println("迭代 1000 次耗时: " + time1000 + "ms(不推荐)");
// 迭代次数:60000(OWASP 2023 推荐)
long time60000 = benchmarkPBKDF2(password, salt, 60000);
System.out.println("迭代 60000 次耗时: " + time60000 + "ms(推荐)");
// 迭代次数:310000(极安全)
long time310000 = benchmarkPBKDF2(password, salt, 310000);
System.out.println("迭代 310000 次耗时: " + time310000 + "ms(极高安全)");
}
private static long benchmarkPBKDF2(String password, byte[] salt, int iterations) throws Exception {
KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, 256);
long start = System.currentTimeMillis();
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
factory.generateSecret(spec);
return System.currentTimeMillis() - start;
}
}
三大密码哈希算法对比
PBKDF2:历史最久,兼容性最好,但抗 GPU 能力弱。适合需要与旧系统兼容的场景。
Bcrypt:设计优秀,社区成熟,但内存占用固定,无法抵御专业的 ASIC 设备。
Argon2id:最新标准,综合最优解。强烈推荐新系统使用。
推荐选择
新项目:使用 Argon2id。
需要与旧系统兼容:使用 PBKDF2,迭代次数设到 60000 以上。
Spring Security 项目:使用 BCrypt,内置支持。
Spring Security 的 PasswordEncoder 体系
Spring Security 提供了完整的密码编码器体系,从简单实现到生产级方案都有覆盖。
PasswordEncoder
public interface PasswordEncoder {
// 编码密码(生成哈希)
String encode(CharSequence rawPassword);
// 验证密码
boolean matches(CharSequence rawPassword, String encodedPassword);
// 是否需要重新编码(用于检测哈希参数是否过时)
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
Spring Security 提供了多种实现:
配置多种编码器(兼容迁移)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.password.PrefixBasedPasswordEncoder;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
Map<String, PasswordEncoder> encoders = new HashMap<>();
// 新用户使用 Argon2
encoders.put("argon2", new Argon2PasswordEncoder());
// 旧用户使用 BCrypt
encoders.put("bcrypt", new BCryptPasswordEncoder(12));
// 默认使用 BCrypt
return new PrefixBasedPasswordEncoder(encoders);
}
}
用户注册与登录
@Service
public class UserService {
@Autowired
private PasswordEncoder passwordEncoder;
public void register(User user, String rawPassword) {
// 自动选择编码器,存储的哈希会带有前缀
String encodedPassword = passwordEncoder.encode(rawPassword);
user.setPassword(encodedPassword);
userRepository.save(user);
}
public boolean login(String username, String rawPassword) {
User user = userRepository.findByUsername(username);
if (user == null) {
return false;
}
// 自动识别哈希前缀,使用对应编码器验证
return passwordEncoder.matches(rawPassword, user.getPassword());
}
}
密码策略设计
好的密码策略需要在安全性和可用性之间找到平衡。过于严格的策略会让用户设置易记但易猜的密码(比如 Password123!)。
基础密码策略
密码验证规则
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.regex.Pattern;
@Service
public class PasswordPolicyValidator {
// 最小长度
private static final int MIN_LENGTH = 8;
private static final int MAX_LENGTH = 128;
// 复杂度要求
private static final Pattern UPPERCASE = Pattern.compile("[A-Z]");
private static final Pattern LOWERCASE = Pattern.compile("[a-z]");
private static final Pattern DIGIT = Pattern.compile("[0-9]");
private static final Pattern SPECIAL = Pattern.compile("[!@#$%^&*(),.?\":{}|<>]");
public ValidationResult validate(String password) {
ValidationResult result = new ValidationResult();
// 长度检查
if (password.length() < MIN_LENGTH) {
result.addError("密码长度至少 " + MIN_LENGTH + " 位");
}
if (password.length() > MAX_LENGTH) {
result.addError("密码长度不能超过 " + MAX_LENGTH + " 位");
}
// 复杂度检查(至少满足 3 种)
int complexityCount = 0;
if (UPPERCASE.matcher(password).find()) complexityCount++;
if (LOWERCASE.matcher(password).find()) complexityCount++;
if (DIGIT.matcher(password).find()) complexityCount++;
if (SPECIAL.matcher(password).find()) complexityCount++;
if (complexityCount < 3) {
result.addError("密码必须包含大写字母、小写字母、数字、特殊字符中的至少 3 种");
}
// 常见密码黑名单
if (isCommonPassword(password)) {
result.addError("密码太常见,请使用更复杂的密码");
}
return result;
}
private boolean isCommonPassword(String password) {
// 实际应使用完整黑名单,如 dropbox-common-passwords.txt
String[] common = {"password", "12345678", "qwerty", "abc123"};
for (String commonPwd : common) {
if (password.toLowerCase().contains(commonPwd)) {
return true;
}
}
return false;
}
}
密码有效期与历史
密码历史检查
@Service
public class PasswordHistoryService {
@Autowired
private PasswordHistoryRepository historyRepository;
// 检查新密码是否在最近 N 次密码中使用过
public boolean isPasswordReused(String userId, String newPassword, int historyCount) {
var history = historyRepository.findTopNByUserIdOrderByChangedAtDesc(userId, historyCount);
PasswordEncoder encoder = new BCryptPasswordEncoder();
for (PasswordHistory h : history) {
if (encoder.matches(newPassword, h.getPasswordHash())) {
return true;
}
}
return false;
}
// 保存密码历史
public void saveHistory(String userId, String passwordHash) {
PasswordHistory history = new PasswordHistory();
history.setUserId(userId);
history.setPasswordHash(passwordHash);
history.setChangedAt(LocalDateTime.now());
historyRepository.save(history);
// 清理过期历史
cleanupOldHistory(userId);
}
}
密码强度评估
除了强制策略,还应该给用户反馈密码强度。好的 UX 是引导而非强制。
密码强度计算器
public class PasswordStrengthMeter {
public enum Strength {
VERY_WEAK(0, "极弱"),
WEAK(1, "弱"),
FAIR(2, "一般"),
STRONG(3, "强"),
VERY_STRONG(4, "极强");
}
public static Strength calculate(String password) {
int score = 0;
// 长度评分
if (password.length() >= 8) score++;
if (password.length() >= 12) score++;
if (password.length() >= 16) score++;
// 字符类型评分
if (password.matches(".*[a-z].*")) score++;
if (password.matches(".*[A-Z].*")) score++;
if (password.matches(".*[0-9].*")) score++;
if (password.matches(".*[!@#$%^&*()].*")) score++;
// 罚分:纯数字或纯字母
if (password.matches("\\d+")) score -= 3;
if (password.matches("[a-zA-Z]+")) score -= 2;
return Strength.entries[Math.max(0, Math.min(4, score / 2))];
}
}
暴力破解的防护
即使使用了强哈希算法,仍然需要防护暴力破解攻击。
账户锁定
登录失败计数
@Service
public class LoginAttemptService {
private static final int MAX_ATTEMPTS = 5;
private static final long LOCKOUT_DURATION = 15 * 60 * 1000; // 15 分钟
@Autowired
private CacheManager cacheManager;
public void loginSucceeded(String username) {
Cache cache = cacheManager.getCache("loginAttempts");
cache.evict(username);
}
public void loginFailed(String username) {
Cache cache = cacheManager.getCache("loginAttempts");
LoginAttempt attempt = cache.get(username, LoginAttempt.class);
if (attempt == null) {
attempt = new LoginAttempt();
}
attempt.increment();
if (attempt.getCount() >= MAX_ATTEMPTS) {
attempt.setLockedUntil(System.currentTimeMillis() + LOCKOUT_DURATION);
}
cache.put(username, attempt);
}
public boolean isLockedOut(String username) {
Cache cache = cacheManager.getCache("loginAttempts");
LoginAttempt attempt = cache.get(username, LoginAttempt.class);
if (attempt == null) return false;
return attempt.getLockedUntil() > System.currentTimeMillis();
}
}
限速策略
自适应限速
@Service
public class LoginRateLimiter {
public boolean allowAttempt(String username, String ipAddress) {
// 多维度限速:用户名 + IP + 账户
String key = "login:" + username + ":" + ipAddress;
// 使用 Redis 实现滑动窗口限流
Long attempts = redisTemplate.opsForValue().increment(key);
if (attempts != null && attempts == 1) {
// 首次尝试,设置过期时间
redisTemplate.expire(key, Duration.ofMinutes(15));
}
if (attempts != null && attempts > 5) {
// 尝试次数过多,延长冷却时间
redisTemplate.expire(key, Duration.ofMinutes(60));
return false;
}
return true;
}
}
密码重置的安全流程
密码重置是安全系统中风险最高的流程之一,必须谨慎设计。
安全的密码重置流程
@Service
public class PasswordResetService {
@Autowired
private TokenService tokenService;
@Autowired
private EmailService emailService;
// 第一步:请求重置
public void requestPasswordReset(String email) {
User user = userRepository.findByEmail(email);
if (user == null) {
// 仍然发送邮件,防止枚举攻击
return;
}
// 生成一次性 token(加密随机数)
String token = tokenService.generatePasswordResetToken(user.getId());
// 存储 token 及其过期时间(15 分钟)
passwordResetRepository.save(token, user.getId(), Duration.ofMinutes(15));
// 发送邮件
emailService.sendPasswordResetEmail(user.getEmail(), token);
}
// 第二步:验证 Token 并重置密码
public boolean resetPassword(String token, String newPassword) {
PasswordReset reset = passwordResetRepository.findByToken(token);
if (reset == null || reset.isExpired()) {
return false;
}
User user = userRepository.findById(reset.getUserId());
// 验证新密码复杂度
ValidationResult result = passwordPolicyValidator.validate(newPassword);
if (!result.isValid()) {
throw new PasswordValidationException(result.getErrors());
}
// 更新密码
user.setPassword(passwordEncoder.encode(newPassword));
userRepository.save(user);
// 使 Token 失效
passwordResetRepository.delete(token);
// 使所有活跃会话失效(可选,安全增强)
sessionService.invalidateAllUserSessions(user.getId());
// 记录审计日志
auditService.log("PASSWORD_RESET", user.getId());
}
}
:::warning 密码重置的常见错误
- 使用用户 ID 或邮箱地址作为 Token:容易被预测
- Token 不过期:攻击者可在任意时间重置密码
- 不发送确认邮件:用户不知道账户被入侵
- 不记录审计日志:无法追溯攻击痕迹
:::
思考题
问题 1:如果用户在多个网站使用相同密码,一个网站被拖库后,其他网站也会面临风险。如何在密码存储层面缓解这个问题?
参考答案
可以从以下几个层面缓解:
-
使用唯一盐值:每个网站使用不同的盐值存储密码,即使密码相同,存储的哈希值也不同。这不能阻止攻击者针对单个网站破解,但能防止直接查表攻击。
-
使用网站特定前缀:在哈希时加入网站标识符作为额外盐,如 hash(website_name + email + password + salt)。这样即使同一个密码在不同网站的哈希值完全不同。
-
更好的方案是密码管理器:从根本上解决密码复用问题。每个网站使用随机生成的强密码,完全隔离风险。
-
监测 Have I Been Pwned:使用 hibp 等服务检测用户密码是否在泄露库中,在注册和登录时进行检测并警告用户。
问题 2:为什么说「密码有效期策略」可能是过时的安全实践?
参考答案
传统观点认为定期更换密码可以限制泄露后的影响时间。但现代安全研究(包括 NIST SP 800-63B)认为这个策略可能适得其反:
问题 1:用户在被迫更换密码时,往往会做最小化改动,如 Password1 → Password2。这种「密码演化」实际上更容易被预测。
问题 2:频繁更换密码会导致用户选择更简单、更易记的密码,或在多个服务间复用同一个密码。
更好的替代方案:
- 检测泄露:使用 Have I Been Pwned 等服务检测用户密码是否已泄露,而不是定期强制更换。
- 多因素认证:MFA 比定期改密码更有效,即使密码泄露,攻击者也无法登录。
- 异常检测:监控登录行为,发现异常立即要求验证。