本篇为自定义验证码登录的下篇,主要会对自定义登录的流程进行讲解;我们曾在认证(二):表单登录的源码解析中对 Spring Security
的认证流程有过比较详细的学习。
“模仿不是创作,但创作不能不有模仿”————周谷城。
我们可以从表单登录认证流程中寻找灵感,定制化开发出验证码登录认证。
流程分析
首先先回顾一下表单登录的认证流程:
大致上有这么几个比较核心的类/接口:
-
UsernamePasswordAuthenticationFilter
过滤器,用以捕获表单登录请求。 -
UsernamePasswordAuthenticationToken
表单登录token
,用以匹配对应的认证Provider
。 -
AuthenticationManager
认证管理器,即不干活的"大佬"。 -
ProviderManager
管理领域的"二把手"。 -
DaoAuthenticationProvider
执行表单登录认证的Provider
,即真正干活的"小弟"。
分析得出,除了管理领域的“大佬” AuthenticationManager
和 “二把手” ProviderManager
不需要我们额外实现,剩下的我们都需要自定义实现。
基于这个结论,我大致描绘出验证码登录认证的流程图:
自定义验证码认证,需要将Filter
、Token
以及 Provider
嵌入到整个认证链路中;所以自定义认证需要做以下3件事情:
-
自定义
ValidateCodeAuthenticationFilter
,类比UsernamePasswordAuthenticationFilter
-
自定义
SmsValidateCodeAuthenticationToken
,类比UsernamePasswordAuthenticationToken
-
自定义
SmsValidateCodeAuthenticationProvider
,类比DaoAuthenticationProvider
流程实现
通过上面的流程分析,我们可以大致弄清楚需要做什么事情了;那么 Just Do it!!!
自定义ValidateCodeAuthenticationFilter
ValidateCodeAuthenticationFilter
拦截器的作用是:从请求中获取验证码进行验证。
可能有人会有疑问:从请求中获取验证码进行验证,听起来似乎是 Provider
做的事情呀,为什么会放在这里呢(类比表单登录的 Filter
只是做了请求参数到 Token
的转换,真实的校验是在 Provider
中)?
这里这么做是为了复用验证码的功能,在很多时候我们都用使用验证码的需求:修改密码、付款…… 如果将验证码的校验放在 Provider
模块的话,一定程度上限制了验证码只能用于登录。
ValidateCodeAuthenticationFilter.class
/**
* 验证码拦截器(自定义springSecurity过滤器实现,在登录之前进行验证码验证)
*
* InitializingBean接口为bean提供了初始化方法的方式,它只包括afterPropertiesSet方法,
* 凡是继承该接口的类,在初始化bean的时候都会执行该方法。
*
* @author 小奇
* @date 2020/10/08
**/
@Slf4j
@Component("ValidateCodeAuthenticationFilter")
public class ValidateCodeAuthenticationFilter extends OncePerRequestFilter implements InitializingBean {
/**
* key为请求的路径 map为验证码类型
* 存放所有需要校验验证码的url(例如:验证码登录、支付时需要填写验证码等等……)
*/
private Map<String, ValidateCodeEnum> urlMap = new HashMap<>(8);
/**
* 验证请求url与配置的url是否匹配的工具类
*/
private AntPathMatcher pathMatcher = new AntPathMatcher();
/**
* 通过配置文件的方式配置 一些需要验证码认证的url
*/
@Autowired
private SecurityProperties securityProperties;
/**
* 认证失败处理器
*/
@Autowired
private WebAuthenticationFailHandler failureHandler;
/**
* Holder是对Processor的更上一级调用
*/
@Autowired
private ValidateCodeProcessorHolder validateCodeProcessorHolder;
/**
* 初始化本类的Bean对象时候,会执行该方法进行相应的初始化
*/
@Override
public void afterPropertiesSet() {
initUrlMap(securityProperties.getCode().getImage().getUrls(), ValidateCodeEnum.IMAGE);
initUrlMap(securityProperties.getCode().getSms().getUrls(), ValidateCodeEnum.SMS);
}
/**
* 拦截 校验验证码
*
* @param request
* @param response
* @param chain
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 获取请求的类型 生成对应的key
Optional<ValidateCodeEnum> codeEnumOpt = findValidateCodeType(request);
ValidateCodeEnum codeEnum = codeEnumOpt.orElseThrow(() -> new ValidateCodeException("请求不需要做验证码拦截"));
log.info("校验请求(" + request.getRequestURI() + ")中的验证码,验证码类型" + codeEnum);
try {
validateCodeProcessorHolder.findValidateCodeProcessor(codeEnum).validate(new ServletWebRequest(request, response));
log.info("验证码校验通过");
} catch (ValidateCodeException exception) {
failureHandler.onAuthenticationFailure(request, response, exception);
return;
}
// 执行后续拦截器链
chain.doFilter(request, response);
}
/**
* 根据请求request获取请求的校验码类型
* 如果该请求不需要进行验证验证码拦截则返回空对象 验证码只针对POST请求
* 限制了一个请求路径只能有一种验证码校验
*
* @param request
* @return Optional<ValidateCodeEnum>
*/
private Optional<ValidateCodeEnum> findValidateCodeType(HttpServletRequest request) {
if (StringUtils.equalsIgnoreCase(request.getMethod(), HttpConstant.POST)) {
Set<String> keySet = urlMap.keySet();
return keySet.stream().filter(key -> pathMatcher.match(key, request.getRequestURI())).map(key -> urlMap.get(key)).findFirst();
}
log.warn("校验验证码---请求方式不符合要求");
return Optional.empty();
}
/**
* 装载匹配对应的拦截url到map中
* 配置文件中url串以逗号分割(这个可自定义 并无强制要求,这里只是提供一种实现的方案)
* @param urls
* @param codeEnum
*/
private void initUrlMap(String urls, ValidateCodeEnum codeEnum) {
if (StringUtils.isNotBlank(urls)) {
String[] urlArr = urls.split(CommonConstant.COMMA);
Arrays.stream(urlArr).forEach(url -> urlMap.put(url, codeEnum));
}
}
}
我们可以看到 ValidateCodeAuthenticationFilter
类继承了 OncePerRequestFilter
并且实现于 InitializingBean
,那么这两个类/接口各自有什么用呢?
OncePerRequestFilter
能够确保在一次请求中只通过一次 Filter
,而不需要重复的执行。大家常识上都认为,一次请求本来就只Filter一次,为什么还要由此特别限定呢?
往往我们的常识和实际的实现并不真的一样,经过一番资料的查阅,此方法是为了兼容不同的 Web Container
,也就是说并不是所有的 Container
都入我们期望的只过滤一次,servlet
版本不同,执行过程也不同;而 OncePerRequestFilter
就能够确保一次请求指通过一次 Filiter
。
InitializingBean
是一个初始化设置相关的配置,Spring
在初始化bean的时候,如果bean实现了 InitializingBean
接口,那么会自动调用 afterPropertiesSet
方法进行初始化。
自定义SmsValidateCodeAuthenticationToken
SmsValidateCodeAuthenticationToken
的作用类比于 UsernamePasswordAuthenticationToken
,认证成功之前代表 认证参数信息
,认证成功之后代表 用户信息
。
故而在实现上,我们也可以模仿 UsernamePasswordAuthenticationToken
。
SmsValidateCodeAuthenticationToken.class
/**
* 自定义登录方式---短信验证码登录
* 类比UsernamePasswordAuthenticationToken
*
* @author 小奇
* @date 2020/10/08
**/
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 530L;
/**
* 登录之前放手机号码、登录之后放用户的信息
*/
private final Object principal;
/**
* 认证之前的构造器
*
* @param mobile 认证请求mobile参数
*/
public SmsCodeAuthenticationToken(String mobile) {
super(null);
this.principal = mobile;
// 没认证之前设置为false
this.setAuthenticated(false);
}
/**
* 认证成功之后的构造器
*
* @param principal 用户信息
* @param authorities 用户权限
*/
public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
// 已认证 设置为true
super.setAuthenticated(true);
}
/**
* 这个是登录的密码 这里不需要 直接返回nul
*/
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
} else {
super.setAuthenticated(false);
}
}
/**
* 方法擦除凭证信息,也就是你的密码,这个擦除方法比较简单,就是将 Token 中的 credentials 属性置空。
* 用以保护密码不直接返回前端
*/
@Override
public void eraseCredentials() {
super.eraseCredentials();
}
}
自定义SmsValidateCodeAuthenticationProvider
自定义 SmsValidateCodeAuthenticationProvider
,类比 DaoAuthenticationProvider
;主要功能为:根据用户输入的手机号,进行数据库用户的匹配校验(验证码校验已抽离到 Filter
)。
SmsCodeAuthenticationProvider.class
/**
* Provider 真实做验证操作,类比账号密码登录的DaoAuthenticationProvider
* @author 小奇
* @date 2020/10/08
**/
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
/**
* 具体根据项目中用以表示用户信息的service
*/
private final HrService hrService;
/**
* 构造器注入
* @param hrService
*/
public SmsCodeAuthenticationProvider(HrService hrService) {
this.hrService = hrService;
}
/**
* 身份验证的逻辑
* @param authentication
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 通过手机号去查询用户 authenticationToken 没认证
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
Hr hr = hrService.getUserInfoByPhone((String) authentication.getPrincipal());
if (hr == null) {
throw new InternalAuthenticationServiceException("无法根据手机号:" + authentication.getPrincipal() + "获取用户信息");
}
// 放入用户信息以及权限 以及设置详细信息 authenticationResult 认证之后
SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(hr, hr.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
/**
* 判断该provider能够支持的authenticationToken验证
* ProviderManager就是根据该方法,判断一个Provider是否支持对应的认证模式
*
* @param authenticationToken
* @return boolean
*/
@Override
public boolean supports(Class<?> authenticationToken) {
// 判断传入的authenticationToken是否是SmsCodeAuthenticationToken类型
return SmsCodeAuthenticationToken.class.isAssignableFrom(authenticationToken);
}
}
至此,我们大致已经将自定义验证码登录认证流程所需的模块补充完整,接下来就需要将它们"嵌入"到认证链路上;也即是配置。
SmsCodeSecurityConfig.class
/**
* 短信验证码登录的配置(也可以直接在WebSecurityConfig中配置,抽离出来是为了方便后续的维护)
*
* @author 小奇
* @date 2020/10/08
**/
@Component
public class SmsCodeSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
/**
* 成功处理器
*/
@Autowired
private WebAuthenticationSuccessHandler successHandler;
/**
* 失败处理器
*/
@Autowired
private WebAuthenticationFailHandler failHandler;
@Autowired
private HrService hrService;
@Override
public void configure(HttpSecurity httpSecurity) {
// 1. 配置SmsCodeAuthenticationFilter (类比UsernamePasswordAuthenticationFilter)
// 配置AuthenticationManager、 成功处理和失败处理
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
smsCodeAuthenticationFilter.setAuthenticationManager(httpSecurity.getSharedObject(AuthenticationManager.class));
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(successHandler);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(failHandler);
// 2. 配置自定义的SmsCodeAuthenticationProvider 类比DaoAuthenticationProvider
// 配置hrService 用于读取用户信息
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider(hrService);
// 3. 将自定义的provider添加到AuthenticationManager管理的Provider集合中;
// 并且配置带UsernamePasswordAuthenticationFilter之后
httpSecurity.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
总结
本篇文章为自定义验证码登录认证的下篇,主要对认证流程进行讲解;至此自定义验证码登录认证就介绍完拉!文中主要截取了核心代码进行展示,后续会将完整的项目代码放到github上~
本文为我在之前在b站上学习某大佬课程过程中所做的学习笔记,可能会出现理解上的偏差,如有错误或不妥指出,烦请指出!
文章到这就结束拉,欢迎大家关注小奇公众号~