前后端分离开发中的验证码问题

1.环境和工具

  • 后端:JDK 11、Tomcat 9、 maven 3.6.2、MySQL 5.7
  • 前端:node.js 12.13.0、npm 6.12.0、VueCLI构建的SPA、axios网络请求工具

2.方案描述

  • 后端通过工具方法生成随机字符串
  • 封装使用该字符串在缓冲区生成指定大小图片的方法(可以加干扰线、噪点等),本文主要说明问题的解决流程,所以不对此展开过多探讨
  • 同时需要将该字符串存入HttpSession对象中,其SessionId通过response的响应头Access-Token存入,和验证码一起返回给客户端
  • Servlet处理请求,将该图片通过response对象的输出流返回给客户端
  • 客户端在页面加载完毕,通过请求可以拿到该验证码图片的字节流,通过blob的处理,生成一个URL,作为图片的src属性的值,显示验证码图片,同时可以在请求的回调函数中,通过response的headers取到access-token的值,就是和服务器端对应的那个JSESSIONID的值(传统开发中,一般记录在cookie中),但是通过chrome工具可以看到,是个HTTPOnly,所以拿不到。
  • 加上时间戳,可以实现点击图片换一张的效果
  • 点击“登录”按钮,将用户输入的账号、密码、验证码封装成传输对象,一起传到后台,同时在请求头里面加入之前取到的那个JSESSIONID的值。

3.注意点

  • 过滤器需要添加ACCESS-TOKEN的支持
  • 后端的response需要通过响应头将这个session的id返回给客户端,客户端在回调函数可以拿到响应头,取出这个值绑定给一个变量
  • 客户端在第二次发起登录请求的时候,记得在请求头里面加入这个值,到了登录的请求处理代码里面,通过自定义的Session监听方法,可以通过session的id拿到前一次的那个session对象,从而得到之前生成的正确的验证码的值,把它和刚登录的时候传到后端的登录用户的DTO对象里的验证码比对,判断要不要继续下一步登陆验证。

4.关键代码

  • 生成随机字符串
import java.util.Random;

/**
 * @author mq_xu
 * @ClassName StringUtil
 * @Description 字符串工具类
 * @Date 2019/11/14
 * @Version 1.0
 **/
public class StringUtil {

   private final static int MAX = 4;

    public static String getRandomString() {
        StringBuilder stringBuilder = new StringBuilder();
        Random random = new Random();
        int index;
        //生成四位随机字符
        for (int i = 0; i < MAX; i++) {
            //随机生成0、1、2三个整数,代表数字字符、大写字母、小写字母,保证验证码的组成比较正态随机
            index = random.nextInt(3);
            //调用本类封装的私有方法,根据编号获得对应的字符
            char result = getChar(index);
            //追加到可变长字符串
            stringBuilder.append(result);
        }
        return stringBuilder.toString();
    }

    private static char getChar(int item) {
        //数字字符范围
        int digitalBound = 10;
        //字符范围
        int charBound = 26;
        Random random = new Random();
        int index;
        char c;
        //根据调用时候的三个选项,生成数字、大写字母、小写字母三种不同的字符
        if (item == 0) {
            index = random.nextInt(digitalBound);
            c = (char) ('0' + index);
        } else if (item == 1) {
            index = random.nextInt(charBound);
            c = (char) ('A' + index);
        } else {
            index = random.nextInt(charBound);
            c = (char) ('a' + index);
        }
        return c;
    }
}
  • 生成验证码图片
import java.awt.*;
import java.awt.image.BufferedImage;

/**
 * @author mq_xu
 * @ClassName ImageUtil
 * @Description 验证码生成
 * @Date 2019/11/18
 * @Version 1.0
 **/
public class ImageUtil {
    public static BufferedImage getImage(int width, int height, String content) {
        //创建指定大小和图片模式的缓冲图片对象
        BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        //绘图对象
        Graphics2D graphics = (Graphics2D) img.getGraphics();
        //设置颜色
        graphics.setColor(new Color(68, 134, 49));
        //绘制填充矩形
        graphics.fillRect(0, 0, width, height);
        //设置画笔颜色
        graphics.setPaint(new Color(60, 63, 65));
        //设置字体
        Font font = new Font("微软雅黑", Font.BOLD, 40);
        graphics.setFont(font);
        //在指定位置绘制字符串
        graphics.drawString(content, width / 3, height / 2);
        return img;
    }
}
  • 处理验证码请求
import com.scs.web.blog.util.ImageUtil;
import com.scs.web.blog.util.StringUtil;

import javax.imageio.ImageIO;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.OutputStream;

/**
 * @author mq_xu
 * @ClassName CodeController
 * @Description 验证码请求接口
 * @Date 2019/11/14
 * @Version 1.0
 **/
