一种前后端滑动验证码方案
概述
实际开发中,很常见的一种需求,即拦截机器人登陆,增加略微复杂的滑动验证码(相对于静态验证码而言)。
网络上有很多的实现,并且封装成maven可以检索的jar包。
实现
先给出后端的接口:
后端
/**
* 校验滑动验证码
*/
@RequestMapping("checkcaptcha")
public String checkCaptcha(@RequestBody JSONObject jsonObject) {
String captchaToken = jsonObject.getString("captchtoken");
Integer moveLength = jsonObject.getInteger("moveLength");
if (StringUtils.isEmpty(captchaToken)) {
return JSONObject.toJSONString("验证不通过");
}
try {
String token = RedisTool.getValueByKey(jedisCluster, captchaToken);
if (StringUtils.isEmpty(token)) {
return JSONObject.toJSONString("验证过期,请重试");
}
Integer xWidth = Integer.parseInt(RedisTool.getValueByKey(jedisCluster, captchaToken).trim());
// 精度控制
if (Math.abs(xWidth - moveLength) > 10) {
RedisTool.delKey(jedisCluster, captchaToken);
return JSONObject.toJSONString("验证不通过");
}
} catch (Exception e) {
RedisTool.delKey(jedisCluster, captchaToken);
return JSONObject.toJSONString(e.getMessage());
}
return JSONObject.toJSONString("success");
}
/**
* 生成验证码图片
*/
@RequestMapping(value = "/captcha")
public String getCaptchaImage() {
VerificationCodePlace vcPlace = VerificationCodeAdapter.getRandomVerificationCodePlace(this.getClass().getResource("/static/captcha/after/").getPath(),
this.getClass().getResource("/static/captcha/image/").getPath());
try {
String token = UUID.randomUUID().toString();
RedisTool.setKeyValueExpire(jedisCluster, token, vcPlace.getXLocation().toString(), 60 * 2);
vcPlace.setXLocation(0);
vcPlace.setCaptchtoken(token);
return JSONObject.toJSONString(vcPlace);
} catch (Exception e) {
return JSONObject.toJSONString(e.getMessage());
}
}
POJO
@Data
public class VerificationCodePlace {
private String backName;
private String markName;
private Integer xLocation;
private Integer yLocation;
private String captchtoken;
public VerificationCodePlace(String backName, String markName, Integer xLocation, Integer yLocation) {
this.backName = backName;
this.markName = markName;
this.xLocation = xLocation;
this.yLocation = yLocation;
}
}
@Slf4j
public class VerificationCodeAdapter {
/**
* 模板图宽度
*/
private static final int CUT_WIDTH = 50;
/**
* 模板图高度
*/
private static final int CUT_HEIGHT = 50;
/**
* 抠图凸起圆心
*/
private static final int circleR = 5;
/**
* 抠图内部矩形填充大小
*/
private static final int RECTANGLE_PADDING = 8;
/**
* 抠图的边框宽度
*/
private static final int SLIDER_IMG_OUT_PADDING = 1;
// 生成拼图样式
private static int[][] getBlockData() {
int[][] data = new int[CUT_WIDTH][CUT_HEIGHT];
Random random = new Random();
// (x-a)²+(y-b)²=r²
// x中心位置左右5像素随机
double x1 = RECTANGLE_PADDING + (CUT_WIDTH - 2 * RECTANGLE_PADDING) / 2.0 - 5 + random.nextInt(10);
// y矩形上边界半径-1像素移动
double y1_top = RECTANGLE_PADDING - random.nextInt(3);
double y1_bottom = CUT_HEIGHT - RECTANGLE_PADDING + random.nextInt(3);
double y1 = random.nextInt(2) == 1 ? y1_top : y1_bottom;
double x2_right = CUT_WIDTH - RECTANGLE_PADDING - circleR + random.nextInt(2 * circleR - 4);
double x2_left = RECTANGLE_PADDING + circleR - 2 - random.nextInt(2 * circleR - 4);
double x2 = random.nextInt(2) == 1 ? x2_right : x2_left;
double y2 = RECTANGLE_PADDING + (CUT_HEIGHT - 2 * RECTANGLE_PADDING) / 2.0 - 4 + random.nextInt(10);
double po = Math.pow(circleR, 2);
for (int i = 0; i < CUT_WIDTH; i++) {
for (int j = 0; j < CUT_HEIGHT; j++) {
// 矩形区域
boolean fill;
if ((i >= RECTANGLE_PADDING && i < CUT_WIDTH - RECTANGLE_PADDING)
&& (j >= RECTANGLE_PADDING && j < CUT_HEIGHT - RECTANGLE_PADDING)) {
data[i][j] = 1;
fill = true;
} else {
data[i][j] = 0;
fill = false;
}
// 凸出区域
double d3 = Math.pow(i - x1, 2) + Math.pow(j - y1, 2);
if (d3 < po) {
data[i][j] = 1;
} else {
if (!fill) {
data[i][j] = 0;
}
}
// 凹进区域
double d4 = Math.pow(i - x2, 2) + Math.pow(j - y2, 2);
if (d4 < po) {
data[i][j] = 0;
}
}
}
// 边界阴影
for (int i = 0; i < CUT_WIDTH; i++) {
for (int j = 0; j < CUT_HEIGHT; j++) {
// 四个正方形边角处理
for (int k = 1; k <= SLIDER_IMG_OUT_PADDING; k++) {
// 左上、右上
if (i >= RECTANGLE_PADDING - k && i < RECTANGLE_PADDING
&& ((j >= RECTANGLE_PADDING - k && j < RECTANGLE_PADDING)
|| (j >= CUT_HEIGHT - RECTANGLE_PADDING - k && j < CUT_HEIGHT - RECTANGLE_PADDING + 1))) {
data[i][j] = 2;
}
// 左下、右下
if (i >= CUT_WIDTH - RECTANGLE_PADDING + k - 1 && i < CUT_WIDTH - RECTANGLE_PADDING + 1) {
for (int n = 1; n <= SLIDER_IMG_OUT_PADDING; n++) {
if (((j >= RECTANGLE_PADDING - n && j < RECTANGLE_PADDING)
|| (j >= CUT_HEIGHT - RECTANGLE_PADDING - n && j <= CUT_HEIGHT - RECTANGLE_PADDING))) {
data[i][j] = 2;
}
}
}
}
if (data[i][j] == 1 && j - SLIDER_IMG_OUT_PADDING > 0 && data[i][j - SLIDER_IMG_OUT_PADDING] == 0) {
data[i][j - SLIDER_IMG_OUT_PADDING] = 2;
}
if (data[i][j] == 1 && j + SLIDER_IMG_OUT_PADDING > 0 && j + SLIDER_IMG_OUT_PADDING < CUT_HEIGHT && data[i][j + SLIDER_IMG_OUT_PADDING] == 0) {
data[i][j + SLIDER_IMG_OUT_PADDING] = 2;
}
if (data[i][j] == 1 && i - SLIDER_IMG_OUT_PADDING > 0 && data[i - SLIDER_IMG_OUT_PADDING][j] == 0) {
data[i - SLIDER_IMG_OUT_PADDING][j] = 2;
}
if (data[i][j] == 1 && i + SLIDER_IMG_OUT_PADDING > 0 && i + SLIDER_IMG_OUT_PADDING < CUT_WIDTH && data[i + SLIDER_IMG_OUT_PADDING][j] == 0) {
data[i + SLIDER_IMG_OUT_PADDING][j] = 2;
}
}
}
return data;
}
// 抠出拼图
private static void cutImgByTemplate(BufferedImage oriImage, BufferedImage targetImage, int[][] blockImage, int x, int y) {
int[][] martrix = new int[3][3];
int[] values = new int[9];
for (int i = 0; i < CUT_WIDTH; i++) {
for (int j = 0; j < CUT_HEIGHT; j++) {
int _x = x + i;
int _y = y + j;
int rgbFlg = blockImage[i][j];
int rgb_ori = oriImage.getRGB(_x, _y);
// 原图中对应位置变色处理
if (rgbFlg == 1) {
// 抠图上复制对应颜色值
targetImage.setRGB(i, j, rgb_ori);
//原图对应位置颜色变化
// oriImage.setRGB(_x, _y, Color.LIGHT_GRAY.getRGB());
// 抠图区域高斯模糊
readPixel(oriImage, _x, _y, values);
fillMatrix(martrix, values);
oriImage.setRGB(_x, _y, avgMatrix(martrix));
} else if (rgbFlg == 2) {
targetImage.setRGB(i, j, Color.WHITE.getRGB());
oriImage.setRGB(_x, _y, Color.GRAY.getRGB());
} else if (rgbFlg == 0) {
//int alpha = 0;
// targetImage.setRGB(i, j, rgb_ori & 0x00ffffff);
}
}
}
}
private static void readPixel(BufferedImage img, int x, int y, int[] pixels) {
int xStart = x - 1;
int yStart = y - 1;
int current = 0;
for (int i = xStart; i < 3 + xStart; i++) {
for (int j = yStart; j < 3 + yStart; j++) {
int tx = i;
if (tx < 0) {
tx = -tx;
} else if (tx >= img.getWidth()) {
tx = x;
}
int ty = j;
if (ty < 0) {
ty = -ty;
} else if (ty >= img.getHeight()) {
ty = y;
}
pixels[current++] = img.getRGB(tx, ty);
}
}
}
private static void fillMatrix(int[][] matrix, int[] values) {
int filled = 0;
for (int[] x : matrix) {
for (int j = 0; j < x.length; j++) {
x[j] = values[filled++];
}
}
}
private static int avgMatrix(int[][] matrix) {
int r = 0;
int g = 0;
int b = 0;
for (int[] x : matrix) {
for (int j = 0; j < x.length; j++) {
if (j == 1) {
continue;
}
Color c = new Color(x[j]);
r += c.getRed();
g += c.getGreen();
b += c.getBlue();
}
}
return new Color(r / 8, g / 8, b / 8).getRGB();
}
/**
* 获取图片
*/
private static BufferedImage getBufferedImage(String path) throws IOException {
File file = new File(path);
if (file.isFile()) {
return ImageIO.read(file);
}
return null;
}
// 处理存放
private static VerificationCodePlace cutAndSave(String imgName, String path, int[][] data, String headPath) throws Exception {
VerificationCodePlace vcPlace =
new VerificationCodePlace("sample_after.png", "sample_after_mark.png", 112, 50);
// 进行图片处理
BufferedImage originImage = getBufferedImage(path);
if (originImage != null) {
int locationX = 90 + new Random().nextInt(originImage.getWidth() - CUT_WIDTH * 3);
int locationY = new Random().nextInt(originImage.getHeight() - CUT_HEIGHT) / 2;
BufferedImage markImage = new BufferedImage(CUT_WIDTH, CUT_HEIGHT, BufferedImage.TYPE_4BYTE_ABGR);
cutImgByTemplate(originImage, markImage, data, locationX, locationY);
vcPlace = new VerificationCodePlace(getImageBASE64(originImage), getImageBASE64(markImage), locationX, locationY);
}
return vcPlace;
}
/**
* 图片转BASE64
*/
private static String getImageBASE64(BufferedImage image) throws IOException {
ByteArrayOutputStream bao = new ByteArrayOutputStream();
ImageIO.write(image, "png", bao);
byte[] imagedata = bao.toByteArray();
BASE64Encoder encoder = new BASE64Encoder();
String base64Image = encoder.encodeBuffer(imagedata).trim();
// 删除 \r\n
base64Image = base64Image.replaceAll("\r|\n", "");
return base64Image;
}
/**
* 获取文件夹下所有文件名
*/
private static ArrayList<String> getFileNamesFromDic(String dicPath) {
File dic = new File(dicPath);
ArrayList<String> imageFileNames = new ArrayList<>();
File[] dicFileList = dic.listFiles();
for (File f : dicFileList) {
imageFileNames.add(f.getName());
}
return imageFileNames;
}
/**
* 总流程,随机获取图片并处理,将拼图和对应图片存放至after_img
* 出错则返回sample
* headPath为存放生成图片的文件夹地址
*/
public static VerificationCodePlace getRandomVerificationCodePlace(String headPath, String imageUrl) {
VerificationCodePlace vcPlace = new VerificationCodePlace("sample_after.png", "sample_mark_after.png", 112, 50);
// 从文件夹中读取所有待选择文件
ArrayList<String> imageFileNames = getFileNamesFromDic(imageUrl);
// 随机获取
int r = (int) Math.round(Math.random() * (imageFileNames.size() - 1));
String imgName = imageFileNames.get(r);
String path = imageUrl + imgName;
int[][] data = VerificationCodeAdapter.getBlockData();
// 进行图片处理
try {
vcPlace = cutAndSave(imgName, path, data, headPath);
} catch (Exception e) {
log.error("getRandomVerificationCodePlace failed: " + e.getMessage());
return vcPlace;
}
return vcPlace;
}
}
前端
前端采用React框架,使用TypeScript语法。
service目录下的login.ts
源码如下,指定后端接口:
interface CheckCaptchaType {
moveLength: string;
captchtoken: string;
}
export async function getImgInfo() {
return request(`/api/login/captcha`);
}
export async function checkCaptcha(params: CheckCaptchaType) {
return request('/api/login/checkcaptcha', {
method: 'POST',
data: params,
});
}
models目录下的login.ts
源码定义,是接口请求成功和失败的逻辑:
import {stringify} from 'querystring';
import {Effect, history, Reducer} from 'umi';
import {getImgInfo} from '@/services/login';
import {message} from 'antd';
export interface StateType {
status?: 'ok' | 'error';
type?: string;
logMsg?: string;
currentAuthority?: 'user' | 'guest' | 'admin';
imageInfo?: any;
}
export interface LoginModelType {
namespace: string;
state: StateType;
effects: {
getImgCode: Effect;
};
reducers: {
changeImageInfo: Reducer<StateType>;
};
}
const Model: LoginModelType = {
namespace: 'login',
state: {
status: undefined,
},
effects: {
* getImgCode(_, {call, put}) {
const response = yield call(getImgInfo);
yield put({
type: 'changeImageInfo',
payload: response,
});
},
},
reducers: {
changeImageInfo(state, {payload}) {
const lx = payload.xLocation;
const ly = payload.yLocation;
const backImg = `/captcha/after/${payload.backName}`;
const frontImg = `/captcha/after/${payload.markName}`;
return {
...state,
imageInfo: {lx, ly, backImg, frontImg}
};
},
};
export default Model;
models目录下的ImageCode.ts
源码:
import {Effect, Reducer} from 'umi';
import {checkCaptcha, getImgInfo} from '@/services/login';
import {message} from 'antd';
export interface ImageInfoType {
lx?: number;
ly?: number;
backImg?: string;
frontImg?: string;
captchtoken?: string;
}
export interface ImageCodeModelState {
status?: 'ok' | 'error';
imageInfo?: ImageInfoType;
isSuccess?: true | false;
sliderStart?: true | false;
x?: number;
y?: number;
showTip?: 'none' | 'block';
maskColor?: '1px solid #F75567' | 'none' | '1px solid #3F81EE';
yArray?: Array<number>;
moveX?: number;
btnClass?: 'sliderArrowError' | 'sliderArrow';
btnSvg?: 0 | 1;
}
export interface ImageCodeModelType {
namespace: string;
state: ImageCodeModelState;
effects: {
getImgCode: Effect;
checkcaptcha: Effect;
};
reducers: {
changeImageInfo: Reducer<ImageCodeModelState>;
setCheckResult: Reducer<ImageCodeModelState>;
setSliderPush: Reducer<ImageCodeModelState>;
setSliderDrug: Reducer<ImageCodeModelState>;
setSliderStart: Reducer<ImageCodeModelState>;
setIsSuccess: Reducer<ImageCodeModelState>;
reset: Reducer<ImageCodeModelState>;
setFailed: Reducer<ImageCodeModelState>;
};
}
const Model: ImageCodeModelType = {
namespace: 'imagecode',
state: {
status: undefined,
imageInfo: {
lx: undefined,
ly: 0,
frontImg: '',
backImg: '',
captchtoken: '',
},
isSuccess: false,
sliderStart: false,
x: undefined,
y: undefined,
showTip: 'block',
maskColor: 'none',
yArray: [],
moveX: 0,
btnClass: 'sliderArrow',
btnSvg: 1,
},
effects: {
* getImgCode(_, {call, put}) {
yield put({
type: 'reset',
});
const response = yield call(getImgInfo);
yield put({
type: 'changeImageInfo',
payload: response ?? {},
});
},
* checkcaptcha({payload, callback}, {call, put}) {
yield put({
type: 'setSliderStart',
payload: false,
})
const response = yield call(checkCaptcha, payload);
if (response.status === 'success' && response.msg === '验证通过') {
message.success('验证通过')
yield put({
type: 'setIsSuccess',
payload: true,
})
if (callback && typeof callback === 'function') callback();
} else {
yield put({
type: 'setFailed',
})
yield call((timeout: number) => {
return new Promise(resolve => {
setTimeout(resolve, timeout);
});
}, 500);
yield put({
type: 'reset',
});
yield put({
type: 'getImgCode',
});
}
}
},
reducers: {
changeImageInfo(state, {payload}) {
if (payload.status === 'success') {
const result = JSON.parse(payload.msg)
const lx = result.xLocation;
const ly = result.yLocation;
const backImg = `data:image/png;base64,${result.backName}`;
const frontImg = `data:image/png;base64,${result.markName}`;
const {captchtoken} = result;
return {
...state,
status: 'ok',
imageInfo: {lx, ly, backImg, frontImg, captchtoken},
isSuccess: false,
};
}
return {
...state,
status: 'error',
};
},
setCheckResult(state, {payload}) {
if (payload.status === 'success' && payload.msg === '验证通过') {
return {
...state,
isSuccess: true,
}
}
return {...state};
},
setSliderPush(state, {payload}) {
if (!state?.sliderStart && state!.yArray!.length > 0) return {...state};
const {x, y,} = payload;
return {
...state,
sliderStart: true,
showTip: 'none',
maskColor: '1px solid #3F81EE',
yArray: [],
x, y,
}
},
setSliderDrug(state, {payload}) {
if (!state?.sliderStart) return {...state};
const {moveY, moveX} = payload;
const yArray = state?.yArray;
yArray?.push(moveY);
if (moveX < 5 || moveX + 50 > 280) {
return {
...state,
yArray,
}
}
return {
...state,
yArray,
moveX,
}
},
setSliderStart(state, {payload}) {
return {
...state,
sliderStart: payload
}
},
setIsSuccess(state, {payload}) {
return {
...state,
isSuccess: payload,
btnClass: 'sliderArrowSuccess',
maskColor: '0',
}
},
setFailed(state) {
return {
...state,
maskColor: '1px solid #F75567',
btnClass: 'sliderArrowError',
btnSvg: 0,
}
},
reset(state) {
return {
...state,
btnClass: 'sliderArrow',
maskColor: 'none',
moveX: 0,
showTip: 'block',
btnSvg: 1,
yArray: [],
isSuccess: false,
}
}
},
};
export default Model;