万字长文 详解Spring Security 验证码的生成

it2022-12-27  74

本文思维导图

图1-1 验证码生成 概图

概述

总所周知,验证码方式的登录模式十分的普遍,不过 Spring Security 并没有提供比较好的原生解决方案,但是我们可以 do it by ourselves!,本文的篇幅相对比较长,因此分上下篇分别来介绍。上篇主要介绍:验证码的生成,下篇对自定义验证码登录的流程进行讲解。

我们比较常见的验证码主要有两种:图形验证码以及短信验证码,相对来说不是特别的复杂。可能会有人有疑惑:为什么简单的验证码生成需要花费一整篇幅来介绍呢?原因当然是:身为菜鸟的我也有一个架构师的梦!验证码的生成会结合模板方法模式一起讲解。

初探模板方法模式

模板方法模式属于一种行为型的设计模式,主要是用来解决复用和扩展两个问题。

模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到某些子类中实现。该模式可以让子类在不改变算法整体结构的情况,重新定义算法中的某些步骤细节。

这里提到了一个算法骨架的概念,算法 并非是指数据结构中的“算法”,可以理解为广义上的业务逻辑;骨架 架子其实就是模板;总的来说:算法骨架 可以理解为包含广义业务逻辑的模板方法。

实践出真知

绝大部分的设计模式的原理都十分的简单,难得是将原理落实到实践中,解决实际问题。

我们知道模板方法模式主要是用来解决 复用 和 扩展 这两个问题,结合到实际情况中来分析;验证码生成有哪些地方需要 复用 和 扩展 呢?

让我们来梳理一下验证码登录模式的流程,无论是短信验证码还是图形验证码,大致上都有如下步骤:生成验证码、存储、发送、校验;既然流程上相同,那么就能做到复用。而扩展 并非是指代码的扩展性,而是指框架上的扩展性,模板方法模式可以让使用者在不修改骨架源码的情况下,定制化扩展功能。

废话不多说,接下来就来瞅瞅模板方法模式在验证码生成模块的落地情况吧!还是老规矩,先上图:

图1-2 验证码关系概览图

验证码的生成主要分3个模块:骨架模块、验证码生命周期模块、具体验证码模块(短信验证码和图形验证码)

骨架模块主要包含 ValidateCodeProcessor 接口以及AbstractValidateCodeProcessor抽象类;封装了验证码相关的可复用的业务逻辑。

验证码生命周期模块是指:验证码的生成、存储、发送。

具体验证码模块涉及短信验证码和图形验证码,基于骨架重新定义自己的相关实现。

验证码骨架

无论是图形验证码还是短信验证码,验证码的相关业务逻辑(算法骨架)都是大同小异的;主要是验证码的 创建流程 和 验证流程。因此使用模板方法模式,对可复用的业务逻辑进行抽离,封装成一个骨架。

ValidateCodeProcessor.class

/**  * 校验码处理器 封装不同验证码的处理逻辑  */ public interface ValidateCodeProcessor {     /**      * 创建验证码      * 1.生成验证码  2.存储  3.发送      *      * @param res http请求的request和response封装      * @throws Exception      */     void create(ServletWebRequest res) throws Exception;     /**      * 校验验证码      *      * @param res      */     void validate(ServletWebRequest res); }

ValidateCodeProcessor 接口定义了2个方法:create() 方法,用于验证码的生成, validate() 方法用于验证码的校验。

AbstractValidateCodeProcessor.class