@WebServlet(urlPatterns = {"/api/code"})
public class CodeController extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        //获取随机验证码
        String code = StringUtil.getRandomString();
        //存入session
        HttpSession session = req.getSession();
        session.setAttribute("code", code);
        //将sessionId通过响应头传回客户端
        resp.setHeader("Access-Token",session.getId());
         //调过生成验证码图片的方法
        BufferedImage img = ImageUtil.getImage(200, 100, code);
        //设置resp的响应内容类型,前端将是blob
        resp.setContentType("image/jpg");
        //将图片通过输出流返回给客户端
        OutputStream out = resp.getOutputStream();
        ImageIO.write(img, "jpg", out);
        out.close();
    }
}

  • 处理登录请求的核心代码(因为请求地址的不同,此处做了一些封装,没有直接写在doPost()方法里)
        BufferedReader reader = req.getReader();
        StringBuilder stringBuilder = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            stringBuilder.append(line);
        }
        logger.info("登录用户信息:" + stringBuilder.toString());
        Gson gson = new GsonBuilder().create();
        UserDto userDto = gson.fromJson(stringBuilder.toString(), UserDto.class);
        //客户端输入的验证码
        String inputCode = userDto.getCode().trim();
        //取得客户端请求头里带来的token
        String sessionId = req.getHeader("Access-Token");
        //从自定义的监听代码中取得之前的session对象
        MySessionContext myc = MySessionContext.getInstance();
        HttpSession session = myc.getSession(sessionId);
        //取得当时存入的验证码
        String correctCode = session.getAttribute("code").toString();
        PrintWriter out = resp.getWriter();
        //忽略大小写比对
        if (inputCode.equalsIgnoreCase(correctCode)) {
            //验证码正确,进入登录业务逻辑调用
            Result result = userService.signIn(userDto);
        } else {
            //验证码错误,直接将错误信息返回给客户端,不要继续登录流程了
            Result result = Result.failure(ResultCode.USER_VERIFY_CODE_ERROR);
        }
        out.print(gson.toJson(result));
        out.close();
  • 后端跨域处理
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author mq_xu
 * @ClassName CORSFilter
 * @Description 跨域过滤器类
 * @Date 2019/10/3
 * @Version 1.0
 **/
@WebFilter(urlPatterns = "/*")
public class CorsFilter implements Filter {
    private static Logger logger = LoggerFactory.getLogger(CorsFilter.class);

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletResponse response = (HttpServletResponse) res;
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, PUT, OPTIONS, DELETE");
        response.setHeader("Access-Control-Max-Age", "3600");
        //允许客户端请求头携带
        response.setHeader("Access-Control-Allow-Headers", "x-requested-with, Content-Type,Access-Token");
        //允许给客户端响应头携带
        response.setHeader("Access-Control-Expose-Headers", "Access-Token");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        chain.doFilter(req, res);
    }

    @Override
    public void init(FilterConfig filterConfig) {
        logger.info("跨域过滤器初始化");
    }

    @Override
    public void destroy() {
        logger.info("跨域过滤器销毁");
    }

}
  • 前端登录页面(省略CSS样式)
<template>
    <div id="bg">
        <router-link to="/">首页</router-link>
        <div class="login-box">
            <form class="login-form">
                <input type="text" v-model="userDto.mobile" id="mobile" />
                <input type="password" v-model="userDto.password" />
                <div class="code-box">
                    <input type="text" v-model="userDto.code" class="code" />
                    <img class="verify" @click.prevent="refresh" ref="codeImg" />
                </div>
                <input type="button" class="btn btn-lg dark-fill" value="登录" @click="signIn(userDto)" />
                <router-link to="/sign-up">没有账号?去注册</router-link>
            </form>
        </div>
    </div>
</template>
<script>
export default {
    data() {
        return {
            userDto: {
                mobile: '',
                password: '',
                code: ''
            },
            token: ''
        };
    },
    created() {
        this.axios.get(this.GLOBAL.baseUrl + '/code', { responseType: 'blob' }).then(res => {
            // console.log(res);
            var img = this.$refs.codeImg;
            let url = window.URL.createObjectURL(res.data);
            img.src = url;
            console.log(res.headers);
            //取得后台通过响应头返回的sessionId的值
            this.token = res.headers['access-token'];
            console.log(this.token);
        });
    },
    methods: {
        signIn(userDto) {
            this.axios({
                method: 'post',
                url: this.GLOBAL.baseUrl + '/user/sign-in',
                data: JSON.stringify(this.userDto),
                headers: {
                    'Access-Token': this.token  //将token放在请求头带到后端
                }
            }).then(res => {
                if (res.data.msg === '成功') {
                    alert('登录成功');
                    localStorage.setItem('user', JSON.stringify(res.data.data));
                    this.$router.push('/');
                } else {
                    alert(res.data.msg);
                    this.userDto.code = '';
                }
            });
        },
        refresh() {
            this.axios.get(this.GLOBAL.baseUrl + '/code', { responseType: 'blob' }).then(res => {
                console.log(res);
                var img = this.$refs.codeImg;
                let url = window.URL.createObjectURL(res.data);
                img.src = url;
            });
        }
    }
};
</script>
<style scoped>
</style>
  • 验证结果
    登陆页面启动,会先从后端请求一个验证码,并且拿到响应头里面的sessionid的值


    前端

    查看请求验证码的网络请求,发现响应头里面加入了Access-Token的值


    验证码请求的响应头

可以看下后台的信息,两个id的值一致


后端

登录请求的请求头,可以看到带着的Access-Token


登录请求头
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,373评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,732评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,163评论 0 238
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,700评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,036评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,425评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,737评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,421评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,141评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,398评论 2 243
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,908评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,276评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,907评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,018评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,772评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,448评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,325评论 2 261

推荐阅读更多精彩内容