1、如何实现?

回顾一下上篇文章仿造Spring Security表单登录实现一个IP登录,我们仿造用户名密码登录实现了本地管理员无需账号密码直接登录的功能。在我们项目中,通常表单登录的时候都会有一个图形验证码,手机登录的时候也会发送一个几位数的验证码给用户,那么加上验证码有什么用呢?验证码是一种区分用户是计算机还是人的公共全自动程序;可以防止恶意破解密码、刷票、论坛灌水、刷页、恶意注册、登录。
🤔好了,已经知道了图形验证码存在的必要性,这个时候我们该考虑的就是该如何将这个验证码校验功能加到默认的表单登录认证流程中?
我的做法是将表单登录用的图形验证码和手机短信登录用的短信验证码的校验过程都放在一个过滤器中来实现,所以接下来的工作就是类似默认的表单登录和我们前面写过的IP登录一样看看怎么将验证码校验过滤器和手机短信登录认证过滤器加到过滤器链中。
其中,有一个关键的点就是我们在使用表单登录的时候通常是账号密码和图形验证码是一起提交的,也就是说访问后端的URL是同一个,所以校验图形验证码的过滤器拦截的URL应该和表单登录认证过滤器拦截的URL应该是同一个,那么与之类似,校验短信验证码的过滤器拦截的URL应该和手机短信登录认证过滤器拦截的URL也应该是同一个。
那么具体该怎么开始呢?首先,你需要有一个发送验证码的接口,这个接口可以根据验证码类型来生成不同的验证码返回给前端,并且将生成的验证码保存起来(内存或者redis);验证码校验过滤器需要对用户输入的验证码和刚才生成的验证码进行比对,校验不通过,则抛出异常,校验通过的话就会来到表单登录认证过滤器或者手机短信登录认证过滤器,因为表单登录认证的过滤器Spring Security已经默认实现,那么我们只要写一个手机短信登录过滤器就好了。

2、主要逻辑

验证码过滤器和手机短信登录认证过滤器。

2.1、验证码过滤器

2.1.1、生成验证码和验证码校验接口

验证码处理器接口ValidateCodeProcessor,用于生成和校验验证码,以及判断该处理器是属于哪种验证码类型三个方法。

  1. public interface ValidateCodeProcessor {
  2. /**
  3. * 创建验证码
  4. *
  5. * @param request 请求
  6. * @return JsonResult对象
  7. */
  8. JsonResult create(ServletWebRequest request);
  9. /**
  10. * 校验验证码
  11. *
  12. * @param request request
  13. */
  14. void validate(ServletWebRequest request);
  15. boolean supports(ValidateCodeType validateCodeType);
  16. }

