DaoAuthenticationProvider
定义与作用
DaoAuthenticationProvider(Data Access Object Authentication Provider)是 Spring Security 中最常用、最基础的认证提供者。它基于 UserDetailsService 从数据源(数据库、内存、LDAP 等)加载用户信息,然后使用 PasswordEncoder 比对密码,完成认证。
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
private PasswordEncoder passwordEncoder;
private UserDetailsService userDetailsService;
// ...
}
手写过滤器的痛点
手写登录认证时,通常把所有逻辑写在一个类里:
// 痛点:查库、比对密码、判断账户状态全混在一起,无法复用
public class HandmadeLoginService {
public boolean login(String username, String password) {
User user = userDao.findByUsername(username); // 查库
if (user == null) return false;
if (user.isLocked()) return false; // 状态检查
if (user.isExpired()) return false;
return user.getPassword().equals(password); // 明文比对!无编码策略
}
}
DaoAuthenticationProvider 将认证流程拆分为标准阶段:加载用户 → 预检查 → 密码比对 → 后检查 → 组装结果。每个阶段都有标准接口和扩展点,安全且可复用。
核心原理
认证四阶段流程
各阶段源码逻辑(简化)
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) {
// 阶段1:加载用户
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException("UserDetailsService returned null");
}
return loadedUser;
}
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
// 阶段3:密码比对
if (authentication.getCredentials() == null) {
throw new BadCredentialsException("Bad credentials");
}
String presentedPassword = authentication.getCredentials().toString();
if (!this.getPasswordEncoder().matches(presentedPassword, userDetails.getPassword())) {
throw new BadCredentialsException("Bad credentials");
}
}
支持的 Token 类型
DaoAuthenticationProvider 只处理 UsernamePasswordAuthenticationToken:
@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
这意味着表单登录、HTTP Basic 等产生 UsernamePasswordAuthenticationToken 的过滤器,最终都会汇聚到 DaoAuthenticationProvider。
示例一:自定义用户加载 + 密码编码升级
场景说明
系统早期使用 MD5 存储密码,现迁移到 BCrypt。需要让 DaoAuthenticationProvider 在验证 MD5 密码通过后,自动将其升级为 BCrypt。
操作前配置
密码以 MD5 明文存储,无编码器:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService); // 没有 passwordEncoder,Spring Security 5.x 会报错
}
操作后配置
使用 DelegatingPasswordEncoder + UserDetailsPasswordService 实现自动升级:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@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());
}
}
自定义 UserDetailsService 同时实现 UserDetailsPasswordService:
@Service
public class CustomUserDetailsService implements UserDetailsService, UserDetailsPasswordService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity entity = userMapper.findByUsername(username);
if (entity == null) {
throw new UsernameNotFoundException("用户不存在: " + username);
}
return User.builder()
.username(entity.getUsername())
.password(entity.getPassword()) // 数据库中可能是 {MD5}xxx 或 {bcrypt}xxx
.roles(entity.getRole().split(","))
.build();
}
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
// 认证成功后,DaoAuthenticationProvider 会自动调用此方法
userMapper.updatePassword(user.getUsername(), newPassword);
return User.builder()
.username(user.getUsername())
.password(newPassword)
.authorities(user.getAuthorities())
.build();
}
}
结果分析
- 用户密码在数据库中存储为
{MD5}abc123... DelegatingPasswordEncoder识别{MD5}前缀,使用 MD5 编码器比对密码- 密码验证通过后,
DaoAuthenticationProvider检测到当前编码策略与默认策略不一致,调用updatePassword()将密码更新为{bcrypt}$2a$10$... - 下次登录时,自动使用 BCrypt 比对
示例二:利用预检查实现账户锁定策略
场景说明
业务要求:用户连续 5 次密码错误后,账户锁定 30 分钟。通过自定义 UserDetails 返回的账户状态,让 DaoAuthenticationProvider 的预检查自动拦截锁定账户。
操作前配置
没有账户锁定机制,密码错误可无限重试:
@Override
public UserDetails loadUserByUsername(String username) {
UserEntity entity = userMapper.findByUsername(username);
return new org.springframework.security.core.userdetails.User(
entity.getUsername(),
entity.getPassword(),
AuthorityUtils.createAuthorityList("ROLE_USER")
);
}
操作后配置
自定义 UserDetails 实现,将登录失败次数和锁定时间纳入状态判断:
@Service
public class LockableUserDetailsService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Autowired
private LoginAttemptService loginAttemptService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity entity = userMapper.findByUsername(username);
if (entity == null) {
throw new UsernameNotFoundException("用户不存在");
}
boolean isLocked = loginAttemptService.isBlocked(username);
boolean isEnabled = entity.isActive();
return new LockableUserDetails(
entity.getUsername(),
entity.getPassword(),
isEnabled,
!isLocked, // accountNonLocked:锁定状态
true, // accountNonExpired
true, // credentialsNonExpired
AuthorityUtils.createAuthorityList("ROLE_USER")
);
}
}
public class LockableUserDetails extends org.springframework.security.core.userdetails.User {
public LockableUserDetails(String username, String password, boolean enabled,
boolean accountNonLocked, boolean accountNonExpired,
boolean credentialsNonExpired, Collection authorities) {
super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
}
}
监听认证失败事件,更新失败计数:
@Component
public class AuthenticationFailureListener implements ApplicationListener<AuthenticationFailureBadCredentialsEvent> {
@Autowired
private LoginAttemptService loginAttemptService;
@Override
public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent event) {
String username = event.getAuthentication().getPrincipal().toString();
loginAttemptService.loginFailed(username);
}
}
配置 DaoAuthenticationProvider:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(lockableUserDetailsService)
.passwordEncoder(new BCryptPasswordEncoder());
}
结果分析
- 当用户连续 5 次密码错误,
loginAttemptService标记该用户为锁定状态 - 第 6 次登录时,
loadUserByUsername()返回的UserDetails中accountNonLocked = false DaoAuthenticationProvider在preAuthenticationChecks阶段检测到!isAccountNonLocked(),抛出LockedException- 无需改动
DaoAuthenticationProvider本身,仅通过UserDetailsService返回的状态即可实现账户锁定
易错场景:密码错误 vs 用户不存在的信息泄露
问题描述
新手常试图在 UserDetailsService 中区分"用户不存在"和"密码错误",给前端返回不同的错误提示,这会造成用户枚举漏洞。
错误代码
@Service
public class BadUserDetailsService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) {
UserEntity user = userMapper.findByUsername(username);
if (user == null) {
// 危险!攻击者可以通过不同错误信息枚举哪些用户名存在
throw new UsernameNotFoundException("用户不存在"); // ← 信息泄露
}
return new User(user.getUsername(), user.getPassword(), ...);
}
}
正确做法
DaoAuthenticationProvider 默认已经将 UsernameNotFoundException 和 BadCredentialsException 统一包装为 BadCredentialsException:
// AbstractUserDetailsAuthenticationProvider 源码
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
} catch (UsernameNotFoundException ex) {
// 注意:如果 hideUserNotFoundExceptions = true(默认),会转为 BadCredentialsException
if (this.hideUserNotFoundExceptions) {
throw new BadCredentialsException("Bad credentials");
} else {
throw ex;
}
}
默认情况下,无论用户不存在还是密码错误,前端收到的都是 BadCredentialsException: Bad credentials。保持 hideUserNotFoundExceptions = true(默认),不要试图关闭它。
面试考点:
DaoAuthenticationProvider的认证四阶段分别是什么?hideUserNotFoundExceptions的作用是什么?为什么默认隐藏?- 如何通过
UserDetailsService实现账户锁定/过期,而不改动DaoAuthenticationProvider?