DelegatingPasswordEncoder
定义与作用
DelegatingPasswordEncoder 是 Spring Security 提供的委托式密码编码器,它通过密码前缀 {algorithm} 识别不同的编码算法,将验证任务委派给对应的子编码器。它是系统从旧密码编码迁移到新编码的桥梁方案。
public class DelegatingPasswordEncoder implements PasswordEncoder {
private final String idForEncode; // 编码新密码时使用的默认算法ID
private final Map<String, PasswordEncoder> idToPasswordEncoder; // 算法ID → 编码器映射
private final PasswordEncoder defaultPasswordEncoderForMatches; // 无前缀时的默认编码器
// ...
}
手写过滤器的痛点
系统升级密码算法时,手写方案通常面临两难:
// 痛点:数据库中既有旧 MD5 密码,又有新 BCrypt 密码
// 方案A:一次性要求所有用户重置密码 → 用户体验极差,流失率高
// 方案B:维护两套登录逻辑,先判断密码长度再分支处理 → 代码混乱,易出错
// 方案C:写一堆正则匹配前缀,自己路由到不同编码器 → 重复造轮子
public boolean checkPassword(String input, String stored) {
if (stored.startsWith("$2a$")) {
return bcrypt.matches(input, stored);
} else if (stored.length() == 32) {
return md5(input).equals(stored);
}
return false;
}
DelegatingPasswordEncoder 通过标准化前缀协议和内置路由逻辑,让多算法共存变得透明。
核心原理
前缀识别与路由机制
支持的算法前缀
| 前缀 | 编码器 | 说明 |
|---|---|---|
{bcrypt} | BCryptPasswordEncoder | 默认编码新密码 |
{noop} | NoOpPasswordEncoder | 明文,仅测试使用 |
{pbkdf2} | Pbkdf2PasswordEncoder | PBKDF2 |
{scrypt} | SCryptPasswordEncoder | SCrypt |
{sha256} | StandardPasswordEncoder | SHA-256(已废弃) |
{MD5} | MessageDigestPasswordEncoder | MD5(需手动注册) |
工厂方法创建
// 使用 Spring Security 提供的工厂方法,默认以 BCrypt 编码新密码
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
// 手动构建(自定义算法组合)
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt", new BCryptPasswordEncoder(12));
encoders.put("bcrypt10", new BCryptPasswordEncoder(10));
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
DelegatingPasswordEncoder delegatingEncoder =
new DelegatingPasswordEncoder("bcrypt", encoders);
示例一:从 SHA256 平滑迁移到 BCrypt
场景说明
旧系统使用 SHA256 编码密码,现需迁移到 BCrypt。要求在迁移期间,所有用户都能正常登录,无需强制重置密码。
操作前配置
数据库中只有 SHA256 哈希,无算法前缀:
-- 旧数据格式(无前缀,系统默认知道是 SHA256)
SELECT username, password FROM users;
-- admin | 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
旧配置只支持一种编码器:
@Bean
public PasswordEncoder passwordEncoder() {
return new MessageDigestPasswordEncoder("SHA-256"); // 无法处理 BCrypt
}
操作后配置
步骤1:为数据库中所有旧密码添加 {SHA-256} 前缀
UPDATE users SET password = CONCAT('{SHA-256}', password)
WHERE password NOT LIKE '{%}';
步骤2:配置 DelegatingPasswordEncoder 支持两种算法
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
return new DelegatingPasswordEncoder("bcrypt", encoders);
}
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
provider.setUserDetailsPasswordService((UserDetailsPasswordService) userDetailsService);
auth.authenticationProvider(provider);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
}
}
@Service
public class MigrationUserDetailsService implements UserDetailsService, UserDetailsPasswordService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) {
UserEntity entity = userMapper.findByUsername(username);
return User.builder()
.username(entity.getUsername())
.password(entity.getPassword()) // 格式:{SHA-256}xxx 或 {bcrypt}xxx
.roles("USER")
.build();
}
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
userMapper.updatePassword(user.getUsername(), newPassword);
return User.builder()
.username(user.getUsername())
.password(newPassword)
.authorities(user.getAuthorities())
.build();
}
}
结果分析
- 旧用户登录时,密码前缀为
{SHA-256},DelegatingPasswordEncoder路由到 SHA-256 编码器验证通过 - 验证成功后,
upgradeEncoding()发现当前默认是{bcrypt},旧密码需要升级 DaoAuthenticationProvider调用updatePassword(),将密码重新编码为{bcrypt}$2a$10$...- 新注册用户直接以
{bcrypt}格式存储,无需经历迁移 - 随着时间推移,所有活跃用户逐步完成密码升级,旧算法可以最终下线
示例二:多系统合并后的密码兼容
场景说明
公司并购后,两个系统的用户库合并。系统A使用 BCrypt,系统B使用 PBKDF2,合并后需要统一认证入口。
操作前配置
两个系统各自维护自己的编码器,用户需要分别登录:
// 系统A
@Bean
public PasswordEncoder encoderA() { return new BCryptPasswordEncoder(); }
// 系统B
@Bean
public PasswordEncoder encoderB() { return new Pbkdf2PasswordEncoder(); }
操作后配置
统一使用 DelegatingPasswordEncoder 兼容两种格式:
@Bean
public PasswordEncoder passwordEncoder() {
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("pbkdf2_sha256", new Pbkdf2PasswordEncoder("sha256", 185000, 256));
return new DelegatingPasswordEncoder("bcrypt", encoders);
}
数据迁移脚本:
-- 为系统A用户添加前缀(如原本已无前缀,默认视为 BCrypt)
UPDATE users SET password = CONCAT('{bcrypt}', password)
WHERE source_system = 'A' AND password NOT LIKE '{%}';
-- 为系统B用户添加前缀
UPDATE users SET password = CONCAT('{pbkdf2}', password)
WHERE source_system = 'B' AND password NOT LIKE '{%}';
UserDetailsService 统一查询:
@Service
public class MergedUserDetailsService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) {
UserEntity entity = userMapper.findByUsername(username);
if (entity == null) {
throw new UsernameNotFoundException("用户不存在");
}
return User.builder()
.username(entity.getUsername())
.password(entity.getPassword()) // 自动携带 {bcrypt} 或 {pbkdf2} 前缀
.roles(entity.getRole().split(","))
.build();
}
}
结果分析
- 合并后的用户库中,两种密码格式共存,前缀自动区分来源
DelegatingPasswordEncoder根据前缀路由到正确的编码器,单一认证入口处理所有用户- 新用户统一使用
{bcrypt}注册,长期目标是将 PBKDF2 用户逐步迁移
易错场景:忘记给旧密码添加前缀
问题描述
迁移时直接切换为 DelegatingPasswordEncoder,但数据库中的旧密码没有 {algorithm} 前缀,导致所有旧用户登录失败。
错误配置
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
数据库中的旧密码:
-- 无前缀!DelegatingPasswordEncoder 无法识别
SELECT password FROM users WHERE username = 'alice';
-- 返回: $2a$10$xxxxxxxx...
原因分析
PasswordEncoderFactories.createDelegatingPasswordEncoder() 默认的新编码格式是 {bcrypt}...。当密码没有前缀时,DelegatingPasswordEncoder 尝试查找空字符串 id,找不到对应的编码器,直接抛出 IllegalArgumentException。
正确做法
迁移时必须为历史密码补充前缀:
-- 假设旧密码全部是 BCrypt 格式(以 $2a$ 开头)
UPDATE users SET password = CONCAT('{bcrypt}', password)
WHERE password LIKE '$2a$%';
或者在创建 DelegatingPasswordEncoder 时指定一个默认编码器用于处理无前缀密码:
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt", new BCryptPasswordEncoder());
// 用 BCrypt 作为默认编码器,处理没有前缀的密码
DelegatingPasswordEncoder delegating = new DelegatingPasswordEncoder("bcrypt", encoders);
// 但这样无法区分不同旧算法,仅适用于所有旧密码都是同一种算法的情况
面试考点:
DelegatingPasswordEncoder的密码前缀格式是什么?如何提取和路由?- 为什么
PasswordEncoderFactories.createDelegatingPasswordEncoder()默认使用 BCrypt?- 密码迁移期间,旧密码无前缀怎么办?
upgradeEncoding()在什么条件下返回 true?DaoAuthenticationProvider如何触发自动升级?