然后写一个抽象类继承该接口,实现这三个方法,其中的生成验证码的create方法中的send方法交给子类去重写,其实这个地方就用到了模板方法模式。然后需要注意的是校验验证码的validate方法中在短信验证码校验成功后先别删除,因为在后续的手机短信登录认证过滤器中需要用到。

  1. public abstract class AbstractValidateCodeProcessor<T extends ValidateCode>
  2. implements ValidateCodeProcessor {
  3. // 用于保存生成的验证码
  4. protected final ValidateCodeRepository validateCodeRepository;
  5. // 验证码生成接口
  6. protected final ValidateCodeGenerator<T> validateCodeGenerator;
  7. // 验证码类型
  8. protected final ValidateCodeType validateCodeType;
  9. protected AbstractValidateCodeProcessor(
  10. ValidateCodeRepository validateCodeRepository,
  11. ValidateCodeGenerator<T> validateCodeGenerator,
  12. ValidateCodeType validateCodeType) {
  13. this.validateCodeRepository = validateCodeRepository;
  14. this.validateCodeGenerator = validateCodeGenerator;
  15. this.validateCodeType = validateCodeType;
  16. }
  17. @Override
  18. public JsonResult create(ServletWebRequest request) {
  19. T validateCode = validateCodeGenerator.generate(request);
  20. validateCodeRepository.save(validateCode, validateCodeType);
  21. return send(validateCode);
  22. }
  23. protected abstract JsonResult send(T validateCode);
  24. @Override
  25. public void validate(ServletWebRequest request) {
  26. ValidateCode codeInSession = validateCodeRepository.get(validateCodeType);
  27. String codeInRequest;
  28. try {
  29. codeInRequest =
  30. ServletRequestUtils.getRequiredStringParameter(
  31. request.getRequest(), validateCodeType.getParamNameOnValidate());
  32. } catch (ServletRequestBindingException e) {
  33. throw new MyAuthenticationException(ResultCode.VALIDATE_CODE_PARAM_NOT_EXIST);
  34. }
  35. if (StringUtils.isBlank(codeInRequest)) {
  36. throw new MyAuthenticationException(ResultCode.VALIDATE_CODE_IS_BLANK);
  37. }
  38. if (codeInSession == null) {
  39. throw new MyAuthenticationException(ResultCode.VALIDATE_CODE_NOT_EXIST);
  40. }
  41. if (codeInSession.isExpired()) {
  42. validateCodeRepository.remove(validateCodeType);
  43. throw new MyAuthenticationException(ResultCode.VALIDATE_CODE_EXPIRED);
  44. }
  45. if (!CharSequenceUtil.equals(codeInSession.getCode(), codeInRequest)) {
  46. throw new MyAuthenticationException(ResultCode.VALIDATE_CODE_NOT_MATCH);
  47. }
  48. if (!ValidateCodeType.SMS.equals(validateCodeType)) {
  49. validateCodeRepository.remove(validateCodeType);
  50. }
  51. }
  52. @Override
  53. public boolean supports(ValidateCodeType validateCodeType) {
  54. return this.validateCodeType.equals(validateCodeType);
  55. }
  56. }

生成的图形验证码以base64编码返回。

  1. @Slf4j
  2. @Component
  3. public class ImageCodeProcessor extends AbstractValidateCodeProcessor<ImageCode> {
  4. public ImageCodeProcessor(
  5. ValidateCodeRepository validateCodeRepository, ImageCodeGenerator imageCodeGenerator) {
  6. super(validateCodeRepository, imageCodeGenerator, ValidateCodeType.IMAGE);
  7. }
  8. @Override
  9. protected JsonResult send(ImageCode imageCode) {
  10. FastByteArrayOutputStream os = new FastByteArrayOutputStream();
  11. try {
  12. ImageIO.write(imageCode.getImage(), "png", os);
  13. } catch (IOException e) {
  14. return JsonResultUtil.error(e.getMessage());
  15. }
  16. log.info("生成的图形验证码:" + imageCode.getCode());
  17. return JsonResultUtil.success("data:image/png;base64," + Base64.encode(os.toByteArray()));
  18. }
  19. }

手机短信是需要发送到手机上的,现在我默认的实现类是在控制台中打印出来,后续有条件的话会直接调接口发到手机上。

  1. @Component
  2. public class SmsCodeProcessor extends AbstractValidateCodeProcessor<SmsCode> {
  3. private final SmsCodeSender smsCodeSender;
  4. public SmsCodeProcessor(
  5. SmsCodeGenerator smsCodeGenerator,
  6. ValidateCodeRepository validateCodeRepository,
  7. SmsCodeSender smsCodeSender) {
  8. super(validateCodeRepository, smsCodeGenerator, ValidateCodeType.SMS);
  9. this.smsCodeSender = smsCodeSender;
  10. }
  11. @Override
  12. protected JsonResult send(SmsCode smsCode) {
  13. return smsCodeSender.send(smsCode);
  14. }
  15. }

2.1.2、验证码校验过滤器

