滑动验证码(前后端完整逻辑与代码)
验证码的作用
1、为了防止机器冒充人类做账号密码的暴力破解:调用接口或者模仿用户行为,大批量的尝试账号密码登录,就是为了获取真实的账号密码
2、防止大规模在线注册滥用服务:批量注册大量无用的账号信息,给服务器增加压力
3、防止滥用在线批量化操作:比如在投票的时候,有些恶意刷票软件就可以实现批量化投票功能
4、防止信息被大量采集聚合:互联网时代,最有价值的就是内容生产,精心创作的原创文章,而验证码可以防止机器批量的爬取数据
效果图
本文需要用到的关键技术
node 16.20.2、antd、react、jdk 1.8、springboot、redis
前端
一、依赖rc-slider-captcha
需要先添加滑动验证码生成的库
yarn add rc-slider-captcha
二、前端代码(该自定义组件主要用于登录,如果用于其他验证请适当修改)
1、思路
用户输入登录信息后,点击登录
打开验证码弹窗,保存用户输入的登录信息传入验证码弹窗组件
请求后端获取验证码的信息(图片)
在验证码弹窗中滑动滑块至目标x坐标,有三种情况
1、如果登录成功 则跳转
2、如果验证码验证失败 则刷新验证码
3、如果验证码验证成功,但输入用户信息有误 则关闭弹窗,让用户自己再输入登录信息
2、自定义滑动验证码弹窗组件,可直接复制使用
/* eslint-disable @typescript-eslint/ban-types */
import React from "react";
// 滑块验证码的组件
import SliderCaptcha from "rc-slider-captcha";
// 弹窗组件 如果没有用到 antd 也可以自己写个弹窗组件
import { Modal } from "antd";
// 后端返回的验证码结构,并且也是 SliderCaptcha 组件所需要的参数
export type CaptchaInfo = {
bgUrl: string; // 背景图
puzzleUrl: string; // 拼图
};
// 登录信息
export type LoginInfo = {
// 登录名
loginId: string;
// 密码
password: string;
// x轴偏移量
x: string;
// uuid 用于获取redis保存的x轴偏移量
uuid: string;
};
// 后端返回的验证码结构
export type ResultCaptchaInfo = {
code: string;
obj: { bgUrl: string; puzzleUrl: string; uuid: string; };
msg: string;
}
// 后端返回的登录信息
export type ResultLoginIno = {
// 返回的 自定义code
code: string;
// 返回的参数
obj: any;
// 错误\成功 信息
msg: string;
}
const ModalSliderCaptcha: React.FC<{
// 是否打开
open: boolean,
// 关闭时调用
onCancel: () => void,
// 登录信息(从登录页面获取)
// 该组件(ModalSliderCaptcha)只有UUID、X轴偏移量,其他信息需要通过该对象传递
loginData: LoginInfo,
// 向后端请求
// true代表 向后端请求后 登录成功或者登录账号密码有误(验证码验证成功,不管登录失败与否都需要关闭弹窗)
// false或者为空代表 向后端请求后 验证码失败(不关闭弹窗)
onVerify: (data?: LoginInfo) => Promise<boolean | undefined>,
// 请求后端 发送验证码图片
request: () => Promise<ResultCaptchaInfo>,
// Modal组件的属性,见:https://ant.design/components/modal-cn#api
modalProps?: {},
// SliderCaptcha的属性,详情见:https://www.npmjs.com/package/rc-slider-captcha
sliderCaptchaProps?: {},
}> = React.memo(({
modalProps, sliderCaptchaProps, open, onCancel, onVerify, request, loginData
}) => {
// 图片尺寸
const bgSize = {
width: 380,
height: 200,
}
// 拼图宽度
const puzzleWidth = 70
// uuid 用于向后端传递,从redis中取出值,校验验证码是否正确
const [uuid, setUuid] = React.useState<string>("")
// 请求验证码
const requestCaptcha = async () => {
return request().then((res: ResultCaptchaInfo): CaptchaInfo => {
// 每次请求都要重新设置UUID
setUuid(res?.obj?.uuid)
// 返回给 SliderCaptcha 组件所需要的验证码参数
return {
bgUrl: res?.obj.bgUrl,
puzzleUrl: res?.obj?.puzzleUrl,
} as CaptchaInfo
})
}
// 验证验证码(登录校验)
const onVerifyCaptcha: any = async (data?: LoginInfo) => {
const params = {
// 登录页面传入的参数,包括登录名、密码等信息
// ps:后面我想了一下,可以通过 onVerify() 把loginData这个参数包进去,你们也可以自己优化一下
...loginData,
// SliderCaptcha 组件返回的参数,包括x轴偏移量等信息
...data,
// uuid 为每次刷新验证码时保存,登录校验需要传给后端
uuid,
}
// 后端验证
let isOK = false
// onVerify(params) 方法已经做了登录校验,逻辑由登录页面实现
// .then 后面的参数 res 只是由 onVerify(params) 判断登录成功与否,再控制是否关闭弹窗
await onVerify(params).then((res: boolean | undefined) => {
// 判断是否关闭弹窗 onVerify(params) 方法返回
// true代表验证成功(仅验证码验证成功,不包括登录成功,关闭弹窗),false代表验证失败(不关闭弹窗)
if (res) {
// 后端校验通过,验证成功
isOK = true
}
})
if (isOK) {
// 关闭弹窗
onCancel()
// SliderCaptcha 组件 不需要再请求刷新验证码
return Promise.resolve()
}
// 刷新验证码
return Promise.reject(new Error("验证失败"))
}
// antd 弹窗组件属性
const modelDefaultProps = {
title: "安全验证",
zIndex: 1024,
style: {
maxWidth: "100%",
},
styles: {
content: {
padding: 20
}
},
centered: true,
width: 430,
footer: false,
destroyOnClose: true,
...modalProps
}
return (
// Modal组件的属性,见:https://ant.design/components/modal-cn#api
<Modal
{...modelDefaultProps}
onCancel={onCancel}
open={open}
>
{/* SliderCaptcha的属性,详情见:https://www.npmjs.com/package/rc-slider-captcha */}
<SliderCaptcha
request={requestCaptcha}
onVerify={onVerifyCaptcha}
bgSize={bgSize}
tipText={{
default: '向右拖动完成拼图 👉',
loading: "👩🏻💻🧑💻努力中...",
moving: '继续向右拖动 →',
verifying: '正在验证...',
error: '验证失败,请重试',
success: '验证成功!'
}}
puzzleSize={{
width: puzzleWidth,
}}
style={{
"--rcsc-primary": "#6153FC",
"--rcsc-primary-light": "#efecfc"
}}
loadingDelay={300}
limitErrorCount={3}
{...sliderCaptchaProps}
/>
</Modal>
)
});
export default ModalSliderCaptcha;
3、登录页面传入组件ModalSliderCaptcha
部分函数逻辑,不可直接复制使用
// 弹窗关闭与否
const [modalSliderCaptchaOpen, setModalSliderCaptchaOpen] = useState<boolean>(false)
// 关闭弹窗
const onCancel = () => {
setModalSliderCaptchaOpen(false)
}
// 登录信息
const [loginInfo, setLoginInfo] = useState<LoginInfo>();
// 向后端获取验证码内容(图片)
const getCaptcha = () => {
return request('getSliderCaptcha', {
method: 'POST',
});
}
// 登录
const submit = async (v: any) => {
// v 为登录参数 password 因为需要加密 所以需要重写、覆盖
const values = {
...v,
password: encodeStr(v.password, publicKey)
}
// 从其他地方调用登录逻辑(请求后端、验证登录成功与否、参数保存、页面跳转等等)
const { dispatch } = props;
// 判断是否关闭验证码弹窗(判断滑块验证码是否有效)
let flag = true;
// 登录校验
await dispatch({
type: 'login',
payload: { ...values, type },
}).then((f: boolean) => {
flag = f;
});
// 用于需要传给 ModalSliderCaptcha 是否需要关闭弹窗、是否需要重新请求获取验证码
return Promise.resolve(flag);
};
// antd登录组件
// 从上往下数 第一个...是 ProForm 的组件参数
// 从上往下数 第二个...是 登录id 登录密码 等组件
<ProForm
...
// onFinish 为 用户点击登录时的操作
// values 为 登录id 登录密码 等组件的输入值
onFinish={(values: any) => {
// 打开自定义的弹窗滑动验证码组件
setModalSliderCaptchaOpen(true)
// 保存登录时 用户输入的信息
setLoginInfo(values)
return Promise.resolve();
}}
>
...
</ProForm>
// 自定义的弹窗滑动验证码组件
<ModalSliderCaptcha open={modalSliderCaptchaOpen} onCancel={onCancel} request={getCaptcha} loginData={loginInfo!} onVerify={submit} />
后端
本文基于 Springboot 手搓 后端 滑块验证码生成 做改动和优化,非常感谢
一、思路
1、后端需要生成对应的两个图片(拼图图片和拼图背景图片,图片内存尽量小一点)和对应位置(x和y, 等高拼图只需要记录x即可);
2、验证码生成服务,生成唯一的标识uuid(可考虑雪花算法生成),将生成图片生成后得到的位置信息即x(非登高拼图x和y)记录到缓存中,建议使用redis存储,即使分布式也能使用;
3、将验证码数据返回给前端,格式参考如下:
{
"msg": "成功",
"obj": {
"bgUrl": "data:image/png;base64,img信息",
"puzzleUrl": "data:image/png;base64,img信息",
"uuid": "e8190fee-9787-44cd-a241-0021f61aa5f3"
},
"code": "0000"
}
二、原理解析
要想生成拼图形状的拼图,我们需要运用到一些数学知识,核心代码如下:
通过 圆的标准方程 (x-a)²+(y-b)²=r²,标识圆心(a,b),半径为r的圆,>=的在外侧,<的内侧。
简单来看就是这样的一个模型:
三、代码
1、获取验证码逻辑,不可直接复制
public Results<GetCaptchaVo> getCaptcha() {
try {
// 获取滑块验证码图片
SliderCaptchaUtil.SliderCaptcha sliderCaptcha = SliderCaptchaUtil.generateCaptcha();
// 用于登录校验 存redis
String uuid = UUID.randomUUID().toString();
// x轴信息 存redis
redisUtil.set(RedisUtil.SLIDER_CAPTCHA + uuid, String.valueOf(sliderCaptcha.getX()), 120);
// 返回前端的信息 GetCaptchaVo 仅有三个参数 uuid、puzzleUrl、bgUrl
GetCaptchaVo getCaptchaVo = new GetCaptchaVo()
.setUuid(uuid)
.setPuzzleUrl(sliderCaptcha.getPuzzleImg())
.setBgUrl(sliderCaptcha.getBgImg());
return Results.success(getCaptchaVo);
} catch (Exception e) {
log.error("getCaptcha 报错", e);
return Results.failed(e.getMessage());
}
}
2、SliderCaptchaUtil工具类,可直接复制使用
/**
* 滑块验证码生成器 加 todo 的是需要改的
*/
@Slf4j
public class SliderCaptchaUtil {
/**
* 图片存储的目录 todo
*/
private static final String IMAGE_DIR = "classpath:static/img";
/**
* 图片格式
*/
private static final String IMG_FORMAT = "png";
/**
* base64前缀
*/
private static final String BASE64_PREFIX = "data:image/" + IMG_FORMAT + ";base64,";
/**
* 图片缓存
*/
private static final List<BufferedImage> cachedPngImages = new ArrayList<>();
/**
* 大图宽度(原图裁剪拼图后的背景图)
*/
private static final int width = 380;
/**
* 大图高度
*/
private static final int height = 200;
/**
* 小图宽度(滑块拼图),前端拼图的实际宽度:puzzleWidth + 2 * borderSize + 2
*/
private static final int puzzleWidth = 66;
/**
* 小图高度,前端拼图的实际高度:puzzleHeight + 2 * borderSize + 2
*/
private static final int puzzleHeight = 66;
/**
* 边框厚度
*/
private static final int borderSize = 1;
/**
* 小圆半径,即拼图上的凹凸轮廓半径
*/
private static final int radius = 8;
/**
* 图片一周预留的距离,randomR1最大值不能超过radius * 3 / 2
*/
private static final int distance = radius * 3 / 2;
/**
* 随机获取背景图
*/
public static BufferedImage randomBgImg() throws IOException {
try {
if (cachedPngImages.isEmpty()) {
// 使用Spring的资源解析器
ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource[] resources = resolver.getResources(IMAGE_DIR + "**/*." + IMG_FORMAT);
if (resources.length == 0) {
log.error("randomBgImg 在目录中找不到PNG文件: {}", IMAGE_DIR);
throw new IOException("在目录中找不到PNG文件: " + IMAGE_DIR);
}
// 读取所有图片到内存
for (Resource resource : resources) {
try (InputStream is = resource.getInputStream()) {
// 读取原始图片
BufferedImage originalImage = ImageIO.read(is);
// 调整尺寸(例如:调整为 width x height)
BufferedImage bufferedImage = ImageUtil.resizeImage(originalImage, width, height);
cachedPngImages.add(bufferedImage);
}
}
}
// 随机选择一张图片
int index = new Random().nextInt(cachedPngImages.size());
return cachedPngImages.get(index);
} catch (IOException e) {
log.error("randomBgImgPath 获取随机图片失败");
throw e;
}
}
/**
* 生成滑块验证码
*/
public static SliderCaptcha generateCaptcha() throws IOException {
BufferedImage bufferedImage = randomBgImg();
return generateCaptcha(bufferedImage);
}
/**
* 生成滑块验证码
*
* @param bgImg 1、传入随机背景图
* @return SliderCaptcha 验证码结果
* @throws IOException IO异常
*/
public static SliderCaptcha generateCaptcha(BufferedImage bgImg) throws IOException {
// 2、复制一份图片,在这张图片上面改动
bgImg = ImageUtil.deepCopy(bgImg);
// 3、随机生成离左上角的(X,Y)坐标,上限为 [width-puzzleWidth, height-puzzleHeight]。最好离大图左边远一点,上限不要紧挨着大图边界
Random random = new Random();
// X范围:[puzzleWidth, width - puzzleWidth)
int x = random.nextInt(width - 2 * puzzleWidth) + puzzleWidth;
// Y范围:[puzzleHeight, height - puzzleHeight)
int y = random.nextInt(height - 2 * puzzleHeight) + puzzleHeight;
// 4、创建拼图图像
BufferedImage puzzleImg = new BufferedImage(puzzleWidth, puzzleHeight, BufferedImage.TYPE_4BYTE_ABGR);
// 5、随机获取位置数据
int randomR1 = getRandomR1();
// 6、随机生成拼图轮廓数据
int[][] slideTemplateData = createTemplateData(randomR1);
// 7、从大图中裁剪拼图。抠原图,裁剪拼图
cutByTemplate(bgImg, puzzleImg, slideTemplateData, x, y);
// 8、给拼图加边框
puzzleImg = ImageUtil.addBorderWithOutline(puzzleImg, borderSize, Color.white);
// 9、设置拼图的高度
puzzleImg = reshapeAccordant(puzzleImg, y);
return new SliderCaptcha(ImageUtil.toBase64(bgImg),
ImageUtil.toBase64(puzzleImg), x);
}
/**
* 随机获取小圆距离点
*/
private static int getRandomR1() {
Integer[] r1List = new Integer[]{
radius * 3 / 2,
radius,
radius / 2,
};
int index = new Random().nextInt(r1List.length);
return r1List[index];
}
/**
* 随机生成拼图图轮廓数据
*
* @param randomR1 圆点距离随机值
* @return 0和1,其中0表示没有颜色,1有颜色
*/
private static int[][] createTemplateData(int randomR1) {
// 拼图轮廓数据
int[][] data = new int[puzzleWidth][puzzleHeight];
// 拼图去掉凹凸的白色距离
int xBlank = puzzleWidth - distance;
int yBlank = puzzleHeight - distance;
// 记录圆心的位置值
int topOrBottomX = puzzleWidth / 2;
int leftOrRightY = puzzleHeight / 2;
// 凹时对应的位置
int topYOrLeftX = distance - randomR1 + radius;
int rightX = puzzleWidth - topYOrLeftX;
int bottomY = puzzleHeight - topYOrLeftX;
// 凸时对应的位置
int topYOrLeftXR = distance + randomR1 - radius;
int rightXR = puzzleWidth - topYOrLeftXR;
int bottomYR = puzzleHeight - topYOrLeftXR;
double rPow = Math.pow(radius, 2);
/* 随机获取判断条件 */
Random random = new Random();
Integer[] randomCondition = new Integer[]{
random.nextInt(3),
random.nextInt(3),
random.nextInt(3),
random.nextInt(3)
};
/*
计算需要的拼图轮廓(方块和凹凸),用二维数组来表示,二维数组有两张值,0和1,其中0表示没有颜色,1有颜色
圆的标准方程 (x-a)²+(y-b)²=r²,标识圆心(a,b),半径为r的圆
*/
for (int i = 0; i < puzzleWidth; i++) {
for (int j = 0; j < puzzleHeight; j++) {
/* 凹时对应的圆点 */
// 顶部的圆心位置为(puzzleWidth / 2, topYOrLeftX)
double top = Math.pow(i - topOrBottomX, 2) + Math.pow(j - topYOrLeftX, 2);
// 底部的圆心位置为(puzzleWidth / 2, puzzleHeight - topYOrLeftX)
double bottom = Math.pow(i - topOrBottomX, 2) + Math.pow(j - bottomY, 2);
// 左侧的圆心位置为(topYOrLeftX, puzzleHeight / 2)
double left = Math.pow(i - topYOrLeftX, 2) + Math.pow(j - leftOrRightY, 2);
// 右侧的圆心位置为(puzzleWidth - topYOrLeftX, puzzleHeight / 2)
double right = Math.pow(i - rightX, 2) + Math.pow(j - leftOrRightY, 2);
/* 凸时对应的圆点 */
// 顶部的圆心位置为(puzzleWidth / 2, topYOrLeftXR)
double topR = Math.pow(i - topOrBottomX, 2) + Math.pow(j - topYOrLeftXR, 2);
// 底部的圆心位置为(puzzleWidth / 2, puzzleHeight - topYOrLeftXR)
double bottomR = Math.pow(i - topOrBottomX, 2) + Math.pow(j - bottomYR, 2);
// 左侧的圆心位置为(topYOrLeftXR, puzzleHeight / 2)
double leftR = Math.pow(i - topYOrLeftXR, 2) + Math.pow(j - leftOrRightY, 2);
// 右侧的圆心位置为(puzzleWidth - topYOrLeftXR, puzzleHeight / 2)
double rightR = Math.pow(i - rightXR, 2) + Math.pow(j - leftOrRightY, 2);
/* 随机获取条件 */
Boolean[][] conditions = new Boolean[][]{
new Boolean[]{
(j <= distance && topR >= rPow),
(j <= distance || top <= rPow),
(j <= distance)
},
new Boolean[]{
(j >= yBlank && bottomR >= rPow),
(j >= yBlank || bottom <= rPow),
(j >= yBlank)
},
new Boolean[]{
(i <= distance && leftR >= rPow),
(i <= distance || left <= rPow),
(i <= distance)
},
new Boolean[]{
(i >= xBlank && rightR >= rPow),
(i >= xBlank || right <= rPow),
(i >= xBlank)
}
};
boolean hide = false;
for (int c = 0; c < randomCondition.length; c++) {
if (conditions[c][randomCondition[c]]) {
hide = true;
break;
}
}
if (hide) {
// 不显示的像素
data[i][j] = 0;
} else {
data[i][j] = 1;
}
}
}
return data;
}
/**
* 裁剪拼图
*
* @param bgImg - 原图规范大小之后的大图
* @param puzzleImg - 小图
* @param slideTemplateData - 拼图轮廓数据
* @param x - 坐标x
* @param y - 坐标y
*/
private static void cutByTemplate(BufferedImage bgImg, BufferedImage puzzleImg, int[][] slideTemplateData, int x, int y) {
int[][] matrix = new int[3][3];
int[] values = new int[9];
// 虚假的x坐标
int fakeX = getRandomFakeX(x);
// 虚假的y坐标
int fakeY = getRandomFakeY(y);
// 创建shape区域,即原图抠图区域模糊和抠出小图
/*
遍历小图轮廓数据,创建shape区域。即原图抠图处模糊和抠出小图
*/
for (int i = 0; i < puzzleImg.getWidth(); i++) {
for (int j = 0; j < puzzleImg.getHeight(); j++) {
// 获取大图中对应位置变色
int rgb_ori = bgImg.getRGB(x + i, y + j);
// 0和1,其中0表示没有颜色,1有颜色
int rgb = slideTemplateData[i][j];
if (rgb == 1) {
// 设置小图中对应位置变色
puzzleImg.setRGB(i, j, rgb_ori);
// 大图抠图区域高斯模糊
readPixel(bgImg, x + i, y + j, values);
fillMatrix(matrix, values);
bgImg.setRGB(x + i, y + j, avgMatrix(matrix, false));
// 抠虚假图
readPixel(bgImg, fakeX + i, fakeY + j, values);
fillMatrix(matrix, values);
bgImg.setRGB(fakeX + i, fakeY + j, avgMatrix(matrix, true));
} else {
// 这里把背景设为透明
puzzleImg.setRGB(i, j, rgb_ori & 0x00ffffff);
}
}
}
}
/**
* 随机获取虚假x坐标的值
*
* @param x 真正的x坐标
* @return fakeX
*/
private static int getRandomFakeX(int x) {
int puzzleRealWidth = puzzleWidth + 2 * borderSize + 2;
// 计算左右有效范围
int leftStart = puzzleRealWidth;
int leftEnd = x - puzzleRealWidth;
int leftAvailable = Math.max(0, leftEnd - leftStart + 1);
int rightStart = x + puzzleRealWidth;
int rightEnd = width - puzzleRealWidth;
int rightAvailable = Math.max(0, rightEnd - rightStart + 1);
int totalAvailable = leftAvailable + rightAvailable;
if (totalAvailable == 0) {
throw new IllegalArgumentException("无法生成不重叠的位置");
}
Random rand = new Random();
int randomChoice = rand.nextInt(totalAvailable);
// 确定最终生成的fakeX
if (randomChoice < leftAvailable) {
return leftStart + randomChoice;
} else {
return rightStart + (randomChoice - leftAvailable);
}
}
/**
* 随机获取虚假y坐标的值
*
* @param y 真正的y坐标
* @return fakeY
*/
private static int getRandomFakeY(int y) {
int puzzleRealHeight = puzzleHeight + 2 * borderSize + 2;
Random random = new Random();
int fakeY = random.nextInt(height - 2 * puzzleRealHeight) + puzzleRealHeight;
if (Math.abs(fakeY - y) <= puzzleRealHeight) {
fakeY = height - y;
}
return fakeY;
}
/**
* 通过拼图图片生成登高拼图图片
*
* @param puzzleImg 拼图图片
* @param offsetY 随机生成的y
* @return 登高拼图图片
*/
private static BufferedImage reshapeAccordant(BufferedImage puzzleImg, int offsetY) {
BufferedImage puzzleBlankImg = new BufferedImage(puzzleWidth + 2 * borderSize + 2, height, BufferedImage.TYPE_4BYTE_ABGR);
Graphics2D graphicsPuzzle = puzzleBlankImg.createGraphics();
graphicsPuzzle.drawImage(puzzleImg, 1, offsetY, null);
graphicsPuzzle.dispose();
return puzzleBlankImg;
}
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 int avgMatrix(int[][] matrix, boolean fake) {
// 假图片要比真图片颜色深
int colorChange = 10;
if (fake) {
colorChange = 20;
}
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 / colorChange, g / colorChange, b / colorChange).getRGB();
}
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++];
}
}
}
/**
* 滑动验证码输出实体类
*/
@Getter
@ToString
public static class SliderCaptcha {
/**
* 验证码背景图
*/
private final String bgImg;
/**
* 验证码滑块
*/
private final String puzzleImg;
/**
* 验证码正确的x位置(此值需自行存入缓存,用于验证码判断)
*/
private final int x;
public SliderCaptcha(String bgImg, String puzzleImg, int x) {
this.bgImg = bgImg;
this.puzzleImg = puzzleImg;
this.x = x;
}
}
/**
* 图片处理工具
*/
static class ImageUtil {
/**
* 添加带轮廓的边框
*
* @param image
* @param borderWidth
* @param borderColor
* @return
*/
public static BufferedImage addBorderWithOutline(BufferedImage image, int borderWidth, Color borderColor) {
// 创建新图像,尺寸扩大以容纳边框
BufferedImage result = new BufferedImage(
image.getWidth() + borderWidth * 2,
image.getHeight() + borderWidth * 2,
BufferedImage.TYPE_INT_ARGB
);
Graphics2D g2d = result.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 获取图像的非透明区域
Area area = new Area();
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
if ((image.getRGB(x, y) >> 24) != 0x00) {
area.add(new Area(new Rectangle(x, y, 1, 1)));
}
}
}
// 绘制边框
g2d.setColor(borderColor);
g2d.setStroke(new BasicStroke(borderWidth * 2));
g2d.translate(borderWidth, borderWidth);
g2d.draw(area);
// 绘制原始图像
g2d.drawImage(image, 0, 0, null);
g2d.dispose();
return result;
}
/**
* 图片转Base64
*
* @param image
* @return
* @throws IOException
*/
public static String toBase64(BufferedImage image) throws IOException {
// 创建一个字节数组输出流
ByteArrayOutputStream os = new ByteArrayOutputStream();
// 将BufferedImage写入到输出流中,这里指定图片格式为"png"或"jpg"等
ImageIO.write(image, IMG_FORMAT, os);
// 将输出流的字节数组转换为Base64编码的字符串
String imageBase64 = Base64.getEncoder().encodeToString(os.toByteArray());
// 关闭输出流
os.close();
return BASE64_PREFIX + imageBase64;
}
/**
* 调整 BufferedImage 的尺寸
*
* @param originalImage 原始图片
* @param targetWidth 目标宽度
* @param targetHeight 目标高度
* @return 调整后的 BufferedImage
*/
public static BufferedImage resizeImage(BufferedImage originalImage, int targetWidth, int targetHeight) {
// 创建新的 BufferedImage
BufferedImage resizedImage = new BufferedImage(
targetWidth,
targetHeight,
originalImage.getType() == 0 ? BufferedImage.TYPE_INT_ARGB : originalImage.getType()
);
// 获取 Graphics2D 对象进行缩放绘制
Graphics2D g2d = resizedImage.createGraphics();
g2d.drawImage(
originalImage,
0, 0, targetWidth, targetHeight,
null
);
g2d.dispose(); // 释放资源
return resizedImage;
}
/**
* 深度复制 BufferedImage
*
* @param original 原始图片
* @return 复制后的 BufferedImage
*/
public static BufferedImage deepCopy(BufferedImage original) {
// 创建一个新的 BufferedImage,类型与原图相同
BufferedImage copy = new BufferedImage(
original.getWidth(),
original.getHeight(),
original.getType()
);
// 绘制原图到新图
copy.getGraphics().drawImage(original, 0, 0, null);
return copy;
}
}
}
3、登录验证码校验逻辑、不可直接复制
/**
* 校验滑块验证码
*/
private boolean verifySliderCaptcha(String uuid, BigDecimal x) {
// 从 Redis 获取存储的滑块验证码坐标
Object o = redisUtil.get(RedisUtil.SLIDER_CAPTCHA + uuid);
// 如果 Redis 中没有记录,验证失败
if (o == null) {
return false;
}
try {
String redisX = (String) o;
// 将 redisX 转换为 BigDecimal 进行比较
BigDecimal storedX = new BigDecimal(redisX);
// 计算差值的绝对值
BigDecimal difference = x.subtract(storedX).abs();
// 判断差值是否小于 10
return difference.compareTo(BigDecimal.TEN) < 0;
} catch (NumberFormatException e) {
// 如果 redisX 不是有效的数字格式,验证失败
return false;
}
}