文章目录

  • 需求
  • 后台实现
  • 前台实现
  • 彩蛋

需求

最近做的一个管理后台项目中,手机号登录是需要发送手机验证码的,然后需求方提出想要一个前台的图片验证码,只有输入正确后才可发送手机验证码。很简单很普通的一个需求但是实际做的时候,碰到了一个小坑,其他人有很多都没说出这点,千篇一律的。特此记录。

后台实现

图片的生成实现,这个部分网上已经有很多例子了,各种各样的实现方案和结果,大家找一个改改就应该可以了。

/**
 * Captcha
 * 图形验证码
 *
 * @author rysis
 * @version 1.00
 * @Date 2020/6/5 18:54
 */
public class Captcha {

    private static final char MAP_TABLE[] = {
            'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
            'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'
    };
    private static final int CHAR_NUM = 5; // 显示的字符数目
    private static final int DISTURB_LINES_NUM = 168; // 干扰线数目

    /**
     * 生成图片验证码相关信息
     *
     * @param width 图片的宽度
     * @param height 图片的高度
     * @return
     */
    public static Map<String, Object> getImageCode(int width, int height) {

        if (width <= 0) {
            width = 60;
        }
        if (height <= 0) {
            height = 20;
        }
        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.PLAIN, 18));
        // 随机产生 DISTURB_LINES_NUM 条干扰线,使图象中的认证码不易被其它程序探测到
        g.setColor(getRandColor(160, 200));
        for (int i = 0; i < DISTURB_LINES_NUM; 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);
        }
        // 取随机产生的码
        StringBuilder code = new StringBuilder();
        // CHAR_NUM代表CHAR_NUM位验证码,如果要生成更多位的认证码,则加大数值
        for (int i = 0; i < CHAR_NUM; ++i) {
            code.append(MAP_TABLE[(int) (MAP_TABLE.length * Math.random())]);
            // 将认证码显示到图象中
            g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
            // 直接生成
            String str = code.substring(i, i + 1);
            // 设置随便码在背景图图片上的位置
            g.drawString(str, 13 * i + 20, 25);
        }
        // 释放图形上下文
        g.dispose();
        Map<String, Object> returnMap = new HashMap<>();
        returnMap.put("image", image);
        returnMap.put("code", code.toString());
        return returnMap;
    }

    /**
     * 给定范围获得随机颜色
     *
     * @param fc
     * @param bc
     * @return
     */
    private static 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);
    }
}

Java 服务部分使用了 spring-boot。然后提供了两个接口:一个是用于给前台返回二进制图片的;另一个接口是 用于校验图片验证码接口。

@RestController
@RequestMapping(value = "/api")
public class CaptchaController {
    /**
     * 获取图片验证码接口
     *
     * @return
     */
    @PostMapping(value = "/captcha/image")
    public String imageCode() {
        // 返回验证码和图片的map
        Map<String, Object> map = Captcha.getImageCode(90, 37);

        // session 存入验证码和时间
        HttpSession session = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest().getSession();
        session.setAttribute(SIMPLE_CAPTCHA_KEY, map.get("code").toString().toLowerCase());
        session.setAttribute(CAPTCHA_TIME_KEY, new Date().getTime());

        // response
        HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getResponse();
        response.setDateHeader("Expires", 0);
        response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
        response.addHeader("Cache-Control", "post-check=0, pre-check=0");
        response.setHeader("Pragma", "no-cache");
        response.setContentType("image/jpeg"); // 这里要指定响应的类型
        OutputStream outs = null;
        try {
            outs = response.getOutputStream();
            ImageIO.write((BufferedImage) map.get("image"), "jpg", outs);
        } catch (IOException e) {
            logger.error("CaptchaController.imageCode:图片验证码流输出异常:", e.getMessage());
            return "";
        } finally {
            if (outs != null) {
                try {
                    outs.flush();
                    outs.close();
                } catch (IOException e) {
                    logger.error("CaptchaController.imageCode:图片验证码流关闭异常:", e.getMessage());
                }
            }
        }
        return null;
    }

    /**
     * 检查会话验证码信息是否正确
     *
     * @param paramJson 请求参数
     * @return
     */
    @PostMapping(value = "/captcha/image/check")
    public ResponseUtil.ResponseResult checkCode(@RequestBody JSONObject paramJson) {
        // session 验证码信息(这里是抽象到BaseController的一个获取方法)
        HttpSession session = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest().getSession();
        Object simpleCaptcha = session.getAttribute(SIMPLE_CAPTCHA_KEY); // 验证码信息
        Object captchaTime = session.getAttribute(CAPTCHA_TIME_KEY); // 验证码时间
        if (simpleCaptcha == null || captchaTime == null) {
            return ResponseUtil.failed("图形验证码已失效,请重新获取!", 2);
        }
        String captcha = simpleCaptcha.toString();
        Long createTime = Long.valueOf(captchaTime.toString());
        // 参数信息
        String checkCode = paramJson.getString("code");

		// 这里是我封装的一个类(类似:scala.util.Either),可以使用Map代替。
        Either<String, String> result = checkCode(checkCode, captcha, createTime);
        Map<String, Object> data = new HashMap<>();
        data.put("success", result.isRight());
        if (result.isRight()) {
            session.removeAttribute(SIMPLE_CAPTCHA_KEY);
            return ResponseUtil.success("success", data);
        } else {
            return ResponseUtil.success(result.getLeftData().orElse("图形验证码错误"), data);
        }
    }
    
