跳到主要内容

密码存储

密码存储安全是应用安全的基础环节。一旦密码泄露,用户账户将面临被接管的风险。Spring Security 提供了完善的密码编码支持,本章将详细介绍密码存储的最佳实践和各种加密算法的使用。

密码存储安全原则

常见错误做法

明文存储

最危险的做法,数据库泄露直接暴露所有密码:

// 错误:明文存储
user.setPassword(password); // 不要这样做!

可逆加密

加密密码可以被解密,密钥泄露等同于明文泄露:

// 错误:可逆加密
String encrypted = AES.encrypt(password, secretKey); // 不要这样做!

简单哈希

MD5、SHA-1 等简单哈希容易被彩虹表破解:

// 错误:简单哈希
String hash = MD5(password); // 不要这样做!

正确做法

密码存储应该满足以下条件:

  1. 不可逆:无法从哈希值还原原始密码
  2. 加盐:每个密码使用不同的随机盐值
  3. 慢哈希:增加计算成本,抵抗暴力破解
  4. 可配置:支持升级加密算法

Spring Security PasswordEncoder

接口定义

PasswordEncoder 是 Spring Security 密码编码的核心接口:

public interface PasswordEncoder {

// 编码原始密码
String encode(CharSequence rawPassword);

// 验证密码是否匹配
boolean matches(CharSequence rawPassword, String encodedPassword);

// 是否需要重新编码(用于升级算法)
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}

BCryptPasswordEncoder

BCrypt 是目前最推荐的密码哈希算法,专为密码存储设计:

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

// 使用
String encoded = passwordEncoder.encode("password123");
boolean matches = passwordEncoder.matches("password123", encoded);

BCrypt 的特点:

  • 自动生成随机盐值并嵌入哈希结果
  • 内置工作因子(strength),可调节计算成本
  • 输出格式:$2a$10$盐值哈希值,共 60 字符

调整工作因子

工作因子决定计算的迭代次数,值越大越安全但越慢:

// 默认 strength = 10(2^10 次迭代)
PasswordEncoder encoder = new BCryptPasswordEncoder();

// 增加到 12(更安全但更慢)
PasswordEncoder strongerEncoder = new BCryptPasswordEncoder(12);

推荐值:10-12 之间,根据服务器性能调整。

测试性能影响

@Test
void testBcryptPerformance() {
for (int strength = 10; strength <= 14; strength++) {
PasswordEncoder encoder = new BCryptPasswordEncoder(strength);

long start = System.currentTimeMillis();
encoder.encode("password");
long duration = System.currentTimeMillis() - start;

System.out.println("Strength " + strength + ": " + duration + "ms");
}
}

// 典型输出:
// Strength 10: 100ms
// Strength 11: 200ms
// Strength 12: 400ms
// Strength 13: 800ms
// Strength 14: 1600ms

其他哈希算法

Argon2PasswordEncoder

Argon2 是密码哈希竞赛的获胜者,抗 GPU 攻击能力更强:

@Bean
public PasswordEncoder passwordEncoder() {
// Argon2id 变体
return new Argon2PasswordEncoder(
16, // 盐值长度(字节)
32, // 哈希值长度(字节)
1, // 并行度
65536, // 内存成本(KB)
3 // 迭代次数
);
}

需要 Bouncy Castle 库支持:

<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.77</version>
</dependency>

SCryptPasswordEncoder

SCrypt 也是一种内存密集型哈希算法:

@Bean
public PasswordEncoder passwordEncoder() {
return new SCryptPasswordEncoder(
16384, // CPU 成本(N)
8, // 内存成本(r)
1, // 并行化(p)
32, // 密钥长度
16 // 盐值长度
);
}

PBKDF2PasswordEncoder

PBKDF2 是标准的密码派生函数,兼容性好:

@Bean
public PasswordEncoder passwordEncoder() {
return new Pbkdf2PasswordEncoder(
"", // 秘密(可选额外盐值)
16, // 盐值长度
185000, // 迭代次数
Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256
);
}

算法对比

算法特点推荐场景
BCrypt简单可靠,广泛支持通用推荐
Argon2抗 GPU 攻击最强高安全要求
SCrypt内存密集型抗 ASIC 攻击
PBKDF2标准兼容性好需要标准合规

委托密码编码器

问题场景

随着时间推移,可能需要升级密码哈希算法。但数据库中已有的旧密码使用旧算法,新密码使用新算法,如何兼容?

DelegatingPasswordEncoder

