PasswordEncoder
定义与作用
PasswordEncoder 是 Spring Security 定义密码哈希和比对规范的接口,只有两个方法:
public interface PasswordEncoder {
String encode(CharSequence rawPassword); // 明文 → 密文
boolean matches(CharSequence rawPassword, String encodedPassword); // 明文与密文比对
}
Spring Security 5.0 起强制要求使用 PasswordEncoder,不允许明文存储密码。DaoAuthenticationProvider 在认证阶段调用 matches() 验证用户输入的密码是否与数据库中的哈希值匹配。
手写过滤器的痛点
手写登录时,密码处理通常极其随意:
// 痛点1:明文存储,数据库泄露等于密码全泄露
// 痛点2:简单 MD5,彩虹表秒破
// 痛点3:没有统一盐值,相同密码哈希结果相同,易被批量撞库
// 痛点4:换算法时全部密码要重新生成,无法平滑迁移
// 痛点5:没有标准接口,每个项目写一套密码工具类
String storedPassword = user.getPassword(); // 可能是明文、MD5、SHA256...
if (storedPassword.equals(inputPassword)) { // 明文比对!
// 登录成功
}
PasswordEncoder 将密码编码抽象为标准接口,使算法选择、强度升级、多算法共存都有了统一方案。
核心原理
接口设计哲学
主要实现对比
| 实现类 | 算法 | 是否含盐 | 是否可配置强度 | 安全性 | 推荐场景 |
|---|---|---|---|---|---|
BCryptPasswordEncoder | BCrypt(Blowfish) | 自动随机盐 | 可配置 cost(默认10) | 高 | 通用首选 |
SCryptPasswordEncoder | SCrypt | 自动随机盐 | 可配置 | 高 | 内存密集型抗 GPU |
Argon2PasswordEncoder | Argon2 | 自动随机盐 | 可配置 | 最高 | 高安全场景 |
Pbkdf2PasswordEncoder | PBKDF2 | 可配置盐 | 可配置迭代次数 | 中高 | 合规/金融 |
DelegatingPasswordEncoder | 多算法委托 | 依子编码器 | 依子编码器 | — | 算法迁移期 |
NoOpPasswordEncoder | 明文(无编码) | 无 | 无 | 无 | 仅测试,禁止生产 |
升级编码机制
PasswordEncoder 接口提供 upgradeEncoding() 方法,用于判断当前密码是否需要重新编码(如升级算法或强度):
// DelegatingPasswordEncoder 源码简化
public boolean upgradeEncoding(String encodedPassword) {
String id = extractId(encodedPassword); // 提取 {bcrypt} 前缀
PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
return this.idForEncode.equals(id) && delegate.upgradeEncoding(encodedPassword);
}
当 upgradeEncoding() 返回 true 时,DaoAuthenticationProvider 会在认证成功后调用 UserDetailsPasswordService.updatePassword() 自动升级密码。
示例一:从无编码到 BCrypt 的强制迁移
场景说明
旧系统使用 NoOpPasswordEncoder(明文或简单编码),需升级到 BCryptPasswordEncoder。
操作前配置
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.passwordEncoder(NoOpPasswordEncoder.getInstance()) // 明文!危险!
.withUser("admin").password("admin123").roles("ADMIN");
}
}
操作后配置
切换到 BCryptPasswordEncoder,并预生成测试用户密码:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.passwordEncoder(passwordEncoder())
.withUser("admin")
.password(passwordEncoder().encode("admin123")) // 存储的是哈希值
.roles("ADMIN")
.and()
.withUser("user")
.password(passwordEncoder().encode("user123"))
.roles("USER");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
}
}
结果分析
- 数据库中存储的密码从
admin123变为$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy - 即使数据库泄露,攻击者拿到的是哈希值,无法反推明文
- 同一明文每次
encode()结果不同,因为 BCrypt 内置随机盐值
示例二:使用 DelegatingPasswordEncoder 实现多算法共存
场景说明
系统历史数据库中同时存在旧 MD5 密码和新 BCrypt 密码,需要一种编码器能自动识别并分别处理。
操作前配置
只能配置一种编码器,旧 MD5 密码无法验证:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 旧 {MD5}xxx 密码无法匹配
}
操作后配置
使用 DelegatingPasswordEncoder,通过密码前缀自动识别算法:
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
数据库中的密码格式示例:
-- 老用户:MD5 编码(已废弃但暂时保留)
INSERT INTO users (username, password) VALUES ('old_user', '{MD5}5f4dcc3b5aa765d61d8327deb882cf99');
-- 新用户:BCrypt 编码
INSERT INTO users (username, password) VALUES ('new_user', '{bcrypt}$2a$10$...');
-- 测试用户:明文(不推荐)
INSERT INTO users (username, password) VALUES ('test_user', '{noop}password');
UserDetailsService 无需改动,直接返回数据库中的密码字符串:
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) {
UserEntity entity = userMapper.findByUsername(username);
return User.builder()
.username(entity.getUsername())
.password(entity.getPassword()) // 直接返回 {bcrypt}... 或 {MD5}...
.roles("USER")
.build();
}
}
结果分析
DelegatingPasswordEncoder从密码中提取{bcrypt}、{MD5}、{noop}前缀- 根据前缀路由到对应的子编码器执行
matches() - 默认编码新密码时使用 BCrypt(
idForEncode = "bcrypt") - 逐步引导用户重新登录,将旧 MD5 密码升级为 BCrypt
易错场景:encode() 的返回值不能直接用于 matches() 的第二次比对
问题描述
开发者误以为 encode() 返回的字符串是固定的,试图将其缓存后多次使用:
// 错误:BCrypt 每次 encode 结果不同
String encoded1 = passwordEncoder.encode("password"); // $2a$10$abc...
String encoded2 = passwordEncoder.encode("password"); // $2a$10$xyz...
// 错误认知:
System.out.println(encoded1.equals(encoded2)); // false!但两次都是有效的
正确理解
BCryptPasswordEncoder 每次 encode() 生成不同的随机盐,所以相同明文的结果不同。验证时必须使用 matches() 而不是字符串比对:
// 正确:将 encode 的结果存入数据库
String storedPassword = passwordEncoder.encode(rawPassword);
// 正确:验证时用 matches
boolean match = passwordEncoder.matches(rawPassword, storedPassword); // true
面试陷阱题
问:为什么
BCryptPasswordEncoder.encode("123")调用两次,结果不一样?答:因为每次生成不同的随机盐值(salt)。盐值与明文一起参与哈希,所以结果不同,但两者都能通过
matches("123", result)验证。
面试考点:
- Spring Security 5.x 为什么强制要求
PasswordEncoder?DelegatingPasswordEncoder的密码前缀格式是什么?如何实现平滑迁移?upgradeEncoding()的触发条件是什么?DaoAuthenticationProvider如何利用它自动升级密码?- 生产环境为什么绝对不能用
NoOpPasswordEncoder?