	/**
     * 检查会话验证码信息是否正确(这个我是放到service中的)
     *
     * @param checkCode 前台的验证码参数
     * @param captcha session的验证码
     * @param createTime 验证码创建时间
     * @return
     */
    private Either<String, String> checkCode(String checkCode, String captcha, Long createTime) {
        Date now = new Date();

        logger.debug(String.format("CaptchaController.checkCode:图片验证码J检查:checkCode=%s, captcha=%s", checkCode, captcha));
        if (StringUtils.isEmpty(checkCode) || StringUtils.isEmpty(captcha) || !(checkCode.equalsIgnoreCase(captcha))) {
            return new Left<>("图形验证码错误!");
        } else if ((now.getTime() - createTime) > 5 * 60 * 1000) {
            // 验证码有效时长为5分钟
            return new Left<>("图形验证码已超时,请重新获取!");
        } else {
            return new Right<>("");
        }
    }
}

前台实现

前台使用了Vue来进行页面的开发。我这里主要的坑就是两个点:

  1. 由于我使用的axios守卫进行响应的拦截,我之前拦截是根据响应是数据内容进行判断成功与否。但是这里获取图片的时候是二进制的内容,无法直接进行判断,所以要根据content-type进行过滤判断;
  2. 响应的对图片无法使用<img>标签进行显示,但是使用 postman 和浏览器 network 却都能看见返回的图片,这里是需要在请求后端时要在header上面进行响应结果的标注 responseType: 'arraybuffer'

以上两点我都会在代码里面进行提到的。其中有些内容自行进行替换即可,这里我使用了element-ui的组件。

api.js 文件部分代码:

/**
   * 获取图片验证码
   * @param data
   */
getImageCode: (data) => {
  return request({
    url: `${_baseUrl}/api/captcha/image`,
    method: 'post',
    responseType: 'arraybuffer', // 注意点2
    data
  })
}

/**
 * 检查图片验证码
 * @param data
 */
checkImageCode: (data) => {
  return request({
    url: `${_baseUrl}/api/captcha/image/check`,
    method: 'post',
    data
  })
}

login.vue 文件中的部分代码:

<template>
	<img :src="imageCodeData" @click="getImageCode">
</template>
<script>
/**
 * 获取图片验证码
 */
getImageCode() {
  getImageCode().then(resp => {
    return 'data:image/png;base64,' + btoa(new Uint8Array(resp).reduce((data, byte) => data + String.fromCharCode(byte), ''))
  }).then(data => {
    this.imageCodeData = data
  })
},

/**
 * 检查图片验证码
 */
checkImageCode() {
  return checkImageCode({ code: this.loginForm.imageCode }).then(resp => {
    this.authCodeBtnLock = !resp.data.success
    this.authCodeBtnLock && this.$message.warning(resp.msg || '图片验证码错误')
    return this.authCodeBtnLock
  }).catch(() => {
    this.authCodeBtnLock = true
    return this.authCodeBtnLock
  })
}
</script>

到这里就结束了,如果有不明确的,欢迎留言!

附上以前可以破解滑动验证码例子:https://github.com/RysisLiang/Trial_Jigsaw

彩蛋

附上我的Either类的实现代码。只是个人的癖好,没有什么太多的实际意义。

package com.demo.utils.either;

import java.util.Optional;

/**
 * EitherAbstract
 * 简单实现 scala.util.Either
 *
 * @author Rysis
 * @version 1.00
 * @Date 2020/2/8 20:04
 */
public interface Either<A, B> {

    Optional<B> getRightData();

    Optional<A> getLeftData();

    boolean isRight();

    boolean isLeft();
}

/**
 * Left
 *
 * @author Rysis
 * @version 1.00
 * @Date 2020/2/8 20:05
 */
public class Left<A, B> implements Either<A, B> {
    private final boolean type = false;
    private A data;

    public Left(A data) {
        this.data = data;
    }

    @Override
    public Optional<B> getRightData() {
        return Optional.empty();
    }

    @Override
    public Optional<A> getLeftData() {
        return Optional.ofNullable(data);
    }

    @Override
    public boolean isRight() {
        return type;
    }

    @Override
    public boolean isLeft() {
        return !type;
    }

    public A getData() {
        return data;
    }

    public void setData(A data) {
        this.data = data;
    }

}

/**
 * Right
 *
 * @author Rysis
 * @version 1.00
 * @Date 2020/2/8 20:05
 */
public class Right<A, B> implements Either<A, B> {
    private final boolean type = true;
    private B data;

    public Right(B data) {
        this.data = data;
    }

    @Override
    public Optional<B> getRightData() {
        return Optional.ofNullable(data);
    }

    @Override
    public Optional<A> getLeftData() {
        return Optional.empty();
    }

    @Override
    public boolean isRight() {
        return type;
    }

    @Override
    public boolean isLeft() {
        return !type;
    }

    public B getData() {
        return data;
    }

    public void setData(B data) {
        this.data = data;
    }

}