该类的主要逻辑就是从请求参数中读取出验证码类型参数,根据验证码类型调用上面的验证码处理器类去调用validate方法对请求参数中的验证码和保存起来的验证码进行比对。

  1. /** 该过滤器用于验证码校验(目前表单登录和手机登录时需要校验) */
  2. @Slf4j
  3. @EqualsAndHashCode(callSuper = true)
  4. @Data
  5. public class ValidateCodeAuthenticationProcessingFilter extends OncePerRequestFilter {
  6. private RequestMatcher requiresAuthenticationRequestMatcher;
  7. private ValidateCodeProcessorHolder validateCodeProcessorHolder;
  8. private AuthenticationFailureHandler authenticationFailureHandler;
  9. public ValidateCodeAuthenticationProcessingFilter(
  10. RequestMatcher requiresAuthenticationRequestMatcher,
  11. ValidateCodeProcessorHolder validateCodeProcessorHolder,
  12. AuthenticationFailureHandler authenticationFailureHandler) {
  13. Assert.notNull(
  14. requiresAuthenticationRequestMatcher,
  15. "requiresAuthenticationRequestMatcher cannot be null");
  16. Assert.notNull(validateCodeProcessorHolder, "validateCodeProcessorHolder cannot be null");
  17. Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null");
  18. this.requiresAuthenticationRequestMatcher = requiresAuthenticationRequestMatcher;
  19. this.validateCodeProcessorHolder = validateCodeProcessorHolder;
  20. this.authenticationFailureHandler = authenticationFailureHandler;
  21. }
  22. @Override
  23. protected void doFilterInternal(
  24. HttpServletRequest request, HttpServletResponse response, FilterChain chain)
  25. throws ServletException, IOException {
  26. if (requiresAuthentication(request)) {
  27. try {
  28. Integer validateCodeType =
  29. ServletRequestUtils.getRequiredIntParameter(
  30. request, SecurityConstant.DEFAULT_PARAMETER_NAME_VALIDATE_CODE_TYPE);
  31. validateCodeProcessorHolder
  32. .findValidateCodeProcessorByType(validateCodeType)
  33. .validate(new ServletWebRequest(request, response));
  34. log.info("验证码校验通过");
  35. } catch (ServletRequestBindingException e) {
  36. authenticationFailureHandler.onAuthenticationFailure(
  37. request,
  38. response,
  39. new MyAuthenticationException(ResultCode.VALIDATE_CODE_TYPE_PARAM_NOT_EXIST));
  40. return;
  41. } catch (MyAuthenticationException e) {
  42. authenticationFailureHandler.onAuthenticationFailure(request, response, e);
  43. return;
  44. }
  45. }
  46. chain.doFilter(request, response);
  47. }
  48. private boolean requiresAuthentication(HttpServletRequest request) {
  49. return this.requiresAuthenticationRequestMatcher.matches(request);
  50. }
  51. }

短信验证码校验过滤器继承自ValidateCodeAuthenticationProcessingFilter过滤器,定义了拦截地址URL为DEFAULT_SIGN_IN_PROCESSING_URL_MOBILE常量,这个拦截地址URL应该和接下来的手机短信登录过滤器拦截的URL一样。

  1. public class SmsCodeAuthenticationFilter extends ValidateCodeAuthenticationProcessingFilter {
  2. private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER =
  3. new AntPathRequestMatcher(SecurityConstant.DEFAULT_SIGN_IN_PROCESSING_URL_MOBILE, "POST");
  4. public SmsCodeAuthenticationFilter(
  5. ValidateCodeProcessorHolder validateCodeProcessorHolder,
  6. AuthenticationFailureHandler authenticationFailureHandler) {
  7. super(
  8. DEFAULT_ANT_PATH_REQUEST_MATCHER,
  9. validateCodeProcessorHolder,
  10. authenticationFailureHandler);
  11. }
  12. }

