密码存储
密码存储安全是应用安全的基础环节。一旦密码泄露,用户账户将面临被接管的风险。Spring Security 提供了完善的密码编码支持,本章将详细介绍密码存储的最佳实践和各种加密算法的使用。
密码存储安全原则
常见错误做法
明文存储
最危险的做法,数据库泄露直接暴露所有密码:
// 错误:明文存储
user.setPassword(password); // 不要这样做!
可逆加密
加密密码可以被解密,密钥泄露等同于明文泄露:
// 错误:可逆加密
String encrypted = AES.encrypt(password, secretKey); // 不要这样做!
简单哈希
MD5、SHA-1 等简单哈希容易被彩虹表破解:
// 错误:简单哈希
String hash = MD5(password); // 不要这样做!
正确做法
密码存储应该满足以下条件:
- 不可逆:无法从哈希值还原原始密码
- 加盐:每个密码使用不同的随机盐值
- 慢哈希:增加计算成本,抵抗暴力破解
- 可配置:支持升级加密算法
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);
}
验证流程
- 根据存储密码的前缀
{算法ID}选择对应的编码器 - 使用选定的编码器验证密码
- 如果密码是旧格式(无前缀),使用默认编码器
自动升级密码
登录成功后自动将旧算法密码升级为新算法:
@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 的常用配置速查表。