1、如何实现?
回顾一下上篇文章仿造Spring Security表单登录实现一个IP登录,我们仿造用户名密码登录实现了本地管理员无需账号密码直接登录的功能。在我们项目中,通常表单登录的时候都会有一个图形验证码,手机登录的时候也会发送一个几位数的验证码给用户,那么加上验证码有什么用呢?验证码是一种区分用户是计算机还是人的公共全自动程序;可以防止恶意破解密码、刷票、论坛灌水、刷页、恶意注册、登录。
🤔好了,已经知道了图形验证码存在的必要性,这个时候我们该考虑的就是该如何将这个验证码校验功能加到默认的表单登录认证流程中?
我的做法是将表单登录用的图形验证码和手机短信登录用的短信验证码的校验过程都放在一个过滤器中来实现,所以接下来的工作就是类似默认的表单登录和我们前面写过的IP登录一样看看怎么将验证码校验过滤器和手机短信登录认证过滤器加到过滤器链中。
其中,有一个关键的点就是我们在使用表单登录的时候通常是账号密码和图形验证码是一起提交的,也就是说访问后端的URL是同一个,所以校验图形验证码的过滤器拦截的URL应该和表单登录认证过滤器拦截的URL应该是同一个,那么与之类似,校验短信验证码的过滤器拦截的URL应该和手机短信登录认证过滤器拦截的URL也应该是同一个。
那么具体该怎么开始呢?首先,你需要有一个发送验证码的接口,这个接口可以根据验证码类型来生成不同的验证码返回给前端,并且将生成的验证码保存起来(内存或者redis);验证码校验过滤器需要对用户输入的验证码和刚才生成的验证码进行比对,校验不通过,则抛出异常,校验通过的话就会来到表单登录认证过滤器或者手机短信登录认证过滤器,因为表单登录认证的过滤器Spring Security已经默认实现,那么我们只要写一个手机短信登录过滤器就好了。
2、主要逻辑
2.1、验证码过滤器
2.1.1、生成验证码和验证码校验接口
验证码处理器接口ValidateCodeProcessor,用于生成和校验验证码,以及判断该处理器是属于哪种验证码类型三个方法。
public interface ValidateCodeProcessor {/*** 创建验证码** @param request 请求* @return JsonResult对象*/JsonResult create(ServletWebRequest request);/*** 校验验证码** @param request request*/void validate(ServletWebRequest request);boolean supports(ValidateCodeType validateCodeType);}
然后写一个抽象类继承该接口,实现这三个方法,其中的生成验证码的create方法中的send方法交给子类去重写,其实这个地方就用到了模板方法模式。然后需要注意的是校验验证码的validate方法中在短信验证码校验成功后先别删除,因为在后续的手机短信登录认证过滤器中需要用到。
public abstract class AbstractValidateCodeProcessor<T extends ValidateCode>implements ValidateCodeProcessor {// 用于保存生成的验证码protected final ValidateCodeRepository validateCodeRepository;// 验证码生成接口protected final ValidateCodeGenerator<T> validateCodeGenerator;// 验证码类型protected final ValidateCodeType validateCodeType;protected AbstractValidateCodeProcessor(ValidateCodeRepository validateCodeRepository,ValidateCodeGenerator<T> validateCodeGenerator,ValidateCodeType validateCodeType) {this.validateCodeRepository = validateCodeRepository;this.validateCodeGenerator = validateCodeGenerator;this.validateCodeType = validateCodeType;}@Overridepublic JsonResult create(ServletWebRequest request) {T validateCode = validateCodeGenerator.generate(request);validateCodeRepository.save(validateCode, validateCodeType);return send(validateCode);}protected abstract JsonResult send(T validateCode);@Overridepublic void validate(ServletWebRequest request) {ValidateCode codeInSession = validateCodeRepository.get(validateCodeType);String codeInRequest;try {codeInRequest =ServletRequestUtils.getRequiredStringParameter(request.getRequest(), validateCodeType.getParamNameOnValidate());} catch (ServletRequestBindingException e) {throw new MyAuthenticationException(ResultCode.VALIDATE_CODE_PARAM_NOT_EXIST);}if (StringUtils.isBlank(codeInRequest)) {throw new MyAuthenticationException(ResultCode.VALIDATE_CODE_IS_BLANK);}if (codeInSession == null) {throw new MyAuthenticationException(ResultCode.VALIDATE_CODE_NOT_EXIST);}if (codeInSession.isExpired()) {validateCodeRepository.remove(validateCodeType);throw new MyAuthenticationException(ResultCode.VALIDATE_CODE_EXPIRED);}if (!CharSequenceUtil.equals(codeInSession.getCode(), codeInRequest)) {throw new MyAuthenticationException(ResultCode.VALIDATE_CODE_NOT_MATCH);}if (!ValidateCodeType.SMS.equals(validateCodeType)) {validateCodeRepository.remove(validateCodeType);}}@Overridepublic boolean supports(ValidateCodeType validateCodeType) {return this.validateCodeType.equals(validateCodeType);}}
生成的图形验证码以base64编码返回。
@Slf4j@Componentpublic class ImageCodeProcessor extends AbstractValidateCodeProcessor<ImageCode> {public ImageCodeProcessor(ValidateCodeRepository validateCodeRepository, ImageCodeGenerator imageCodeGenerator) {super(validateCodeRepository, imageCodeGenerator, ValidateCodeType.IMAGE);}@Overrideprotected JsonResult send(ImageCode imageCode) {FastByteArrayOutputStream os = new FastByteArrayOutputStream();try {ImageIO.write(imageCode.getImage(), "png", os);} catch (IOException e) {return JsonResultUtil.error(e.getMessage());}log.info("生成的图形验证码:" + imageCode.getCode());return JsonResultUtil.success("data:image/png;base64," + Base64.encode(os.toByteArray()));}}
手机短信是需要发送到手机上的,现在我默认的实现类是在控制台中打印出来,后续有条件的话会直接调接口发到手机上。
@Componentpublic class SmsCodeProcessor extends AbstractValidateCodeProcessor<SmsCode> {private final SmsCodeSender smsCodeSender;public SmsCodeProcessor(SmsCodeGenerator smsCodeGenerator,ValidateCodeRepository validateCodeRepository,SmsCodeSender smsCodeSender) {super(validateCodeRepository, smsCodeGenerator, ValidateCodeType.SMS);this.smsCodeSender = smsCodeSender;}@Overrideprotected JsonResult send(SmsCode smsCode) {return smsCodeSender.send(smsCode);}}
2.1.2、验证码校验过滤器
该类的主要逻辑就是从请求参数中读取出验证码类型参数,根据验证码类型调用上面的验证码处理器类去调用validate方法对请求参数中的验证码和保存起来的验证码进行比对。
/** 该过滤器用于验证码校验(目前表单登录和手机登录时需要校验) */@Slf4j@EqualsAndHashCode(callSuper = true)@Datapublic class ValidateCodeAuthenticationProcessingFilter extends OncePerRequestFilter {private RequestMatcher requiresAuthenticationRequestMatcher;private ValidateCodeProcessorHolder validateCodeProcessorHolder;private AuthenticationFailureHandler authenticationFailureHandler;public ValidateCodeAuthenticationProcessingFilter(RequestMatcher requiresAuthenticationRequestMatcher,ValidateCodeProcessorHolder validateCodeProcessorHolder,AuthenticationFailureHandler authenticationFailureHandler) {Assert.notNull(requiresAuthenticationRequestMatcher,"requiresAuthenticationRequestMatcher cannot be null");Assert.notNull(validateCodeProcessorHolder, "validateCodeProcessorHolder cannot be null");Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null");this.requiresAuthenticationRequestMatcher = requiresAuthenticationRequestMatcher;this.validateCodeProcessorHolder = validateCodeProcessorHolder;this.authenticationFailureHandler = authenticationFailureHandler;}@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws ServletException, IOException {if (requiresAuthentication(request)) {try {Integer validateCodeType =ServletRequestUtils.getRequiredIntParameter(request, SecurityConstant.DEFAULT_PARAMETER_NAME_VALIDATE_CODE_TYPE);validateCodeProcessorHolder.findValidateCodeProcessorByType(validateCodeType).validate(new ServletWebRequest(request, response));log.info("验证码校验通过");} catch (ServletRequestBindingException e) {authenticationFailureHandler.onAuthenticationFailure(request,response,new MyAuthenticationException(ResultCode.VALIDATE_CODE_TYPE_PARAM_NOT_EXIST));return;} catch (MyAuthenticationException e) {authenticationFailureHandler.onAuthenticationFailure(request, response, e);return;}}chain.doFilter(request, response);}private boolean requiresAuthentication(HttpServletRequest request) {return this.requiresAuthenticationRequestMatcher.matches(request);}}
短信验证码校验过滤器继承自ValidateCodeAuthenticationProcessingFilter过滤器,定义了拦截地址URL为DEFAULT_SIGN_IN_PROCESSING_URL_MOBILE常量,这个拦截地址URL应该和接下来的手机短信登录过滤器拦截的URL一样。
public class SmsCodeAuthenticationFilter extends ValidateCodeAuthenticationProcessingFilter {private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER =new AntPathRequestMatcher(SecurityConstant.DEFAULT_SIGN_IN_PROCESSING_URL_MOBILE, "POST");public SmsCodeAuthenticationFilter(ValidateCodeProcessorHolder validateCodeProcessorHolder,AuthenticationFailureHandler authenticationFailureHandler) {super(DEFAULT_ANT_PATH_REQUEST_MATCHER,validateCodeProcessorHolder,authenticationFailureHandler);}}
图形验证码校验过滤器也继承自ValidateCodeAuthenticationProcessingFilter过滤器,定义了拦截地址URL为DEFAULT_SIGN_IN_PROCESSING_URL_FORM常量,这个拦截地址URL应该和表单登录认证过滤器拦截的URL一样。
public class ImageCodeAuthenticationFilter extends ValidateCodeAuthenticationProcessingFilter {private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER =new AntPathRequestMatcher(SecurityConstant.DEFAULT_SIGN_IN_PROCESSING_URL_FORM, "POST");public ImageCodeAuthenticationFilter(ValidateCodeProcessorHolder validateCodeProcessorHolder,AuthenticationFailureHandler authenticationFailureHandler) {super(DEFAULT_ANT_PATH_REQUEST_MATCHER,validateCodeProcessorHolder,authenticationFailureHandler);}}
默认的表单登录认证过滤器拦截的URL是/login的POST请求,所以我们得更改它的拦截地址为DEFAULT_SIGN_IN_PROCESSING_URL_FORM。
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Overrideprotected void configure(HttpSecurity http) throws Exception {http....formLogin().loginProcessingUrl(SecurityConstant.DEFAULT_SIGN_IN_PROCESSING_URL_FORM)...}}
2.1.3、将验证码过滤器添加到过滤器链
@Component@RequiredArgsConstructorpublic class ValidateCodeConfigurerextends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {private final ValidateCodeProcessorHolder validateCodeProcessorHolder;private final MyAuthenticationFailureHandler authenticationFailureHandler;@Overridepublic void configure(HttpSecurity http) {ImageCodeAuthenticationFilter imageCodeAuthenticationFilter =new ImageCodeAuthenticationFilter(validateCodeProcessorHolder, authenticationFailureHandler);SmsCodeAuthenticationFilter smsCodeAuthenticationFilter =new SmsCodeAuthenticationFilter(validateCodeProcessorHolder, authenticationFailureHandler);http.addFilterBefore(imageCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);http.addFilterBefore(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);}}
然后在SecurityConfig中使用http.apply(validateConfigurer)即可。
2.2、手机短信登录认证过滤器
相信大家通过IP登录功能,其实对扩展Spring Security认证登录有了一定的了解,其实,Spring Security 就是通过过滤器链来实现的,所以我们只要把我们的过滤器加到这个过滤器链中就可以了。看看Spring Security默认的表单登录是怎么将UsernamePasswordAuthenticationFilter加到过滤器链中的,不难发现,其中对扩展认证登录流程有4个最重要的东西:
- xxxAuthenticationToken:继承自
AbstractAuthenticationToken,里面封装了登录用户信息。 - xxxAuthenticationProvider:实现
AuthenticationProvider接口,对特定的AuthenticationToken进行校验,校验不通过则抛出异常,校验通过返回一个认证成功的AuthenticationToken即可。 - xxxAuthenticationFilter:继承自
AbstractAuthenticationProcessingFilter类,重写其中的attemptAuthentication方法,调用AuthenticationManager中的providers对AuthenticatonToken进行校验。 xxxConfigurer:继承自
SecurityConfigurerAdapter_<_DefaultSecurityFilterChain, HttpSecurity_>_,重写其中的init方法和configure方法,init方法主要用于将xxxAuthenticationProvider添加到providerManager的providers集合中,configure方法主要用于将配置好的xxxAuthenticationFilter添加到过滤器链中。2.2.1、SmsCodeAuthenticationToken
@EqualsAndHashCode(callSuper = true)public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {private final String mobile;public SmsCodeAuthenticationToken(String mobile) {super(null);this.mobile = mobile;this.setAuthenticated(false);}public SmsCodeAuthenticationToken(String mobile, Collection<? extends GrantedAuthority> authorities) {super(authorities);this.mobile = mobile;super.setAuthenticated(true);}@Overridepublic Object getCredentials() {return null;}@Overridepublic Object getPrincipal() {return mobile;}}
2.2.2、SmsCodeAuthenticationProvider
public class SmsCodeAuthenticationProvider implements AuthenticationProvider, MessageSourceAware {private final SysUserService sysUserService;private final ValidateCodeRepository validateCodeRepository;protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();public SmsCodeAuthenticationProvider(SysUserService sysUserService, ValidateCodeRepository validateCodeRepository) {this.sysUserService = sysUserService;this.validateCodeRepository = validateCodeRepository;}@Overridepublic Authentication authenticate(Authentication authentication) throws AuthenticationException {Assert.isInstanceOf(SmsCodeAuthenticationToken.class,authentication,() ->this.messages.getMessage("SmsCodeAuthenticationProvider.onlySupports","Only SmsCodeAuthenticationToken is supported"));SmsCodeAuthenticationToken smsCodeAuthenticationToken =(SmsCodeAuthenticationToken) authentication;String mobile = (String) smsCodeAuthenticationToken.getPrincipal();SmsCode smsCode = (SmsCode) validateCodeRepository.get(ValidateCodeType.SMS);if (ObjectUtil.isEmpty(smsCode) || !smsCode.getMobile().equals(mobile)) {throw new MyAuthenticationException(ResultCode.MOBILE_PARAM_NOT_MATCH);}UserDetails userDetails = sysUserService.loadUserByPhone(mobile);validateCodeRepository.remove(ValidateCodeType.SMS);SmsCodeAuthenticationToken authenticationResult =new SmsCodeAuthenticationToken(mobile, userDetails.getAuthorities());authenticationResult.setDetails(smsCodeAuthenticationToken.getDetails());return authenticationResult;}@Overridepublic boolean supports(Class<?> authentication) {return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);}@Overridepublic void setMessageSource(MessageSource messageSource) {this.messages = new MessageSourceAccessor(messageSource);}}
2.2.3、SmsCodeLoginFilter
public class SmsCodeLoginFilter extends AbstractAuthenticationProcessingFilter {private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER =new AntPathRequestMatcher(SecurityConstant.DEFAULT_SIGN_IN_PROCESSING_URL_MOBILE, "POST");protected SmsCodeLoginFilter() {super(DEFAULT_ANT_PATH_REQUEST_MATCHER);}@Overridepublic Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {if (!request.getMethod().equals("POST")) {throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());} else {String mobile = obtainMobile(request);mobile = (mobile != null) ? mobile : "";mobile = mobile.trim();SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);setDetails(request, authRequest);return this.getAuthenticationManager().authenticate(authRequest);}}protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {authRequest.setDetails(authenticationDetailsSource.buildDetails(request));}@Nullableprotected String obtainMobile(HttpServletRequest request) {return request.getParameter(SecurityConstant.DEFAULT_PARAMETER_NAME_MOBILE);}}
2.2.4、SmsCodeLoginConfigurer
@Component@RequiredArgsConstructorpublic class SmsCodeLoginConfigurerextends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {private final MyAuthenticationSuccessHandler authenticationSuccessHandler;private final MyAuthenticationFailureHandler authenticationFailureHandler;private final SysUserService sysUserService;private final ValidateCodeRepository validateCodeRepository;@Overridepublic void init(HttpSecurity http) {http.authenticationProvider(postProcess(new SmsCodeAuthenticationProvider(sysUserService, validateCodeRepository)));}@Overridepublic void configure(HttpSecurity http) {SmsCodeLoginFilter smsCodeLoginFilter = new SmsCodeLoginFilter();smsCodeLoginFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));smsCodeLoginFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);smsCodeLoginFilter.setAuthenticationFailureHandler(authenticationFailureHandler);http.addFilterAfter(smsCodeLoginFilter, UsernamePasswordAuthenticationFilter.class);}}
然后在
SecurityConfig中使用http.apply(SmsCodeLoginConfigurer)即可。
逻辑大概就只有上面这些,具体的实现可以参考项目,其实主要搞懂了源码,知道Spring Security怎么运作的,那这些东西对你来说就没什么难度。
talk is cheap,show me the code.
