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