Appearance
密码加密
在 Spring Security 中,密码加密是保护用户凭证安全的关键机制。通过将用户密码以加密形式存储,即使数据库被泄露,攻击者也无法直接获取用户的原始密码。本文详细介绍 Spring Security 中的密码加密机制和最佳实践。
密码加密基础
为什么需要密码加密
- 防止数据泄露风险:即使数据库被入侵,攻击者也无法获取原始密码
- 满足合规要求:许多行业标准和法规(如 GDPR、PCI-DSS)要求强密码加密
- 减少跨系统密码重用风险:用户在多个系统使用相同密码时,一处泄露不会影响其他系统
- 防止内部人员窥视:系统管理员也无法查看用户原始密码
常见的密码存储方式
- 明文存储:直接存储原始密码,极不安全,绝不应使用
- 加密存储:使用加密算法(如 AES)加密密码,可以解密回原始密码
- 单向哈希:使用哈希算法(如 MD5、SHA)生成密码摘要,理论上不可逆
- 加盐哈希:在哈希前添加随机盐值,防止彩虹表攻击
- 自适应单向函数:如 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);
}
}
}
密码加密的最佳实践
- 使用自适应单向函数:优先选择 bcrypt、Argon2、PBKDF2 或 scrypt
- 适当设置工作因子:根据安全需求和硬件能力调整工作因子
- 使用委托密码编码器:便于未来的算法升级
- 实施密码强度策略:要求用户创建强密码
- 自动升级旧密码:用户登录时自动升级到更安全的算法
- 存储密码哈希,而非原始密码:永远不要在任何地方存储明文密码
- 速率限制和帐户锁定:防止暴力破解攻击
- 安全的密码重置流程:使用安全的令牌且有限时有效
- 监控可疑的身份验证尝试:检测和阻止潜在的攻击
- 定期审查和更新密码策略:随着技术和威胁演变更新策略
总结
Spring Security 提供了全面的密码加密支持,从 BCrypt 到最新的 Argon2 算法。通过合理配置 PasswordEncoder
并遵循安全最佳实践,可以有效保护用户凭证安全。密码加密是应用安全的重要组成部分,但应结合其他安全措施,如多因素认证、账户锁定策略和安全的密码重置流程,共同构建完整的用户认证安全体系。