Skip to content

密码加密

在 Spring Security 中,密码加密是保护用户凭证安全的关键机制。通过将用户密码以加密形式存储,即使数据库被泄露,攻击者也无法直接获取用户的原始密码。本文详细介绍 Spring Security 中的密码加密机制和最佳实践。

密码加密基础

为什么需要密码加密

  1. 防止数据泄露风险:即使数据库被入侵,攻击者也无法获取原始密码
  2. 满足合规要求:许多行业标准和法规(如 GDPR、PCI-DSS)要求强密码加密
  3. 减少跨系统密码重用风险:用户在多个系统使用相同密码时,一处泄露不会影响其他系统
  4. 防止内部人员窥视:系统管理员也无法查看用户原始密码

常见的密码存储方式

  1. 明文存储:直接存储原始密码,极不安全,绝不应使用
  2. 加密存储:使用加密算法(如 AES)加密密码,可以解密回原始密码
  3. 单向哈希:使用哈希算法(如 MD5、SHA)生成密码摘要,理论上不可逆
  4. 加盐哈希:在哈希前添加随机盐值,防止彩虹表攻击
  5. 自适应单向函数:如 bcrypt、PBKDF2、Argon2,专为密码存储设计的算法

Spring Security 中的 PasswordEncoder

Spring Security 通过 PasswordEncoder 接口实现密码加密和验证:

java
public interface PasswordEncoder {
    // 对原始密码进行编码
    String encode(CharSequence rawPassword);
    
    // 验证原始密码与编码后的密码是否匹配
    boolean matches(CharSequence rawPassword, String encodedPassword);
    
    // 检查是否需要升级加密
    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

内置的 PasswordEncoder 实现

Spring Security 提供了多种 PasswordEncoder 实现,适合不同的安全需求:

1. BCryptPasswordEncoder

使用 bcrypt 强哈希函数,是 Spring Security 推荐的实现:

java
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(12); // 可配置强度因子,默认为10
}

BCrypt 特点:

  • 自带盐值,无需单独存储盐
  • 自适应,可通过强度因子调整计算复杂度
  • 计算强度随计算机能力提升可调整

BCrypt 密码格式:

$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
|  |  |                        |
|  |  |                        密码哈希
|  |  哈希强度因子 (2^10 次迭代)
|  哈希算法版本 (2a)
算法标识 (Bcrypt)

2. Pbkdf2PasswordEncoder

使用 PBKDF2 (Password-Based Key Derivation Function 2) 算法:

java
@Bean
public PasswordEncoder passwordEncoder() {
    return Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8(); // 使用推荐默认配置
}

// 或自定义参数
@Bean
public PasswordEncoder passwordEncoder() {
    String secret = "secret-key"; // 密钥
    int iterations = 185000;      // 迭代次数
    int hashWidth = 256;          // 哈希宽度
    return new Pbkdf2PasswordEncoder(
        secret, 
        iterations, 
        hashWidth, 
        Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256 // 算法
    );
}

PBKDF2 特点:

  • NIST 推荐的算法
  • 可配置迭代次数和密钥长度
  • 适用于受限环境,资源占用可预测

3. Argon2PasswordEncoder

使用 Argon2 算法,2015 年密码哈希竞赛的获胜者:

java
@Bean
public PasswordEncoder passwordEncoder() {
    return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
}

// 或自定义参数
@Bean
public PasswordEncoder passwordEncoder() {
    int saltLength = 16;     // 盐长度
    int hashLength = 32;     // 哈希长度
    int parallelism = 1;     // 并行度
    int memory = 1 << 14;    // 内存消耗 (16MB)
    int iterations = 3;      // 迭代次数
    return new Argon2PasswordEncoder(saltLength, hashLength, parallelism, memory, iterations);
}

Argon2 特点:

  • 当前最安全的密码哈希算法
  • 可抵抗 GPU、ASIC 攻击
  • 可配置内存需求、并行度和计算成本
  • 需要 Java 9+ 和 BouncyCastle 支持

4. SCryptPasswordEncoder

使用 scrypt 算法,专为密码存储设计:

java
@Bean
public PasswordEncoder passwordEncoder() {
    return SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8();
}

