图形滑块验证码

0 阅读7分钟

一个完整的、具有防御能力的前后端结合微信小程序的图形滑块验证码系统。这个系统包含了前端(微信小程序)、后端(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();
  }
});

系统部署说明

后端部署

  1. 安装Node.js(v14+)
  2. 安装Redis服务器
  3. 克隆后端代码:
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. 上传小程序代码

防御机制总结

  1. 会话绑定:每个验证码与唯一token绑定
  2. 时效控制:验证码3分钟有效
  3. 行为分析:检测滑动轨迹、速度变化和操作时间
  4. 数据加密:前后端数据传输加密
  5. 频率限制:IP/设备级请求限制
  6. 完整性校验:防止数据篡改
  7. 重放攻击防护:一次性token使用后失效
  8. 位置模糊匹配:允许±5px误差防止精准破解
这个系统提供了完整的防御型滑块验证码实现,能够有效抵御自动化攻击和恶意破解。实际部署时,可以根据业务需求调整安全策略的严格程度