/**  * 抽象方法模式——算法骨架  * 对验证码的一些公有的业务逻辑进行抽离,做到复用  **/ @Slf4j public abstract class AbstractValidateCodeProcessor<C extends ValidateCode> implements ValidateCodeProcessor {     /**      * 收集系统中所有的 {@link ValidateCodeGenerator} 接口的实现。      */     @Autowired     private Map<String, ValidateCodeGenerator> validateCodeGeneratorMap;     /**      * 验证码的存储介质      */     @Autowired     private ValidateCodeRepository validateCodeRepository;     private static final String SMS = "sms", IMAGE = "image";     @Override     public void create(ServletWebRequest res) throws Exception {         // 生成         C validateCode = generate(res);         // 存储         save(res, validateCode);         // 发送 (抽象方法 由具体的子类实现各自的发送逻辑)         send(res, validateCode);     }     @Override     public void validate(ServletWebRequest res) {         // 根据请求获取验证码的类型,并且从repository存储层中寻找匹配的验证码         ValidateCodeEnum codeEnum = getValidateCodeType(res);         Optional<ValidateCode> codeOpt = validateCodeRepository.get(res, codeEnum);         ValidateCode valCodeInStorage = codeOpt.orElseThrow(() -> new ValidateCodeException("验证码不存在"));         // 从请求中获取验证码         String codeInRequest;         try {             codeInRequest = ServletRequestUtils.getStringParameter(res.getRequest(),                     codeEnum.getType());         } catch (ServletRequestBindingException e) {             throw new ValidateCodeException("获取请求验证码的值失败");         }         if (StringUtils.isBlank(codeInRequest)) {             throw new ValidateCodeException(codeEnum + "请求验证码的值不能为空");         }         // 对短信验证码做一个是否过期的判断         if (ValidateCodeEnum.SMS.equals(codeEnum) && valCodeInStorage.checkExpired()) {             validateCodeRepository.remove(res, codeEnum);             throw new ValidateCodeException(codeEnum + "验证码已过期");         }         // 验证码校验         if (!StringUtils.equals(valCodeInStorage.getCode(), codeInRequest)) {             throw new ValidateCodeException(codeEnum + "验证码不匹配");         }         log.info("验证码校验成功");         validateCodeRepository.remove(res, codeEnum);     }     /**      * 生成验证码      *      * @param res      * @return C 验证码泛型      */     @SuppressWarnings("unchecked")     private C generate(ServletWebRequest res) {         // 根据传入的res来做类型判断         String type = getValidateCodeType(res).getType();         // 获取具体的Generator的名字         String generatorName = type.concat(ValidateCodeGenerator.class.getSimpleName());         ValidateCodeGenerator codeGenerator = Optional.ofNullable(validateCodeGeneratorMap.get(generatorName))                 .orElseThrow(() -> new ValidateCodeException("验证码生成器:" + generatorName + "不存在"));         return (C) codeGenerator.generate(res);     }     /**      * 存储短信验证码      * 可复用---抽象类中定义      *      * @param res      * @param validateCode      */     private void save(ServletWebRequest res, C validateCode) {         ValidateCode code = new ValidateCode(validateCode.getCode(), validateCode.getExpireTime());         validateCodeRepository.save(res, code, getValidateCodeType(res));     }     /**      * 验证码的发送      * 图形验证码和短线验证码的发送逻辑不一样,因此设计为抽象方法,由具体的子类实现各自的发送逻辑      *      * @param res      * @param validateCode      * @throws ServletRequestBindingException      * @throws IOException      */     protected abstract void send(ServletWebRequest res, C validateCode) throws ServletRequestBindingException, IOException;     /**      * 根据请求的url获取校验码的类型      *      * @param res      * @return ValidateCodeType      */     private ValidateCodeEnum getValidateCodeType(ServletWebRequest res) {         String uri = res.getRequest().getRequestURI();         if (StringUtils.contains(uri, SMS)) {             return ValidateCodeEnum.SMS;         }         return ValidateCodeEnum.IMAGE;     } }

AbstractValidateCodeProcessor 抽象类实现 ValidateCodeProcessor 接口,主要功能是对验证码相关的共有逻辑进行一个抽离,达到功能的复用。

create流程可以细分为以下几个步骤:生成、存储、发送。

validate是做验证码的校验,无论是图形验证码or短信验证码;验证的逻辑是一致的。

类中有2个成员变量:

private Map<String, ValidateCodeGenerator> validateCodeGeneratorMap 验证码生成器,不同的验证码生成逻辑不同,因此生成模块抽离出去由外部实现。需要提到的是:这里使用到Spring 的 定向查找 技巧进行注入,Spring 启动时,会查找容器中所有 ValidateCodeGenerator接口的实现,并把Bean的名字作为 Key,实体作为Value放到 Map中。

private ValidateCodeRepository validateCodeRepository 验证码存储层,生成的验证码code值需要存储到某个存储介质中,用以后续校验的时候取得(我这里使用的是Redis作为存储介质)。

验证码生命周期

验证码的生命周期可简单的划分为:生成、存储、发送。

生成验证码

生成和发送模块也相对简单,就是定义了验证码的具体生成器以及发送器。

ValidateCodeGenerator.class

/**  * 验证码生成器  */ public interface ValidateCodeGenerator {     /**      * 生成验证码      *      * @param res http请求中的request和response      * @return ValidateCode      */     ValidateCode generate(ServletWebRequest res); }

ValidateCodeGenerator接口定义了验证码生成方法generate(),具体的生成逻辑由对应的子类SmsValidatecodeGenerator和ImageValidateCodeGenerator实现。

SmsValidateCodeGenerator.class

/**  * 短信验证码生成器  **/ public class SmsValidateCodeGenerator implements ValidateCodeGenerator {     private final SecurityProperties securityProperties;     public SmsValidateCodeGenerator(SecurityProperties securityProperties) {         this.securityProperties = securityProperties;     }     /**      * 短信验证码生成逻辑      *      * @param res      * @return ValidateCode      */     @Override     public ValidateCode generate(ServletWebRequest res) {         //随机生成指定长度的短信验证码         String code = RandomStringUtils.randomNumeric(securityProperties.getCode().getSms().getLength());         return new ValidateCode(code, securityProperties.getCode().getSms().getExpireIn());     } }

短信验证码的生成逻辑,代码相对简单,当然可以定义自己的生成逻辑,反正就是随你开心就行拉!

ImageValidateCodeGenerator.class

/**  * 图形验证码生成器  **/ public class ImageValidateCodeGenerator implements ValidateCodeGenerator {     private final SecurityProperties securityProperties;     public ImageValidateCodeGenerator(SecurityProperties securityProperties) {         this.securityProperties = securityProperties;     }     /**      * 图形验证码生成逻辑      * todo 这里的生成逻辑可稍微优化一下成utils      *      * @param res      * @return ValidateCode      */     @Override     public ValidateCode generate(ServletWebRequest res) {         // 这里是实现了验证码参数的三级可配:请求级>应用级>默认配置 从请求中获取width 如果没有则从 securityProperties的配置中获取         int width = ServletRequestUtils.getIntParameter(res.getRequest(), "width",                 securityProperties.getCode().getImage().getWidth());         int height = ServletRequestUtils.getIntParameter(res.getRequest(), "height",                 securityProperties.getCode().getImage().getHeight());         BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);         Graphics g = image.getGraphics();         Random random = new Random();         g.setColor(getRandColor(200, 250));         g.fillRect(0, 0, width, height);         g.setFont(new Font("Times New Roman", Font.ITALIC, 20));         g.setColor(getRandColor(160, 200));         for (int i = 0; i < 155; i++) {             int x = random.nextInt(width);             int y = random.nextInt(height);             int xl = random.nextInt(12);             int yl = random.nextInt(12);             g.drawLine(x, y, x + xl, y + yl);         }         String sRand = "";         for (int i = 0; i < securityProperties.getCode().getImage().getLength(); i++) {             String rand = String.valueOf(random.nextInt(10));             sRand += rand;             g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));             g.drawString(rand, 13 * i + 6, 16);         }         g.dispose();         return new ImageValidateCode(image, sRand, securityProperties.getCode().getImage().getExpireIn());     }     /**      * 生成随机背景条纹      *      * @param fc      * @param bc      * @return Color      */     private Color getRandColor(int fc, int bc) {         Random random = new Random();         if (fc > 255) {             fc = 255;         }         if (bc > 255) {             bc = 255;         }         int r = fc + random.nextInt(bc - fc);         int g = fc + random.nextInt(bc - fc);         int b = fc + random.nextInt(bc - fc);         return new Color(r, g, b);     } }

ImageValidateCodeGenerator为图形验证码生成器,相对简单;当然里面有些类util的代码可以更好的封装,这里就不额外封装了。

存储验证码

验证码生成之后后端服务需要进行存储,方便后续校验的时候取得,相对比较简单,我这里使用的是Redis来存储。

ValidateCodeRepository.class

/**  * 验证码存取器 接口  */ public interface ValidateCodeRepository {     /**      * 保存验证码      *      * @param res 请求HttpRequest 和HttpResponse的封装      * @param code 验证码      * @param validateCodeType 验证码类型      */     void save(ServletWebRequest res, ValidateCode code, ValidateCodeEnum validateCodeType);     /**      * 获取验证码      *      * @param res      * @param validateCodeType      * @return Optional<ValidateCode>      */     Optional<ValidateCode> get(ServletWebRequest res, ValidateCodeEnum validateCodeType);     /**      * 移除验证码      *      * @param request      * @param codeType      */     void remove(ServletWebRequest request, ValidateCodeEnum codeType); }

ValidateCodeRepository 接口主要定义了三个方法save保存验证码, get获取验证码, remove移除验证码

RedisValidateCodeRepository.class

/**  * 基于redis的验证码存取器  */ @Slf4j @Component public class RedisValidateCodeRepository implements ValidateCodeRepository {     @Autowired     private RedisTemplate<Object, Object> redisTemplate;     /**      * 设备id      */     private static final String DEVICE_ID = "deviceId";     @Override     public void save(ServletWebRequest res, ValidateCode code, ValidateCodeEnum codeEnum) {         redisTemplate.opsForValue().set(buildKey(res, codeEnum), code, 30, TimeUnit.MINUTES);     }     @Override     public Optional<ValidateCode> get(ServletWebRequest request, ValidateCodeEnum codeEnum) {         Object value = redisTemplate.opsForValue().get(buildKey(request, codeEnum));         if (value == null) {             log.warn("不存在对应的验证码");             return Optional.empty();         }         return Optional.of((ValidateCode) value);     }     @Override     public void remove(ServletWebRequest request, ValidateCodeEnum codeEnum) {         redisTemplate.delete(buildKey(request, codeEnum));     }     /**      * 根据请求的设备生成验证码的key,如果同一个设备多次请求 则先前的验证码则被覆盖无效      *      * @param res      * @param codeEnum      * @return String redis存储的key      */     private String buildKey(ServletWebRequest res, ValidateCodeEnum codeEnum) {         String deviceId = res.getHeader(DEVICE_ID);         if (StringUtils.isBlank(deviceId)) {             throw new ValidateCodeException("请在请求头中携带deviceId参数");         }         String codeKey = SecurityConstant.DEFAULT_PARAMETER_NAME_CODE.concat(codeEnum.getType().toLowerCase())                 .concat(CommonConstant.COLON).concat(deviceId);         log.info("本次请求生成的codeKey:{}", codeKey);         return codeKey;     } }

RedisValidateCodeRepository 类是接口的具体实现,使用Redis 作为存储媒介,代码相对比较简单,不做过多的赘述。

发送验证码

验证码经过生成,后端存储后,就要进入最后一步:发送。

ValidareCodeSender.class

/**  * 短信验证码发送器  */ public interface SmsCodeSender {     /**      * 发送短线验证码      *      * @param code      * @param mobile      */     void send(String code, String mobile); }

DefaultSmsCodeSender.class

/**  * 默认的短信验证码的发送器  **/ @Slf4j public class DefaultSmsCodeSender implements SmsCodeSender {     @Override     public void send(String code, String mobile) {         // 这里做简单的输出即可         log.info("向手机号" + mobile + "发送短信验证码" + code);     } }

真实的生产中发送验证码需要使用第三方的短信服务,由于这里是学习记录,就简单的log一下记录发送。

图形验证码

图形验证码继承于验证码骨架,实现图形验证码有关的自定义逻辑,诸如:生成、发送。

ImageValidateCodeProcessor.class

/**  * 模板方法最底层 --- 基于各自的特定实现各自的发送行为  **/ @Component("imageValidateCodeProcessor") public class ImageValidateCodeProcessor extends AbstractValidateCodeProcessor<ImageValidateCode> {     private static final String JPEG = "JPEG";     @Override     protected void send(ServletWebRequest res, ImageValidateCode validateCode) throws IOException {         if (Objects.nonNull(res.getResponse())) {             ImageIO.write(validateCode.getBufferedImage(), JPEG, res.getResponse().getOutputStream());         }     } }

ImageValidateCodeProcessor类主要自定义图形验证码的发送逻辑,生成的逻辑已经封装在ImageValidateCodeGenerator类,由依赖查找的方式注入到验证码骨架中了。

短信验证码

短信验证码继承于验证码骨架,实现短信验证码有关的自定义逻辑,诸如:生成、发送。

SmsValidateCodeProcessor.class

/**  * 短信验证码的处理器  * 模板方法最底层 --- 基于各自的特定实现各自的发送行为  **/ @Component("smsValidateCodeProcessor") public class SmsValidateCodeProcessor extends AbstractValidateCodeProcessor<ValidateCode> {     @Autowired     private SmsCodeSender smsCodeSender;     private static final String MOBILE = "mobile";     @Override     protected void send(ServletWebRequest res, ValidateCode validateCode) throws ServletRequestBindingException {         smsCodeSender.send(validateCode.getCode(), ServletRequestUtils.getRequiredStringParameter(res.getRequest(), MOBILE));     } }

SmsValidateCodeProcessor类同样自定义短信验证码的发送逻辑,生成的逻辑已经封装在SmsValidateCodeGenerator类,由依赖查找的方式注入到验证码骨架中了。

其他模块

其他模块主要是一些配置类、枚举类、异常类以及是一些用以提升代码质量的封装,需要特别介绍的是ValidateCodeBeanConfig 配置类和ValidateCodeException 异常类。

ValidateCodeBeanConfig 配置了bean的生成规则,契合SpringBoot的默认实现原理:用户有自定义则使用自定义,没有则使用默认实现。

ValidateCodeBeanConfig.class

@Configuration public class ValidateCodeBeanConfig {     @Autowired     private SecurityProperties securityProperties;     /**      * 注册图形验证码生成器      * 使用conditionalOnMissingBean是为了 如果业务方有自己的生成逻辑 则使用业务方的;否则使用该默认配置      * 方法名就是bean的名字      *      * @return ValidateCodeGenerator      */     @Bean     @ConditionalOnMissingBean(name = "imageValidateCodeGenerator")     public ValidateCodeGenerator imageValidateCodeGenerator() {         return new ImageValidateCodeGenerator(securityProperties);     }     /**      * 短线验证码生成器      *      * @return ValidateCodeGenerator      */     @Bean     @ConditionalOnMissingBean(name = "smsValidateCodeGenerator")     public ValidateCodeGenerator smsValidateCodeGenerator() {         return new SmsValidateCodeGenerator(securityProperties);     }     /**      * 找到smsCodeSender接口的所有实现类      * 默认实现是用来被覆盖的      * 如果之前用户已经配置了 则不再装载Default的      */     @Bean     @ConditionalOnMissingBean(SmsCodeSender.class)     public SmsCodeSender smsCodeSender() {         return new DefaultSmsCodeSender();     } }

配置Bean的生成规则,例如:Generator模块,用户可通过实现ValidateCodeGenerator来达到自定义验证码生成,否则使用默认的生成器,也是一种编程技巧。

ValidateCodeException 异常类继承于 SpringSecurity的异常基类AuthenticationException,这是因为我们是基于SpringSecurity做扩展开发自定义验证码认证模式。

/**  * AuthenticationException是整个security异常中的基类  * 验证码异常属于认证过程中的一个特例,归属于该基类之下  **/ public class ValidateCodeException extends AuthenticationException {     /**      * 验证码异常      * @param msg      * @return t      */     public ValidateCodeException(String msg, Throwable t) {         super(msg, t);     }     public ValidateCodeException(String msg) {         super(msg);     } }

其他的一些可以根据类名大致猜出作用的类这里就不做过多的展示。

总结

本篇文章主要结合模板方法模式介绍了验证码的生成,并且介绍了2个比较常用的编程技巧:依赖查找 和 使用ConditionalOnMissingBean 契合SpringBoot默认实现思想。

本文如有错误或不妥指出,烦请指出!

一套非常好的springboot学习教程分享给大家(需要的链接自行观看)👇:

https://www.bilibili.com/video/BV1PZ4y1j7QK

SpringBoot最新教程-SpringBoot框架实战

 

 

最新回复(0)