// 或自定义参数
@Bean
public PasswordEncoder passwordEncoder() {
    int cpuCost = 1 << 14;   // CPU 成本
    int memoryCost = 8;      // 内存成本
    int parallelization = 1; // 并行化
    int keyLength = 32;      // 密钥长度
    int saltLength = 16;     // 盐长度
    return new SCryptPasswordEncoder(cpuCost, memoryCost, parallelization, keyLength, saltLength);
}

SCrypt 特点:

  • 设计用于抵抗硬件暴力破解
  • 比 PBKDF2 更安全,需要更多内存
  • 参数可灵活调整,适应不同安全需求

5. DelegatingPasswordEncoder

DelegatingPasswordEncoder 允许使用多种编码器,同时确保向后兼容性:

java
@Bean
public PasswordEncoder passwordEncoder() {
    Map<String, PasswordEncoder> encoders = new HashMap<>();
    encoders.put("bcrypt", new BCryptPasswordEncoder());
    encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
    encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
    encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
    
    // 兼容旧系统的编码器
    encoders.put("sha256", new StandardPasswordEncoder());
    encoders.put("ldap", new LdapShaPasswordEncoder());
    
    // 使用 bcrypt 作为默认编码器
    return new DelegatingPasswordEncoder("bcrypt", encoders);
}

DelegatingPasswordEncoder 特点:

  • 支持多种加密算法共存
  • 便于加密策略迁移和升级
  • 密码格式包含算法标识符前缀

DelegatingPasswordEncoder 密码格式:

{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc
{argon2}$argon2id$v=19$m=4096,t=3,p=1$JGFyZ29uMmlkJHY9...
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc...

在 Spring Security 中集成 PasswordEncoder

1. 配置 PasswordEncoder

java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // 安全配置...
        return http.build();
    }
}

2. 在认证过程中使用 PasswordEncoder

UserDetailsService 结合使用:

java
@Service
public class UserService implements UserDetailsService {
    
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    
    @Autowired
    public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));
        
        return org.springframework.security.core.userdetails.User
            .withUsername(user.getUsername())
            .password(user.getPassword()) // 已加密的密码
            .roles(user.getRoles().toArray(new String[0]))
            .build();
    }
    
    public User registerNewUser(User user) {
        // 加密密码
        user.setPassword(passwordEncoder.encode(user.getPassword()));
        return userRepository.save(user);
    }
}

配置认证提供者:

java
@Bean
public DaoAuthenticationProvider authenticationProvider() {
    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    provider.setUserDetailsService(userDetailsService());
    provider.setPasswordEncoder(passwordEncoder());
    return provider;
}

3. 密码验证

java
@Service
public class AuthService {
    
    private final PasswordEncoder passwordEncoder;
    
    @Autowired
    public AuthService(PasswordEncoder passwordEncoder) {
        this.passwordEncoder = passwordEncoder;
    }
    
    public boolean verifyPassword(String rawPassword, String encodedPassword) {
        return passwordEncoder.matches(rawPassword, encodedPassword);
    }
    
    public boolean needsUpgrade(String encodedPassword) {
        return passwordEncoder.upgradeEncoding(encodedPassword);
    }
}

密码升级策略

当需要升级到更安全的密码算法时,可以实现无缝升级:

java
@Service
public class UserService {
    
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    
    // 构造函数等代码省略...
    
    @Transactional
    public void authenticateAndUpgradePassword(String username, String password) {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));
        
        // 验证密码
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new BadCredentialsException("Invalid password");
        }
        
        // 检查是否需要升级密码
        if (passwordEncoder.upgradeEncoding(user.getPassword())) {
            // 使用新算法重新加密密码并保存
            user.setPassword(passwordEncoder.encode(password));
            userRepository.save(user);
        }
    }
}

密码强度与复杂度验证

1. 密码复杂度验证器

使用 Bean Validation 或自定义验证器实现密码复杂度校验:

java
@Component
public class PasswordConstraintValidator implements ConstraintValidator<ValidPassword, String> {
    
    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        if (password == null) {
            return false;
        }
        
        // 密码长度至少8位
        if (password.length() < 8) {
            return false;
        }
        
        // 包含至少一个数字
        if (!password.matches(".*\\d.*")) {
            return false;
        }
        
        // 包含至少一个小写字母
        if (!password.matches(".*[a-z].*")) {
            return false;
        }
        
        // 包含至少一个大写字母
        if (!password.matches(".*[A-Z].*")) {
            return false;
        }
        
