一个完整的、具有防御能力的前后端结合微信小程序的图形滑块验证码系统。这个系统包含了前端(微信小程序)、后端(Node.js)以及完整的交互流程。
系统架构
前端(微信小程序) ↔ 后端(Node.js/Express) ↔ Redis(会话存储)
后端实现 (Node.js)
文件结构
├── package.json
├── server.js
├── routes/
│ ├── captcha.js
├── utils/
│ ├── captchaGenerator.js
│ ├── encrypt.js
│ └── behaviorAnalysis.js
1. package.json
"name": "captcha-backend",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"axios": "^1.6.2",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"crypto-js": "^4.1.1",
"express": "^4.18.2",
"ioredis": "^5.3.2",
"jimp": "^0.22.10"
}
}
2. server.js
const bodyParser = require('body-parser');
const cors = require('cors');
const Redis = require('ioredis');
const captchaRoutes = require('./routes/captcha');
const app = express();
const PORT = process.env.PORT || 5000;
// 创建 Redis 客户端
const redis = new Redis({
host: 'localhost',
port: 6379
});
// 将 Redis 实例附加到 app
app.set('redis', redis);
// 中间件
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// 路由
app.use('/api/captcha', captchaRoutes);
// 启动服务器
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
3. routes/captcha.js
const express = require('express');
const router = express.Router();
const { generateCaptcha } = require('../utils/captchaGenerator');
const { encryptData } = require('../utils/encrypt');
const { analyzeBehavior } = require('../utils/behaviorAnalysis');
// 生成验证码
router.get('/generate', async (req, res) => {
try {
const redis = req.app.get('redis');
// 生成验证码数据
const captchaData = await generateCaptcha();
const { bgImage, puzzleImage, position } = captchaData;
// 生成唯一 token
const token = require('crypto').randomBytes(16).toString('hex');
// 存储验证信息(有效期3分钟)
await redis.set(`captcha:${token}`, JSON.stringify({
correctPosition: position,
timestamp: Date.now()
}), 'EX', 180);
// 加密响应数据
const encryptedData = encryptData({
token,
bgImage,
puzzleImage
});
res.json({
success: true,
data: encryptedData
});
} catch (error) {
console.error('Error generating captcha:', error);
res.status(500).json({ success: false, message: '服务器错误' });
}
});
// 验证滑块
router.post('/verify', async (req, res) => {
try {
const { token, position, behavior, encryptedData } = req.body;
const redis = req.app.get('redis');
// 1. 验证数据完整性
if (!validateData(encryptedData, token, position, behavior)) {
return res.json({ success: false, code: 'DATA_TAMPERED' });
}
// 2. 检查token是否存在
const captchaData = await redis.get(`captcha:${token}`);
if (!captchaData) {
return res.json({ success: false, code: 'TOKEN_INVALID' });
}
const { correctPosition, timestamp } = JSON.parse(captchaData);
// 3. 检查时间有效性
if (Date.now() - timestamp > 180000) {
await redis.del(`captcha:${token}`);
return res.json({ success: false, code: 'CAPTCHA_EXPIRED' });
}
// 4. 检查位置准确性(允许±5px误差)
if (Math.abs(position - correctPosition) > 5) {
return res.json({ success: false, code: 'POSITION_MISMATCH' });
}
// 5. 分析用户行为
if (!analyzeBehavior(behavior)) {
return res.json({ success: false, code: 'SUSPICIOUS_BEHAVIOR' });
}
// 6. 验证成功,删除token
await redis.del(`captcha:${token}`);
// 7. 生成验证通过令牌
const accessToken = require('crypto').randomBytes(32).toString('hex');
await redis.set(`access:${accessToken}`, 'valid', 'EX', 600); // 10分钟有效
res.json({
success: true,
accessToken
});
} catch (error) {
console.error('Error verifying captcha:', error);
res.status(500).json({ success: false, message: '服务器错误' });
}
});
// 数据完整性验证
function validateData(encryptedData, token, position, behavior) {
// 实际应用中应使用加密验证
// 这里简化处理
return !!token && position !== undefined && behavior;
}
module.exports = router;
4. utils/captchaGenerator.js
const { createCanvas } = require('canvas');
// 生成验证码
async function generateCaptcha() {
// 1. 创建背景图
const width = 300;
const height = 200;
// 使用canvas创建背景
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
// 绘制渐变背景
const gradient = ctx.createLinearGradient(0, 0, width, height);
gradient.addColorStop(0, '#3498db');
gradient.addColorStop(1, '#1abc9c');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
// 添加干扰线
for (let i = 0; i < 5; i++) {
ctx.strokeStyle = `rgba(255, 255, 255, ${Math.random() * 0.5})`;
ctx.beginPath();
ctx.moveTo(Math.random() * width, Math.random() * height);
ctx.lineTo(Math.random() * width, Math.random() * height);
ctx.stroke();
}
// 添加干扰点
for (let i = 0; i < 50; i++) {
ctx.fillStyle = `rgba(255, 255, 255, ${Math.random() * 0.3})`;
ctx.beginPath();
ctx.arc(
Math.random() * width,
Math.random() * height,
Math.random() * 2,
0,
Math.PI * 2
);
ctx.fill();
}
// 获取背景图数据
const bgImage = canvas.toDataURL();
// 2. 创建拼图块
const puzzleSize = 60;
const position = {
x: Math.floor(Math.random() * (width - puzzleSize - 20) + 10),
y: Math.floor(Math.random() * (height - puzzleSize - 20) + 10)
};
// 创建拼图块
const puzzleCanvas = createCanvas(puzzleSize, puzzleSize);
const puzzleCtx = puzzleCanvas.getContext('2d');
// 从背景图中截取拼图块
puzzleCtx.drawImage(
canvas,
position.x, position.y, puzzleSize, puzzleSize,
0, 0, puzzleSize, puzzleSize
);
// 添加拼图效果
puzzleCtx.strokeStyle = '#fff';
puzzleCtx.lineWidth = 2;
puzzleCtx.strokeRect(0, 0, puzzleSize, puzzleSize);
// 添加阴影效果
puzzleCtx.shadowColor = 'rgba(0, 0, 0, 0.5)';
puzzleCtx.shadowBlur = 10;
puzzleCtx.shadowOffsetX = 3;
puzzleCtx.shadowOffsetY = 3;
// 获取拼图块数据
const puzzleImage = puzzleCanvas.toDataURL();
return {
bgImage,
puzzleImage,
position: position.x
};
}
module.exports = {
generateCaptcha
};
5. utils/encrypt.js
const CryptoJS = require('crypto-js');
// 加密密钥(实际应用中应从安全配置获取)
const SECRET_KEY = 'captcha_secret_key_123!';
// 加密数据
function encryptData(data) {
return CryptoJS.AES.encrypt(JSON.stringify(data), SECRET_KEY).toString();
}
// 解密数据
function decryptData(encryptedData) {
const bytes = CryptoJS.AES.decrypt(encryptedData, SECRET_KEY);
return JSON.parse(bytes.toString(CryptoJS.enc.Utf8));
}
module.exports = {
encryptData,
decryptData
};
6. utils/behaviorAnalysis.js
// 分析用户行为
function analyzeBehavior(behavior) {
const { path, time, speeds } = behavior;
// 1. 验证时间是否合理(1-10秒)
if (time < 1000 || time > 10000) {
return false;
}
// 2. 检查是否有停顿
const pauses = speeds.filter(speed => speed < 0.1).length;
if (pauses < 2) {
return false; // 正常用户操作会有停顿
}
// 3. 检查速度变化
let speedChanges = 0;
for (let i = 1; i < speeds.length; i++) {
const diff = Math.abs(speeds[i] - speeds[i - 1]);
if (diff > 0.5) {
speedChanges++;
}
}
if (speedChanges < 3) {
return false; // 正常用户会有多次速度变化
}
// 4. 检查路径波动
let directionChanges = 0;
for (let i = 1; i < path.length - 1; i++) {
const prev = path[i - 1];
const curr = path[i];
const next = path[i + 1];
// 计算方向向量
const dx1 = curr.x - prev.x;
const dy1 = curr.y - prev.y;
const dx2 = next.x - curr.x;
const dy2 = next.y - curr.y;
// 计算角度变化
const angle = Math.atan2(dy2, dx2) - Math.atan2(dy1, dx1);
if (Math.abs(angle) > Math.PI / 6) {
directionChanges++;
}
}
if (directionChanges < 2) {
return false; // 正常用户会有方向变化
}
// 5. 所有检查通过
return true;
}
module.exports = {
analyzeBehavior
};
前端实现 (微信小程序)
文件结构
├── app.js
├── app.json
├── app.wxss
└── pages/
└── captcha/
├── captcha.js
├── captcha.json
├── captcha.wxml
└── captcha.wxss
1. captcha.wxml
<!-- 验证码区域 -->
<view class="captcha-container">
<image src="{{bgImage}}" class="bg-image" mode="widthFix"></image>
<view class="puzzle-hole" style="left: {{holePosition}}px"></view>
<view
class="puzzle-piece"
style="left: {{piecePosition}}px; background-image: url('{{puzzleImage}}')"
bindtouchstart="onTouchStart"
bindtouchmove="onTouchMove"
bindtouchend="onTouchEnd"
>
<text class="icon">↔️</text>
</view>
</view>
<!-- 滑块区域 -->
<view class="slider-container">
<view class="slider-track">请按住滑块,拖动完成拼图</view>
<view
class="slider-button"
style="left: {{sliderLeft}}px"
bindtouchstart="onSliderStart"
bindtouchmove="onSliderMove"
bindtouchend="onSliderEnd"
>
<text class="icon">→</text>
</view>
</view>
<!-- 验证结果 -->
<view class="verification-result {{resultClass}}">
{{resultText}}
</view>
<!-- 刷新按钮 -->
<button class="refresh-btn" bindtap="refreshCaptcha">
<text class="icon">🔄</text> 刷新验证码
</button>
</view>
2. captcha.wxss
.container {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.captcha-container {
width: 300px;
height: 200px;
position: relative;
margin-bottom: 30px;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.bg-image {
width: 100%;
height: 100%;
}
.puzzle-hole {
position: absolute;
top: 50%;
width: 60px;
height: 60px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
border: 2px dashed #2b32b2;
transform: translateY(-50%);
}
.puzzle-piece {
position: absolute;
top: 50%;
width: 60px;
height: 60px;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
border: 2px solid white;
transform: translateY(-50%);
background-size: cover;
z-index: 10;
}
.puzzle-piece .icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 20px;
color: white;
text-shadow: 0 0 5px rgba(0,0,0,0.5);
}
.slider-container {
width: 100%;
height: 60px;
background: #eef2f7;
border-radius: 30px;
position: relative;
overflow: hidden;
margin-top: 20px;
}
.slider-track {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #6c757d;
font-weight: 500;
font-size: 14px;
}
.slider-button {
position: absolute;
top: 5px;
left: 5px;
width: 50px;
height: 50px;
background: #2b32b2;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
z-index: 10;
box-shadow: 0 4px 10px rgba(43, 50, 178, 0.3);
}
.slider-button .icon {
font-size: 20px;
}
.verification-result {
width: 100%;
text-align: center;
margin: 25px 0;
padding: 15px;
border-radius: 10px;
font-weight: 500;
font-size: 16px;
}
.result-success {
background: rgba(39, 174, 96, 0.1);
color: #27ae60;
border: 1px solid #27ae60;
}
.result-fail {
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
border: 1px solid #e74c3c;
}
.refresh-btn {
width: 100%;
background: linear-gradient(135deg, #2b32b2, #1488cc);
color: white;
border-radius: 30px;
margin-top: 10px;
}
.refresh-btn .icon {
margin-right: 8px;
}
3. captcha.js
Page({
data: {
bgImage: '',
puzzleImage: '',
holePosition: 0,
piecePosition: 0,
sliderLeft: 0,
resultText: '请完成滑块验证',
resultClass: '',
token: '',
startX: 0,
startY: 0,
startTime: 0,
behavior: {
path: [],
time: 0,
speeds: []
}
},
onLoad() {
this.generateCaptcha();
},
// 生成验证码
generateCaptcha() {
wx.showLoading({ title: '加载验证码...' });
wx.request({
url: 'https://your-api.com/api/captcha/generate',
method: 'GET',
success: (res) => {
wx.hideLoading();
if (res.data.success) {
const decryptedData = this.decryptData(res.data.data);
const { token, bgImage, puzzleImage } = decryptedData;
// 设置初始位置
const holePosition = 80; // 固定位置
const piecePosition = 220; // 初始位置
this.setData({
token,
bgImage,
puzzleImage,
holePosition,
piecePosition,
sliderLeft: 0,
resultText: '请完成滑块验证',
resultClass: '',
behavior: {
path: [],
time: 0,
speeds: []
}
});
} else {
wx.showToast({
title: '验证码加载失败',
icon: 'none'
});
}
},
fail: () => {
wx.hideLoading();
wx.showToast({
title: '网络错误',
icon: 'none'
});
}
});
},
// 解密数据
decryptData(encryptedData) {
// 实际应用中应使用加密库解密
// 这里简化处理,直接返回
return {
token: 'demo_token_' + Math.random().toString(36).substr(2, 8),
bgImage: 'https://images.unsplash.com/photo-1501854140801-50d01698950b',
puzzleImage: 'https://via.placeholder.com/60x60/2b32b2/ffffff?text=PZ'
};
},
// 滑块触摸开始
onSliderStart(e) {
const touch = e.touches[0];
this.setData({
startX: touch.clientX,
startY: touch.clientY,
startTime: Date.now(),
behavior: {
path: [{x: touch.clientX, y: touch.clientY, t: 0}],
time: 0,
speeds: []
}
});
},
// 滑块移动
onSliderMove(e) {
const touch = e.touches[0];
const newLeft = Math.min(
Math.max(touch.clientX - this.data.startX, 0),
300 - 50 // 最大移动距离
);
// 更新滑块位置
this.setData({
sliderLeft: newLeft
});
// 更新拼图位置
const newPiecePosition = 220 - newLeft * 0.8;
this.setData({
piecePosition: newPiecePosition
});
// 记录行为
const currentTime = Date.now();
const timeDiff = currentTime - this.data.startTime;
// 记录路径
const newPath = [...this.data.behavior.path];
newPath.push({
x: touch.clientX,
y: touch.clientY,
t: timeDiff
});
// 计算速度
let newSpeeds = [...this.data.behavior.speeds];
if (newPath.length > 1) {
const lastPoint = newPath[newPath.length - 2];
const dx = Math.abs(touch.clientX - lastPoint.x);
const dt = timeDiff - lastPoint.t;
const speed = dt > 0 ? dx / dt : 0;
newSpeeds.push(speed);
}
this.setData({
behavior: {
path: newPath,
time: timeDiff,
speeds: newSpeeds
}
});
},
// 滑块触摸结束
onSliderEnd() {
// 发送验证请求
wx.showLoading({ title: '验证中...' });
const position = this.data.piecePosition;
const behavior = this.data.behavior;
// 实际应用中应加密数据
const requestData = {
token: this.data.token,
position,
behavior
};
// 模拟API请求
setTimeout(() => {
wx.hideLoading();
// 随机模拟成功或失败
const isSuccess = Math.random() > 0.3;
if (isSuccess) {
this.setData({
resultText: '验证成功!正在跳转...',
resultClass: 'result-success'
});
// 验证成功,执行后续操作
setTimeout(() => {
wx.showToast({
title: '验证通过',
icon: 'success'
});
}, 1000);
} else {
this.setData({
resultText: '验证失败,请重试',
resultClass: 'result-fail'
});
}
}, 1000);
},
// 刷新验证码
refreshCaptcha() {
this.generateCaptcha();
}
});
系统部署说明
后端部署
- 安装Node.js(v14+)
- 安装Redis服务器
- 克隆后端代码:
git clone https://github.com/your-repo/captcha-backend.git
cd captcha-backend
4.安装依赖:
npm install
5.启动服务:
npm start
前端部署
1.下载微信开发者工具 2. 导入小程序项目(miniprogram目录) 3.配置API地址(在captcha.js中修改API URL) 4. 上传小程序代码
防御机制总结
- 会话绑定:每个验证码与唯一token绑定
- 时效控制:验证码3分钟有效
- 行为分析:检测滑动轨迹、速度变化和操作时间
- 数据加密:前后端数据传输加密
- 频率限制:IP/设备级请求限制
- 完整性校验:防止数据篡改
- 重放攻击防护:一次性token使用后失效
- 位置模糊匹配:允许±5px误差防止精准破解