密码加密
密码必须经过单向哈希存储,永远不能以明文或可逆加密形式保存。Spring Security 提供了完整的 PasswordEncoder 体系。
为什么不能用 MD5 / SHA
MD5("123456") = "e10adc3949ba59abbe56e057f20f883e"
- 彩虹表攻击:常见密码的 MD5 值已预先计算好,查表即可还原
- 无盐:相同密码哈希相同,一旦泄露可批量破解
- 速度太快:MD5 每秒可计算数十亿次,暴力破解成本极低
现代密码哈希算法(BCrypt / Argon2 / SCrypt)的设计目标正好相反:故意慢、内置盐、可调成本。
BCrypt(Spring Security 默认)
BCrypt 每次哈希自动生成随机盐,相同密码每次输出不同,且内置成本因子(cost)控制计算速度。
基础用法
@Bean
public PasswordEncoder passwordEncoder() {
// strength 是 log2(迭代次数),默认 10,范围 4~31
// strength=10 → 2^10=1024 次迭代,约 100ms/次(适合大多数场景)
// strength=12 → 约 400ms,安全性更高但登录稍慢
return new BCryptPasswordEncoder(10);
}@Service
@RequiredArgsConstructor
public class UserService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepo;
// 注册:加密存储
public void register(String username, String rawPassword) {
String encoded = passwordEncoder.encode(rawPassword);
// encoded 示例:$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
userRepo.save(new User(username, encoded));
}
// 登录:校验密码
public boolean login(String username, String rawPassword) {
User user = userRepo.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
// matches 内部提取盐值后重新哈希再比较,无需手动处理盐
return passwordEncoder.matches(rawPassword, user.getPassword());
}
}BCrypt 输出格式:
$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
│ │ │ │
│ │ └─ 盐(22位 base64)+ 哈希值(31位 base64)
│ └──── cost factor(10 = 2^10 次迭代)
└─────── BCrypt 版本号
算法对比
| 算法 | 抗 GPU 暴力破解 | 内存硬度 | Spring Security 支持 | 推荐场景 |
|---|---|---|---|---|
| BCrypt | 好 | 无 | ✓ 默认 | 通用,新项目首选 |
| Argon2 | 极好 | 强(可配置) | ✓ | 高安全要求(金融、医疗) |
| SCrypt | 极好 | 强 | ✓ | 高安全要求 |
| PBKDF2 | 好 | 无 | ✓ | FIPS 合规要求 |
| MD5 / SHA-1 / SHA-256 | 差 | 无 | ✗(不应使用) | — |
Argon2 配置
@Bean
public PasswordEncoder passwordEncoder() {
// saltLength=16字节, hashLength=32字节, parallelism=1, memory=65536KB, iterations=3
return new Argon2PasswordEncoder(16, 32, 1, 65536, 3);
}DelegatingPasswordEncoder(多算法迁移)
生产系统中历史用户可能使用旧算法(MD5 / SHA),DelegatingPasswordEncoder 支持同时兼容多种算法,无需强迫用户重置密码:
@Bean
public PasswordEncoder passwordEncoder() {
// 默认编码器:新密码用 BCrypt
PasswordEncoder defaultEncoder = new BCryptPasswordEncoder();
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt", defaultEncoder);
encoders.put("argon2", new Argon2PasswordEncoder(16, 32, 1, 65536, 3));
encoders.put("noop", NoOpPasswordEncoder.getInstance()); // 明文(测试用)
// 新密码前缀为 {bcrypt},旧密码前缀为 {noop} 等
return new DelegatingPasswordEncoder("bcrypt", encoders);
}存储格式:{bcrypt}$2a$10$...,前缀标识算法。
迁移旧密码到 BCrypt
@Service
@RequiredArgsConstructor
public class PasswordMigrationService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepo;
// 登录成功时,若密码是旧格式则自动升级
public void upgradePasswordIfNeeded(User user, String rawPassword) {
if (passwordEncoder instanceof DelegatingPasswordEncoder dp) {
if (dp.upgradeEncoding(user.getPassword())) {
// 旧算法,重新加密后保存
user.setPassword(passwordEncoder.encode(rawPassword));
userRepo.save(user);
log.info("用户 {} 密码已升级到 BCrypt", user.getUsername());
}
}
}
}与 Spring Security 集成
Spring Security 的 DaoAuthenticationProvider 自动使用注册的 PasswordEncoder Bean 校验密码:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) throws Exception {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder); // 注入加密器
return new ProviderManager(provider);
}
}Spring Security 完整配置详见 安全,JWT 认证流程详见 OAuth2与JWT。
修改密码
修改密码时必须先验证旧密码,防止会话劫持后直接改密:
@PutMapping("/users/me/password")
public void changePassword(@RequestBody ChangePasswordRequest req,
@AuthenticationPrincipal UserDetails currentUser) {
User user = userRepo.findByUsername(currentUser.getUsername()).orElseThrow();
// 1. 验证旧密码
if (!passwordEncoder.matches(req.getOldPassword(), user.getPassword())) {
throw new BusinessException("原密码错误");
}
// 2. 校验新密码强度
if (req.getNewPassword().length() < 8) {
throw new BusinessException("密码长度不能少于 8 位");
}
// 3. 加密并保存
user.setPassword(passwordEncoder.encode(req.getNewPassword()));
userRepo.save(user);
// 4. 使旧 Token 失效(防止原 Token 继续使用)
tokenService.invalidateAll(user.getId());
}数据脱敏(密码字段序列化)
防止密码字段在日志或 API 响应中意外泄漏:
public class User {
private String username;
@JsonIgnore // 序列化时永远不输出
private String password;
// 或者:只允许反序列化(接收),不允许序列化(输出)
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;
}日志中避免打印用户对象时暴露密码:
@Override
public String toString() {
// 不包含 password 字段
return "User{id=" + id + ", username='" + username + "'}";
}数据脱敏详见 数据脱敏。
常见错误
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 明文存储密码 | 数据库泄露即全军覆没 | 使用 PasswordEncoder.encode() |
| 使用 MD5 / SHA | 彩虹表、GPU 暴力破解 | 使用 BCrypt / Argon2 |
| 手动加盐后用 SHA | 盐存库里等于没有 | BCrypt 自动处理盐 |
| 忘记校验旧密码就改密 | 会话劫持后可任意改密 | 修改前调用 matches() 验证 |
| 在日志中打印密码 | 日志文件即泄漏 | @JsonIgnore + 重写 toString() |
相关链接
- 安全 — Spring Security 完整配置,
PasswordEncoderBean 注册位置 - OAuth2与JWT — 登录流程中的密码验证
- 数据脱敏 — 敏感字段的序列化保护
- Session管理 — 改密后使旧 Session/Token 失效