图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框架实战