        // 包含至少一个特殊字符
        if (!password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?].*")) {
            return false;
        }
        
        return true;
    }
}

用自定义注解标记验证规则:

java
@Documented
@Constraint(validatedBy = PasswordConstraintValidator.class)
@Target({ ElementType.TYPE, ElementType.FIELD, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidPassword {
    
    String message() default "Invalid Password";
    
    Class<?>[] groups() default {};
    
    Class<? extends Payload>[] payload() default {};
}

在用户模型或 DTO 中使用:

java
public class UserRegistrationDto {
    
    @NotBlank
    private String username;
    
    @ValidPassword
    private String password;
    
    // 其他字段和方法...
}

2. 使用 Passay 库进行密码验证

Passay 是一个密码策略实施库,提供更丰富的密码验证功能:

添加依赖:

xml
<dependency>
    <groupId>org.passay</groupId>
    <artifactId>passay</artifactId>
    <version>1.6.2</version>
</dependency>

创建基于 Passay 的验证器:

java
@Component
public class PassayPasswordValidator implements ConstraintValidator<ValidPassword, String> {
    
    private PasswordValidator validator;
    
    @Override
    public void initialize(ValidPassword constraintAnnotation) {
        // 创建密码规则
        List<Rule> rules = new ArrayList<>();
        
        // 长度规则:8-30个字符
        rules.add(new LengthRule(8, 30));
        
        // 至少有一个大写字母
        rules.add(new CharacterRule(EnglishCharacterData.UpperCase, 1));
        
        // 至少有一个小写字母
        rules.add(new CharacterRule(EnglishCharacterData.LowerCase, 1));
        
        // 至少有一个数字
        rules.add(new CharacterRule(EnglishCharacterData.Digit, 1));
        
        // 至少有一个特殊字符
        rules.add(new CharacterRule(EnglishCharacterData.Special, 1));
        
        // 不允许连续重复字符
        rules.add(new RepeatCharacterRegexRule(3));
        
        // 不允许字母数字序列
        rules.add(new AlphabeticalSequenceRule(3, false));
        rules.add(new NumericalSequenceRule(3, false));
        
        // 不允许常见密码
        rules.add(new DictionaryRule(
            new WordListDictionary(DictionaryBuilder.newInstance()
                .addWord("password", "qwerty", "12345", "admin")
                // 添加更多常见密码
                .build())
        ));
        
        // 创建验证器
        validator = new PasswordValidator(rules);
    }
    
    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        if (password == null) {
            return false;
        }
        
        RuleResult result = validator.validate(new PasswordData(password));
        
        if (result.isValid()) {
            return true;
        }
        
        // 添加详细的错误信息
        context.disableDefaultConstraintViolation();
        
        validator.getMessages(result).forEach(message -> 
            context.buildConstraintViolationWithTemplate(message)
                .addConstraintViolation()
        );
        
        return false;
    }
}

保护密码重置流程

1. 安全的密码重置令牌

java
@Service
public class PasswordResetService {
    
    private final UserRepository userRepository;
    private final TokenRepository tokenRepository;
    private final PasswordEncoder passwordEncoder;
    private final EmailService emailService;
    
    // 构造函数省略...
    
    public void initiatePasswordReset(String email) {
        User user = userRepository.findByEmail(email)
            .orElseThrow(() -> new UserNotFoundException("User not found"));
        
        // 生成安全的随机令牌
        String token = UUID.randomUUID().toString();
        
        // 计算过期时间(1小时后)
        LocalDateTime expiryTime = LocalDateTime.now().plusHours(1);
        
        // 存储令牌(为防止时序攻击,应存储令牌的哈希值)
        PasswordResetToken resetToken = new PasswordResetToken();
        resetToken.setUser(user);
        resetToken.setToken(passwordEncoder.encode(token));
        resetToken.setExpiryDate(expiryTime);
        tokenRepository.save(resetToken);
        
        // 发送带有重置链接的邮件
        String resetUrl = "https://example.com/reset-password?token=" + token;
        emailService.sendPasswordResetEmail(user.getEmail(), resetUrl);
    }
    
    @Transactional
    public void resetPassword(String token, String newPassword) {
        // 查找令牌记录
        List<PasswordResetToken> resetTokens = tokenRepository.findAll();
        
        // 找到匹配的令牌
        PasswordResetToken resetToken = resetTokens.stream()
            .filter(t -> passwordEncoder.matches(token, t.getToken()))
            .findFirst()
            .orElseThrow(() -> new InvalidTokenException("Invalid token"));
        
        // 验证令牌是否过期
        if (resetToken.getExpiryDate().isBefore(LocalDateTime.now())) {
            tokenRepository.delete(resetToken);
            throw new TokenExpiredException("Token has expired");
        }
        
        // 更新用户密码
        User user = resetToken.getUser();
        user.setPassword(passwordEncoder.encode(newPassword));
        userRepository.save(user);
        
        // 删除使用过的令牌
        tokenRepository.delete(resetToken);
    }
}

2. 密码密钥派生函数用于敏感数据加密

对于需要在应用程序内进行加密(非密码存储)的场景,可以使用密码派生函数生成加密密钥:

java
@Service
public class DataEncryptionService {
    
    private final SecretKey secretKey;
    
    public DataEncryptionService(@Value("${app.encryption.password}") String password) {
        this.secretKey = deriveKeyFromPassword(password);
    }
    
    private SecretKey deriveKeyFromPassword(String password) {
        try {
            // 使用 PBKDF2 派生密钥
            PBEKeySpec spec = new PBEKeySpec(
                password.toCharArray(),
                "static-salt-value".getBytes(),  // 在生产环境中应使用随机盐并安全存储
                65536,  // 迭代次数
                256     // 密钥长度 (位)
            );
            
            SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
            byte[] keyBytes = factory.generateSecret(spec).getEncoded();
            return new SecretKeySpec(keyBytes, "AES");
        } catch (Exception e) {
            throw new RuntimeException("Error deriving encryption key", e);
        }
    }
    
    public String encrypt(String data) {
        try {
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            byte[] iv = new byte[12];
            new SecureRandom().nextBytes(iv);
            GCMParameterSpec parameterSpec = new GCMParameterSpec(128, iv);
            
            cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);
            byte[] encryptedData = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
            
            // 将 IV 和加密数据合并
            ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + encryptedData.length);
            byteBuffer.put(iv);
            byteBuffer.put(encryptedData);
            
            return Base64.getEncoder().encodeToString(byteBuffer.array());
        } catch (Exception e) {
            throw new RuntimeException("Encryption failed", e);
        }
    }
    
    public String decrypt(String encryptedData) {
        try {
            byte[] decoded = Base64.getDecoder().decode(encryptedData);
            ByteBuffer byteBuffer = ByteBuffer.wrap(decoded);
            
            // 提取 IV
            byte[] iv = new byte[12];
            byteBuffer.get(iv);
            
            // 提取加密数据
            byte[] cipherText = new byte[byteBuffer.remaining()];
            byteBuffer.get(cipherText);
            
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            GCMParameterSpec parameterSpec = new GCMParameterSpec(128, iv);
            cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec);
            
            byte[] decryptedData = cipher.doFinal(cipherText);
            return new String(decryptedData, StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new RuntimeException("Decryption failed", e);
        }
    }
}

密码加密的最佳实践

  1. 使用自适应单向函数:优先选择 bcrypt、Argon2、PBKDF2 或 scrypt
  2. 适当设置工作因子:根据安全需求和硬件能力调整工作因子
  3. 使用委托密码编码器:便于未来的算法升级
  4. 实施密码强度策略:要求用户创建强密码
  5. 自动升级旧密码:用户登录时自动升级到更安全的算法
  6. 存储密码哈希,而非原始密码:永远不要在任何地方存储明文密码
  7. 速率限制和帐户锁定:防止暴力破解攻击
  8. 安全的密码重置流程:使用安全的令牌且有限时有效
  9. 监控可疑的身份验证尝试:检测和阻止潜在的攻击
  10. 定期审查和更新密码策略:随着技术和威胁演变更新策略

总结

Spring Security 提供了全面的密码加密支持,从 BCrypt 到最新的 Argon2 算法。通过合理配置 PasswordEncoder 并遵循安全最佳实践,可以有效保护用户凭证安全。密码加密是应用安全的重要组成部分,但应结合其他安全措施,如多因素认证、账户锁定策略和安全的密码重置流程,共同构建完整的用户认证安全体系。