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

本文思维导图

图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(),具体的生成逻辑由对应的子类SmsValidatecodeGeneratorImageValidateCodeGenerator实现。

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

 

 

©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页