图形验证码校验过滤器也继承自ValidateCodeAuthenticationProcessingFilter过滤器,定义了拦截地址URL为DEFAULT_SIGN_IN_PROCESSING_URL_FORM常量,这个拦截地址URL应该和表单登录认证过滤器拦截的URL一样。

  1. public class ImageCodeAuthenticationFilter extends ValidateCodeAuthenticationProcessingFilter {
  2. private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER =
  3. new AntPathRequestMatcher(SecurityConstant.DEFAULT_SIGN_IN_PROCESSING_URL_FORM, "POST");
  4. public ImageCodeAuthenticationFilter(
  5. ValidateCodeProcessorHolder validateCodeProcessorHolder,
  6. AuthenticationFailureHandler authenticationFailureHandler) {
  7. super(
  8. DEFAULT_ANT_PATH_REQUEST_MATCHER,
  9. validateCodeProcessorHolder,
  10. authenticationFailureHandler);
  11. }
  12. }

默认的表单登录认证过滤器拦截的URL是/loginPOST请求,所以我们得更改它的拦截地址为DEFAULT_SIGN_IN_PROCESSING_URL_FORM

  1. public class SecurityConfig extends WebSecurityConfigurerAdapter {
  2. @Override
  3. protected void configure(HttpSecurity http) throws Exception {
  4. http
  5. ...
  6. .formLogin()
  7. .loginProcessingUrl(SecurityConstant.DEFAULT_SIGN_IN_PROCESSING_URL_FORM)
  8. ...
  9. }
  10. }

