密码存储最佳实践

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;
    }
}

三大密码哈希算法对比

特性PBKDF2BcryptArgon2id
诞生年份200019992015
标准PKCS#5-RFC 9106
抗 GPU
内存硬部分
参数可调迭代次数Cost 因子时间/内存/并行度
推荐参数60000+ 迭代Cost 1264MB, 3 迭代

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 提供了多种实现:

实现类用途安全性
BCryptPasswordEncoderBcrypt 编码生产推荐
Argon2PasswordEncoderArgon2 编码最新标准
Pbkdf2PasswordEncoderPBKDF2 编码兼容旧系统
ScryptPasswordEncoderScrypt 编码内存硬
NoOpPasswordEncoder明文(已废弃)仅测试用
StandardStringEncoderSHA-256仅测试用
配置多种编码器(兼容迁移)
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:如果用户在多个网站使用相同密码,一个网站被拖库后,其他网站也会面临风险。如何在密码存储层面缓解这个问题?

参考答案

可以从以下几个层面缓解:

  1. 使用唯一盐值:每个网站使用不同的盐值存储密码,即使密码相同,存储的哈希值也不同。这不能阻止攻击者针对单个网站破解,但能防止直接查表攻击。

  2. 使用网站特定前缀:在哈希时加入网站标识符作为额外盐,如 hash(website_name + email + password + salt)。这样即使同一个密码在不同网站的哈希值完全不同。

  3. 更好的方案是密码管理器:从根本上解决密码复用问题。每个网站使用随机生成的强密码,完全隔离风险。

  4. 监测 Have I Been Pwned:使用 hibp 等服务检测用户密码是否在泄露库中,在注册和登录时进行检测并警告用户。

问题 2:为什么说「密码有效期策略」可能是过时的安全实践?

参考答案

传统观点认为定期更换密码可以限制泄露后的影响时间。但现代安全研究(包括 NIST SP 800-63B)认为这个策略可能适得其反:

问题 1:用户在被迫更换密码时,往往会做最小化改动,如 Password1Password2。这种「密码演化」实际上更容易被预测。

问题 2:频繁更换密码会导致用户选择更简单、更易记的密码,或在多个服务间复用同一个密码。

更好的替代方案

  1. 检测泄露:使用 Have I Been Pwned 等服务检测用户密码是否已泄露,而不是定期强制更换。
  2. 多因素认证:MFA 比定期改密码更有效,即使密码泄露,攻击者也无法登录。
  3. 异常检测:监控登录行为,发现异常立即要求验证。