Spring Security 提供委托编码器,支持多种算法并存:

@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

存储格式为:{算法ID}编码后的密码

{bcrypt}$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt.4v5q
{argon2}$argon2id$v=19$m=65536,t=3,p=1$...
{pbkdf2}5b9b3c2e1d...
{scrypt}$e0801$...
{noop}password123 # 明文(不推荐)

自定义委托编码器

@Bean
public PasswordEncoder passwordEncoder() {
String idForEncode = "bcrypt";

Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("argon2", new Argon2PasswordEncoder());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());

return new DelegatingPasswordEncoder(idForEncode, encoders);
}

验证流程

  1. 根据存储密码的前缀 {算法ID} 选择对应的编码器
  2. 使用选定的编码器验证密码
  3. 如果密码是旧格式(无前缀),使用默认编码器

自动升级密码

登录成功后自动将旧算法密码升级为新算法:

@Component
public class PasswordUpgradeService {

private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;

public void upgradePasswordIfNeeded(User user, String rawPassword) {
String encodedPassword = user.getPassword();

// 检查是否需要升级
if (passwordEncoder.upgradeEncoding(encodedPassword)) {
String newEncodedPassword = passwordEncoder.encode(rawPassword);
user.setPassword(newEncodedPassword);
userRepository.save(user);
}
}
}

// 在认证成功处理器中调用
@Component
public class CustomAuthenticationSuccessHandler
implements AuthenticationSuccessHandler {

@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) {

UserDetails userDetails = (UserDetails) authentication.getPrincipal();
User user = userRepository.findByUsername(userDetails.getUsername());

// 自动升级密码编码
passwordUpgradeService.upgradePasswordIfNeeded(user, request.getParameter("password"));

// ... 其他处理
}
}

配置用户存储

内存用户存储

@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
UserDetails user = User.builder()
.username("user")
.password(passwordEncoder.encode("password"))
.roles("USER")
.build();

return new InMemoryUserDetailsManager(user);
}

JDBC 用户存储

@Bean
public UserDetailsService userDetailsService(DataSource dataSource) {
JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);
return manager;
}

// 创建用户
@Transactional
public void createUser(String username, String password) {
UserDetails user = User.builder()
.username(username)
.password(passwordEncoder.encode(password))
.roles("USER")
.build();

jdbcUserDetailsManager.createUser(user);
}

自定义用户存储

@Service
public class CustomUserDetailsService implements UserDetailsService {

private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;

@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {

User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));

return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(), // 已编码的密码
user.isEnabled(),
true, true, true,
getAuthorities(user)
);
}

// 注册新用户
public void registerUser(String username, String rawPassword) {
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(rawPassword)); // 编码密码
userRepository.save(user);
}
}

密码验证最佳实践

密码强度验证

在编码存储前验证密码强度:

@Service
public class PasswordValidationService {

private static final int MIN_LENGTH = 8;
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) {
List<String> errors = new ArrayList<>();

if (password.length() < MIN_LENGTH) {
errors.add("密码长度至少 " + MIN_LENGTH + " 位");
}
if (!UPPERCASE.matcher(password).find()) {
errors.add("密码必须包含大写字母");
}
if (!LOWERCASE.matcher(password).find()) {
errors.add("密码必须包含小写字母");
}
if (!DIGIT.matcher(password).find()) {
errors.add("密码必须包含数字");
}
if (!SPECIAL.matcher(password).find()) {
errors.add("密码必须包含特殊字符");
}

return new ValidationResult(errors.isEmpty(), errors);
}

public record ValidationResult(boolean valid, List<String> errors) {}
}

检查常见密码

检查是否为常见弱密码:

@Service
public class CommonPasswordChecker {

private final Set<String> commonPasswords;

public CommonPasswordChecker() {
// 加载常见密码列表
this.commonPasswords = loadCommonPasswords();
}

public boolean isCommonPassword(String password) {
return commonPasswords.contains(password.toLowerCase());
}

private Set<String> loadCommonPasswords() {
// 从文件或数据库加载
return Set.of("password", "123456", "qwerty", "admin", "letmein");
}
}

密码修改流程

安全的密码修改流程:

@Service
public class PasswordChangeService {

private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;

public void changePassword(String username,
String currentPassword,
String newPassword) {

// 1. 验证当前密码
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, currentPassword)
);

// 2. 验证新密码强度
ValidationResult validation = passwordValidationService.validate(newPassword);
if (!validation.valid()) {
throw new WeakPasswordException(validation.errors());
}

