本文章记录在javaweb开发过程中,如何实现短信验证在指定有效时间内进行校验。

在一般情况下,实现验证码校验有两种方式。
   一、利用数据库,存储log日志表实现。
   二、使用Session缓存进行校验存在的问题以及优化方案。

  第一种方式比较简单,但是也比较麻烦,要设计表进行数据库的交互。思路大概也就是根据手机号查表得出最新的验证码及对应的创建时间,判断一下是否在指定有效时间内即可,在此就不详细展开说明。

  本文重点详细分享第二种方式,基于springboot使用缓存Session实现校验。




目录

  • 准备工作
  • 实现方式
  • 常见的实现方式(存bug)
  • 1.设置一个定时器。
  • 2.发送(生成)手机验证码。
  • 3.校验验证码是否成功
  • 可能会出现的问题
  • 解决优化的方式
  • 1、单独封装一个设置缓存的方法
  • 2、有效期定时器的设置
  • 3、发送手机验证码
  • 4、校验验证码的逻辑封装
  • 5、测试验证结果




准备工作

首先我们先准备一个封装手机及验证码的PO实体

/**
 * @Package: com.xizi.po
 * @ClassName: CheckCodeMsg
 * @Author: XIZI
 * @CreateTime: 2022/1/27 16:56
 * @Description: 封装的手机-验证码
 */
public class CheckCodeMsgPo {
    //手机号
    private String phone;

    //验证码
    private String code;

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

}




======================================================

实现方式

常见的实现方式(存bug)

  现在先别急,让我们看看网上很多例子以及一些常见的做法是什么。



1.设置一个定时器。

  定时器执行时间间隔为项目需求对应的有效时长,如图中代码,5分钟后验证码过期—>清除缓存

java验证 符号 java实现验证码校验_springboot

2.发送(生成)手机验证码。

  将手机号及验证码存入缓存Session中

java验证 符号 java实现验证码校验_java_02

3.校验验证码是否成功

java验证 符号 java实现验证码校验_spring_03

可能会出现的问题

这一套流程下来,正常情况是可以行得通的,但是在某些特殊情景会出现bug。

  当人员A点击了发送验证码后,已经生成了第一个验证码存储在Session中,但还处于验证码时效期的时候,A重新点击触发了第二次发送验证码。等A接收到最新的验证码提交时,刚好第一次验证码过期,触发了定时器任务执行清除Session,这个时候即使A把最新的验证码填上,校验时也会报该Session空指针(验证码过期)异常。

java验证 符号 java实现验证码校验_java验证 符号_04




因此,以上的方式更适合极短时效的场景,但也有一定风险出现这个问题。
定时器任务执行是分离异步的,那么我们怎么解决优化这个特殊情况的问题呢?

  针对上面的情况,解决优化的方案为我们将验证码及手机存入Session的方式可以修改为采用 Map<String, List< String >> 的形式,键存手机、值以list的形式存储验证码。

  这样一来定时器内的使得验证码过期的逻辑,就从清除Session,修改为在验证码的List中移除过期验证码的元素;而当验证匹配成功的时候,再直接清除Session。



下面直接上代码。




解决优化的方式

1、单独封装一个设置缓存的方法

  以下全局字典【SmsConstants.CHECK_SESSION】即Session存入的【CheckCode】命名。

/**
     * 设置缓存
     *
     * @param checkCodeMsgPo
     * @param request
     */
    private void setCodeSession(CheckCodeMsgPo checkCodeMsgPo, HttpServletRequest request) {
        //获取缓存内存
        HttpSession session = request.getSession();

        Map<String, List<String>> checkCode = (Map<String, List<String>>) session .getAttribute(SmsConstants.CHECK_SESSION);
        List<String> codes = new ArrayList<>();
        if (null != checkCode) {
            //如果原来就存有session,直接拿出原来的验证码的list
            codes = checkCode.get(checkCodeMsgPo.getPhone());
        } else {
            checkCode = new HashMap<>();
        }
        
        //将新的验证码放入list中
        codes.add(checkCodeMsgPo.getCode());
        checkCode.put(checkCodeMsgPo.getPhone(), codes);

        //自定义类设置进缓存
        session.setAttribute(SmsConstants.CHECK_SESSION, checkCode);

    }



2、有效期定时器的设置

  在阿里的代码规约中,建议使用ScheduledExecutorService代替Timer,那么以下定时器用ScheduledExecutorService实现替换。

