一、背景
当前场景:用户在登录失败需要根据不同的场景返回不同的提示信息,例如账号不存在或密码输错提示 “用户名或密码错误”,账号禁用是提示 "账户被锁定"等,默认输错5次密码后账户会被锁定。
需求:当最后一次输错密码时需要给用户提示出 “xxx账户将被锁定” 的提示,而不是提示 “用户名或密码错误”
分析:前四次输错提示 “用户名或密码错误”,第5次输错提示 “xxx账户将被锁定”,那么需要在密码校验失败时,获取到这是第几次输错
二、实现方案
2.1 登录失败记录输错次数
@Component
public class LoginFailedListener implements ApplicationListener<AbstractAuthenticationFailureEvent> {
@Autowired
private CustomProperties customProperties ;
@Autowired
private UserDao userDao;
@Override
public void onApplicationEvent(AbstractAuthenticationFailureEvent abstractAuthenticationFailureEvent) {
String loginName = abstractAuthenticationFailureEvent.getAuthentication()
.getName();
if (log.isInfoEnabled()) {
log.info("登录失败 loginName=[{}]", loginName);
}
if (StringUtils.isBlank(loginName)) {
return;
}
//查询登录账号是否存在
UserDo condition = new UserDo();
condition.setLoginName(loginName);
List<UserDo> userDos = userDao.selectByRecord(condition);
if (CollectionUtils.isEmpty(userDos)) {
return;
}
UserDo userDo = userDos.get(0);
UserDo updateDo = new UserDo();
updateDo.setUserId(userDo.getUserId());
Integer loginFailMaxCount = customroperties.getLoginFailMaxCount();
if (loginFailMaxCount <= userDo.getTryTime() + 1) {
//更新用户状态为冻结
updateDo.setStatus(EnumUserStatus.FORBIDDEN.getCode());
updateDo.setTryTime(loginFailMaxCount);
if (log.isInfoEnabled()) {
log.info("登录次数已达最大次数:{} 冻结账户 loginName=[{}]", loginFailMaxCount, loginName);
}
} else {
//更新尝试次数
updateDo.setTryTime(userDo.getTryTime() + 1);
if (log.isInfoEnabled()) {
log.info("尝试次数+1 loginName=[{}], tryTime=[{}]", loginName, updateDo.getTryTime());
}
}
updateDo.setUpdateTime(new Date());
userDao.updateBySelective(updateDo);
}
}
2.2 重写校验方法,满足条件时抛出自定义异常
- 校验抛出的异常一定要是AuthenticationException及其子类
- 因为创建了监听器ApplicationListener的实现,源码中回调是有条件的,所以最后最好是原样抛出 BadCredentialsException ,否则ApplicationListener将不会被触发
@Slf4j
public class CustomDaoAuthenticationProvider extends DaoAuthenticationProvider {
@Autowired
private CustomProperties customProperties ;
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
try {
super.additionalAuthenticationChecks(userDetails, authentication);
} catch (AuthenticationException e) {
if(e instanceof BadCredentialsException && userDetails instanceof UserDto){
UserDto userDto = (UserDto) userDetails;
//先到这里,然后去触发LoginFailedListener,达到账户被锁定这里需要+1
//已经被冻结的不处理,只处理正常用户,并且是达到最大失败次数的那一次
if(EnumUserStatus.NORMAL.getCode().equals(userDto.getStatus()) && Objects.equals(userDto.getTryTime() + 1, customProperties .getLoginFailMaxCount())){
if(log.isErrorEnabled()){
log.error("用户:{} 登录失败次数已达最大,账户将被锁定", userDto.getLoginName());
}
throw new BadCredentialsException(e.getMessage(), new LoginFailCountOutException("登录失败次数已达最大"));
}else {
throw e;
}
}else {
throw e;
}
}
}
}
public class LoginFailCountOutException extends AuthenticationException {
private static final long serialVersionUID = -8546980609242201580L;
/**
* Constructs an {@code AuthenticationException} with the specified message and no
* root cause.
*
* @param msg the detail message
*/
public LoginFailCountOutException(String msg) {
super(msg);
}
public LoginFailCountOutException(String msg, Throwable ex) {
super(msg, ex);
}
}
2.4 装配自定义的AuthenticationProvider
@Bean
public AuthenticationProvider authenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder, UserAuthoritiesMapper userAuthoritiesMapper) {
DaoAuthenticationProvider authenticationProvider = new CustomDaoAuthenticationProvider();
// 对默认的UserDetailsService进行覆盖
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
authenticationProvider.setAuthoritiesMapper(userAuthoritiesMapper);
return authenticationProvider;
}
WebSecurityConfigurerAdapter 的配置类中注入
@Autowired
private AuthenticationProvider authenticationProvider;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authenticationProvider);
}
2.5 AuthenticationFailureHandler 根据异常返回提示
failureHandler((request, response, ex) -> {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
String errorMessage = this.loginFailureErrorMessage(ex);
out.write(JSON.toJSONString(Response.<Void>builder().isSuccess(false)
.errorMessage(errorMessage)
.build()));
out.flush();
out.close();
})
private String loginFailureErrorMessage(AuthenticationException ex) {
if (ex instanceof UsernameNotFoundException || ex instanceof BadCredentialsException) {
if(ex.getCause() != null && ex.getCause() instanceof LoginFailCountOutException){
return "登录失败次数已达最大限制, 账户冻结";
}
return "用户名或密码错误";
}
if (ex instanceof DisabledException) {
return "账户被禁用";
}
if (ex instanceof LockedException) {
return "账户被锁定";
}
if (ex instanceof AccountExpiredException) {
return "账户已过期";
}
if (ex instanceof CredentialsExpiredException) {
return "密码已过期";
}
log.warn("不明原因登录失败", ex);
return "登录失败";
}
有其他更好方法的小伙伴欢迎评论指正