// 3. 检查新密码不能与旧密码相同
if (passwordEncoder.matches(newPassword,
userRepository.findByUsername(username).getPassword())) {
throw new SamePasswordException("新密码不能与当前密码相同");
}

// 4. 更新密码
User user = userRepository.findByUsername(username);
user.setPassword(passwordEncoder.encode(newPassword));
user.setPasswordChangedAt(LocalDateTime.now());
userRepository.save(user);

// 5. 记录审计日志
auditService.logPasswordChange(username);
}
}

密码重置安全

重置令牌

安全的密码重置流程:

@Service
public class PasswordResetService {

private final UserRepository userRepository;
private final PasswordResetTokenRepository tokenRepository;
private final EmailService emailService;

// 发送重置链接
public void sendResetLink(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UserNotFoundException("用户不存在"));

// 生成重置令牌
String token = UUID.randomUUID().toString();
PasswordResetToken resetToken = new PasswordResetToken();
resetToken.setToken(token);
resetToken.setUser(user);
resetToken.setExpiryDate(LocalDateTime.now().plusHours(24));
tokenRepository.save(resetToken);

// 发送邮件
String resetLink = "https://example.com/reset-password?token=" + token;
emailService.sendPasswordResetEmail(email, resetLink);
}

// 验证并重置密码
public void resetPassword(String token, String newPassword) {
PasswordResetToken resetToken = tokenRepository.findByToken(token)
.orElseThrow(() -> new InvalidTokenException("无效的重置令牌"));

// 检查令牌是否过期
if (resetToken.isExpired()) {
throw new ExpiredTokenException("重置令牌已过期");
}

// 检查令牌是否已使用
if (resetToken.isUsed()) {
throw new UsedTokenException("重置令牌已使用");
}

// 更新密码
User user = resetToken.getUser();
user.setPassword(passwordEncoder.encode(newPassword));
userRepository.save(user);

// 标记令牌为已使用
resetToken.setUsed(true);
tokenRepository.save(resetToken);
}
}

安全考虑

  • 重置令牌应随机生成,足够长
  • 令牌应有过期时间(建议 1-24 小时)
  • 令牌使用后立即失效
  • 发送重置邮件前不透露用户是否存在
  • 记录重置操作的审计日志

测试密码编码

@SpringBootTest
class PasswordEncoderTests {

@Autowired
private PasswordEncoder passwordEncoder;

@Test
void encodeAndMatch() {
String rawPassword = "password123";
String encoded = passwordEncoder.encode(rawPassword);

// 编码后的密码应与原始密码不同
assertThat(encoded).isNotEqualTo(rawPassword);

// 应能正确匹配
assertThat(passwordEncoder.matches(rawPassword, encoded)).isTrue();

// 错误密码应不匹配
assertThat(passwordEncoder.matches("wrong", encoded)).isFalse();
}

@Test
void samePasswordDifferentHash() {
String password = "password123";

String hash1 = passwordEncoder.encode(password);
String hash2 = passwordEncoder.encode(password);

// 相同密码生成不同的哈希值(因为有随机盐)
assertThat(hash1).isNotEqualTo(hash2);

// 但都能正确匹配
assertThat(passwordEncoder.matches(password, hash1)).isTrue();
assertThat(passwordEncoder.matches(password, hash2)).isTrue();
}

@Test
void delegatingEncoder() {
DelegatingPasswordEncoder encoder =
(DelegatingPasswordEncoder) PasswordEncoderFactories.createDelegatingPasswordEncoder();

// 新密码使用 bcrypt
String encoded = encoder.encode("password");
assertThat(encoded).startsWith("{bcrypt}");

// 能验证 bcrypt 格式
String bcryptHash = "{bcrypt}$2a$10$...";
assertThat(encoder.matches("password", bcryptHash)).isTrue();

// 能验证其他格式
String pbkdf2Hash = "{pbkdf2}5b9b3c2e1d...";
// ... 验证逻辑
}
}

小结

本章详细介绍了密码存储的安全实践:

  • 密码必须使用不可逆、加盐、慢哈希算法存储
  • BCrypt 是最推荐的密码哈希算法
  • 委托编码器支持多算法并存和渐进式升级
  • 密码强度验证和常见密码检查提升安全性
  • 密码修改和重置需要严格的安全流程
  • 测试确保密码编码的正确性

密码存储安全是应用安全的基石,正确实现密码编码对于保护用户账户至关重要。下一章将提供 Spring Security 的常用配置速查表。