/**
     * 指定有效时间内移除验证码
     *
     * @param session        缓存
     * @param attrName       存入缓存的值
     * @param checkCodeMsgPo 封装的手机号码
     */
    public void removeAttrbute(final HttpSession session, final String attrName, final CheckCodeMsgPo checkCodeMsgPo) {

        //从Session提取手机号对应的验证码
        Map<String, List<String>> checkCode = (Map<String, List<String>>) session.getAttribute(attrName);
        List<String> codes = checkCode.get(checkCodeMsgPo.getPhone());

        ScheduledExecutorService schedu = Executors.newScheduledThreadPool(1);
        Runnable removeCode = new Runnable() {
            @Override
            public void run() {
                try {
                    // 移除session中list存的验证码元素
                    codes.remove(checkCodeMsgPo.getCode());

                    logger.info("删除session中[" + checkCodeMsgPo.getPhone() + "]存的验证码:" + checkCodeMsgPo.getCode());
                } catch (Exception e) {
                    CheckCodeMsgPo checkCodeMsgPo = (CheckCodeMsgPo) session.getAttribute(attrName);
                    logger.error(checkCodeMsgPo.getPhone() + "移除出错。");
                }
            }
        };
        //功能:创建并执行在给定延迟后启用的一次性操作。对removeCode任务暂停五分钟后执行
        schedu.schedule(removeCode, 5 * 60, TimeUnit.SECONDS);
        //启动一次顺序关闭,执行以前提交的任务,但不接受新任务。
        schedu.shutdown();

    }

同样阿里规约可能会建议Executors手动创建线程池,那么如下修改即可

ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("code-thread-runner-%d").build();
        ScheduledExecutorService schedu = new ScheduledThreadPoolExecutor(1, threadFactory);
//        ScheduledExecutorService schedu = Executors.newScheduledThreadPool(1);



3、发送手机验证码

@RequestMapping("/sendCode")
    @ResponseBody
    @ApiOperation(value = "发送手机验证码", notes = "测试", httpMethod = "POST")
    public String sendCode(@RequestBody CheckCodeMsgPo checkCodeMsgPo, HttpServletRequest request) throws ErrorException {

        String phoneNum = checkCodeMsgPo.getPhone();
        if (null == phoneNum || "".equals(phoneNum)) {
            throw new ErrorException(Constants.API_SUCCESS, "请输入手机号!");
        }

        //随机生成四位的验证码
        checkCodeMsgPo.setCode(RandomNumUtil.getFourBitRandom());
        //自定义类设置进缓存
        setCodeSession(checkCodeMsgPo, request);

        //此处省略业务逻辑,发送手机验证码
        //.....

        removeAttrbute(request.getSession(), TencentSmsConstants.CHECK_SESSION, checkCodeMsgPo);

        return "已发送验证码!";
    }



4、校验验证码的逻辑封装

/**
     * 验证验证码的有效性
     *
     * @param checkCodeMsgPo
     * @param request
     * @return
     * @throws
     */
    @RequestMapping("/verifyCode")
    @ResponseBody
    @ApiOperation(value = "校验验证码", notes = "手机号、验证码一起传", httpMethod = "POST")
    public String verifyCode(@RequestBody CheckCodeMsgPo checkCodeMsgPo, HttpServletRequest request) throws ErrorException {
        Map<String, List<String>> checkCode = (Map<String, List<String>>) request.getSession().getAttribute(SmsConstants.CHECK_SESSION);

        if (null == checkCode) {
            throw new ErrorException(Constants.API_ERROR_FAIL, "验证码已超时!");
        }
        if (null != checkCode.get(checkCodeMsgPo.getPhone()) && checkCode.get(checkCodeMsgPo.getPhone()).size() > 0) {
            List<String> codes = checkCode.get(checkCodeMsgPo.getPhone());

            if (codes.contains(checkCodeMsgPo.getCode())) {
                //比对成功,移除元素以及清除缓存
                codes.remove(checkCodeMsgPo.getCode());
                request.getSession().removeAttribute(SmsConstants.CHECK_SESSION);
            } else {
                throw new ErrorException(Constants.API_ERROR_FAIL, "验证码错误!");
            }

        } else {
            throw new ErrorException(Constants.API_ERROR_FAIL, "验证码已超时!");
        }

        return "验证成功!";
    }

5、测试验证结果

这里为了快速测试将过期时长缩短为90秒,测试场景:相隔某个时间点后连续触发两次发送手机验证码。

测试情况一:当第一个验证码过期时,提交验证正常显示“验证码错误!”

java验证 符号 java实现验证码校验_spring_05

测试情况二:当输入最新的验证码,提交验证正常显示“验证成功!”

java验证 符号 java实现验证码校验_springboot_06


测试情况三:当最新的验证码也过期了,提交验证正常显示“验证码已过期!”

java验证 符号 java实现验证码校验_spring_07


大功告成!

希望这篇文章对你有所帮助。