2.1.3、将验证码过滤器添加到过滤器链

  1. @Component
  2. @RequiredArgsConstructor
  3. public class ValidateCodeConfigurer
  4. extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
  5. private final ValidateCodeProcessorHolder validateCodeProcessorHolder;
  6. private final MyAuthenticationFailureHandler authenticationFailureHandler;
  7. @Override
  8. public void configure(HttpSecurity http) {
  9. ImageCodeAuthenticationFilter imageCodeAuthenticationFilter =
  10. new ImageCodeAuthenticationFilter(
  11. validateCodeProcessorHolder, authenticationFailureHandler);
  12. SmsCodeAuthenticationFilter smsCodeAuthenticationFilter =
  13. new SmsCodeAuthenticationFilter(validateCodeProcessorHolder, authenticationFailureHandler);
  14. http.addFilterBefore(imageCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
  15. http.addFilterBefore(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
  16. }
  17. }

然后在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中的providersAuthenticatonToken进行校验。
  • xxxConfigurer:继承自SecurityConfigurerAdapter_<_DefaultSecurityFilterChain, HttpSecurity_>_,重写其中的init方法和configure方法,init方法主要用于将xxxAuthenticationProvider添加到providerManagerproviders集合中,configure方法主要用于将配置好的xxxAuthenticationFilter添加到过滤器链中。

    2.2.1、SmsCodeAuthenticationToken

    1. @EqualsAndHashCode(callSuper = true)
    2. public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
    3. private final String mobile;
    4. public SmsCodeAuthenticationToken(String mobile) {
    5. super(null);
    6. this.mobile = mobile;
    7. this.setAuthenticated(false);
    8. }
    9. public SmsCodeAuthenticationToken(
    10. String mobile, Collection<? extends GrantedAuthority> authorities) {
    11. super(authorities);
    12. this.mobile = mobile;
    13. super.setAuthenticated(true);
    14. }
    15. @Override
    16. public Object getCredentials() {
    17. return null;
    18. }
    19. @Override
    20. public Object getPrincipal() {
    21. return mobile;
    22. }
    23. }

    2.2.2、SmsCodeAuthenticationProvider

    1. public class SmsCodeAuthenticationProvider implements AuthenticationProvider, MessageSourceAware {
    2. private final SysUserService sysUserService;
    3. private final ValidateCodeRepository validateCodeRepository;
    4. protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
    5. public SmsCodeAuthenticationProvider(
    6. SysUserService sysUserService, ValidateCodeRepository validateCodeRepository) {
    7. this.sysUserService = sysUserService;
    8. this.validateCodeRepository = validateCodeRepository;
    9. }
    10. @Override
    11. public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    12. Assert.isInstanceOf(
    13. SmsCodeAuthenticationToken.class,
    14. authentication,
    15. () ->
    16. this.messages.getMessage(
    17. "SmsCodeAuthenticationProvider.onlySupports",
    18. "Only SmsCodeAuthenticationToken is supported"));
    19. SmsCodeAuthenticationToken smsCodeAuthenticationToken =
    20. (SmsCodeAuthenticationToken) authentication;
    21. String mobile = (String) smsCodeAuthenticationToken.getPrincipal();
    22. SmsCode smsCode = (SmsCode) validateCodeRepository.get(ValidateCodeType.SMS);
    23. if (ObjectUtil.isEmpty(smsCode) || !smsCode.getMobile().equals(mobile)) {
    24. throw new MyAuthenticationException(ResultCode.MOBILE_PARAM_NOT_MATCH);
    25. }
    26. UserDetails userDetails = sysUserService.loadUserByPhone(mobile);
    27. validateCodeRepository.remove(ValidateCodeType.SMS);
    28. SmsCodeAuthenticationToken authenticationResult =
    29. new SmsCodeAuthenticationToken(mobile, userDetails.getAuthorities());
    30. authenticationResult.setDetails(smsCodeAuthenticationToken.getDetails());
    31. return authenticationResult;
    32. }
    33. @Override
    34. public boolean supports(Class<?> authentication) {
    35. return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
    36. }
    37. @Override
    38. public void setMessageSource(MessageSource messageSource) {
    39. this.messages = new MessageSourceAccessor(messageSource);
    40. }
    41. }

    2.2.3、SmsCodeLoginFilter

    1. public class SmsCodeLoginFilter extends AbstractAuthenticationProcessingFilter {
    2. private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER =
    3. new AntPathRequestMatcher(SecurityConstant.DEFAULT_SIGN_IN_PROCESSING_URL_MOBILE, "POST");
    4. protected SmsCodeLoginFilter() {
    5. super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
    6. }
    7. @Override
    8. public Authentication attemptAuthentication(
    9. HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    10. if (!request.getMethod().equals("POST")) {
    11. throw new AuthenticationServiceException(
    12. "Authentication method not supported: " + request.getMethod());
    13. } else {
    14. String mobile = obtainMobile(request);
    15. mobile = (mobile != null) ? mobile : "";
    16. mobile = mobile.trim();
    17. SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);
    18. setDetails(request, authRequest);
    19. return this.getAuthenticationManager().authenticate(authRequest);
    20. }
    21. }
    22. protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
    23. authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    24. }
    25. @Nullable
    26. protected String obtainMobile(HttpServletRequest request) {
    27. return request.getParameter(SecurityConstant.DEFAULT_PARAMETER_NAME_MOBILE);
    28. }
    29. }

    2.2.4、SmsCodeLoginConfigurer

    1. @Component
    2. @RequiredArgsConstructor
    3. public class SmsCodeLoginConfigurer
    4. extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    5. private final MyAuthenticationSuccessHandler authenticationSuccessHandler;
    6. private final MyAuthenticationFailureHandler authenticationFailureHandler;
    7. private final SysUserService sysUserService;
    8. private final ValidateCodeRepository validateCodeRepository;
    9. @Override
    10. public void init(HttpSecurity http) {
    11. http.authenticationProvider(
    12. postProcess(new SmsCodeAuthenticationProvider(sysUserService, validateCodeRepository)));
    13. }
    14. @Override
    15. public void configure(HttpSecurity http) {
    16. SmsCodeLoginFilter smsCodeLoginFilter = new SmsCodeLoginFilter();
    17. smsCodeLoginFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
    18. smsCodeLoginFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
    19. smsCodeLoginFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
    20. http.addFilterAfter(smsCodeLoginFilter, UsernamePasswordAuthenticationFilter.class);
    21. }
    22. }

    然后在SecurityConfig中使用http.apply(SmsCodeLoginConfigurer)即可。
    逻辑大概就只有上面这些,具体的实现可以参考项目,其实主要搞懂了源码,知道Spring Security怎么运作的,那这些东西对你来说就没什么难度。
    talk is cheap,show me the code.