430 lines
15 KiB
Markdown
430 lines
15 KiB
Markdown
# 滑块验证码系统使用指南
|
||
|
||
## 概述
|
||
|
||
滑块验证码是一种基于用户交互行为的验证机制,通过让用户拖动滑块到指定位置来判断操作是否为真人发起。本系统包含三个核心部分:
|
||
|
||
1. **核心工具模块** (`utils/slider_captcha.py`) - 验证码生成、位置验证、轨迹分析
|
||
2. **API接口层** (`user/views/slider_captcha.py`) - 提供RESTful API供前端调用
|
||
3. **前端交互组件** (`static/js/slider-captcha.js`) - 用户拖拽交互界面
|
||
|
||
## 系统架构
|
||
|
||
```
|
||
┌─────────────────┐ GET /slider-captcha/generate/ ┌─────────────────┐
|
||
│ 前端浏览器 │ ─────────────────────────────────────> │ Django后端 │
|
||
│ (HTML/Canvas) │ │ (APIView) │
|
||
│ │ 返回: {captcha_key, bg_image, │ │
|
||
│ │ slider_image, y_position} │ │
|
||
└────────┬────────┘ <──────────────────────────────────────┴────────┬────────┘
|
||
│ │
|
||
│ 用户拖拽滑块 │ Redis缓存
|
||
│ │ captcha_key →
|
||
│ POST /slider-captcha/verify/ │ {x_position,
|
||
│ {captcha_key, x_position, trajectory: [...]} │ timestamp}
|
||
│ │
|
||
v v
|
||
┌─────────────────┐ ─────────────────────────────────────> ┌─────────────────┐
|
||
│ 前端浏览器 │ 验证结果: {verified: true/false} │ Django后端 │
|
||
│ (JS组件) │ │ (utils模块) │
|
||
│ │ <────────────────────────────────────── │ 位置验证 │
|
||
│ │ │ 轨迹分析 │
|
||
└─────────────────┘ └─────────────────┘
|
||
```
|
||
|
||
## API 接口文档
|
||
|
||
### 1. 生成滑块验证码
|
||
|
||
**请求**
|
||
```
|
||
GET /user/slider-captcha/generate/
|
||
Content-Type: application/json
|
||
```
|
||
|
||
**响应 (200 OK)**
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": {
|
||
"captcha_key": "550e8400-e29b-41d4-a716-446655440000",
|
||
"bg_image": "data:image/png;base64,iVBORw0KGgoAAAANS...",
|
||
"slider_image": "data:image/png;base64,iVBORw0KGgoAAAANS...",
|
||
"y_position": 50
|
||
}
|
||
}
|
||
```
|
||
|
||
**字段说明**:
|
||
- `captcha_key`: 验证码唯一标识,用于后续验证请求。UUID格式
|
||
- `bg_image`: 背景图片。base64编码的PNG格式,data URI
|
||
- `slider_image`: 滑块图片。base64编码的PNG格式,需要用户拖动到背景图的凹槽位置
|
||
- `y_position`: 滑块在背景图中的垂直位置(像素)
|
||
|
||
---
|
||
|
||
### 2. 验证滑块位置
|
||
|
||
**请求**
|
||
```
|
||
POST /user/slider-captcha/verify/
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"captcha_key": "550e8400-e29b-41d4-a716-446655440000",
|
||
"x_position": 150,
|
||
"trajectory": [
|
||
{"x": 0, "y": 150, "t": 0},
|
||
{"x": 50, "y": 152, "t": 100},
|
||
{"x": 100, "y": 148, "t": 200},
|
||
{"x": 150, "y": 150, "t": 300}
|
||
]
|
||
}
|
||
```
|
||
|
||
**请求参数**:
|
||
- `captcha_key` (必填): 从generate接口获取的验证码标识
|
||
- `x_position` (必填, integer): 用户拖动滑块到的最终水平位置(像素)
|
||
- `trajectory` (可选, array): 拖拽过程轨迹点数组,用于机器人检测
|
||
- 每个轨迹点包含 `x` (水平位置), `y` (垂直位置), `t` (时间戳,毫秒)
|
||
|
||
**响应 (200 OK)**
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": {
|
||
"verified": true
|
||
}
|
||
}
|
||
```
|
||
|
||
**错误响应 (400 Bad Request)**
|
||
```json
|
||
{
|
||
"code": 20022,
|
||
"message": "缺少必要参数",
|
||
"data": null
|
||
}
|
||
```
|
||
|
||
验证码过期错误响应:
|
||
```json
|
||
{
|
||
"code": 20021,
|
||
"message": "验证码已过期,请重新获取",
|
||
"data": null
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 后端验证逻辑详解
|
||
|
||
### 1. 位置验证
|
||
|
||
```python
|
||
from utils.slider_captcha import verify_slider_captcha
|
||
|
||
# 验证滑块位置
|
||
result = verify_slider_captcha(captcha_key, x_position)
|
||
# result: True (位置在误差范围内) 或 False (位置错误)
|
||
```
|
||
|
||
**机制**:
|
||
- 系统允许±5像素的误差容差(`TOLERANCE = 5`)
|
||
- 正确位置存储在Redis中,key格式为`slider_captcha_{captcha_key}`
|
||
- 验证成功后缓存立即删除(一次性使用)
|
||
- 验证码有效期为5分钟(`CAPTCHA_TIMEOUT = 300`秒)
|
||
|
||
### 2. 轨迹分析(防机器人)
|
||
|
||
```python
|
||
from utils.slider_captcha import verify_slider_trajectory
|
||
|
||
# 带轨迹验证
|
||
trajectory = [
|
||
{'x': 0, 'y': 150, 't': 0},
|
||
{'x': 50, 'y': 152, 't': 100},
|
||
{'x': 100, 'y': 148, 't': 200},
|
||
{'x': 150, 'y': 151, 't': 300},
|
||
]
|
||
result = verify_slider_trajectory(captcha_key, x_position, trajectory)
|
||
# result: True (轨迹符合人类操作特征) 或 False (轨迹可疑)
|
||
```
|
||
|
||
**检测原理**:
|
||
1. **速度变化检测** - 人类操作有自然的加速/减速,机器人操作速度恒定
|
||
2. **微小停顿检测** - 人类操作过程中常有毫秒级的微小停顿
|
||
3. **轨迹点数量** - 至少需要5个轨迹点(`TRAJECTORY_MIN_POINTS = 5`)
|
||
4. **时间范围检查** - 总操作时间不超过10秒(`TRAJECTORY_MAX_DURATION = 10000`毫秒)
|
||
|
||
---
|
||
|
||
## 前端集成指南
|
||
|
||
### 1. 引入静态资源
|
||
|
||
在需要滑块验证码的页面中引入:
|
||
|
||
```html
|
||
<link rel="stylesheet" href="{% static 'css/slider-captcha.css' %}">
|
||
<script src="{% static 'js/slider-captcha.js' %}"></script>
|
||
```
|
||
|
||
### 2. 添加HTML容器
|
||
|
||
```html
|
||
<div id="slider-captcha-container"></div>
|
||
```
|
||
|
||
### 3. 初始化组件
|
||
|
||
```javascript
|
||
const captcha = new SliderCaptcha({
|
||
containerId: 'slider-captcha-container',
|
||
apiUrl: '/user/slider-captcha/',
|
||
onSuccess: function(captchaKey) {
|
||
console.log('滑块验证成功:', captchaKey);
|
||
// 验证成功后,隐藏滑块验证码,提交表单
|
||
document.getElementById('captcha-key').value = captchaKey;
|
||
},
|
||
onError: function() {
|
||
console.log('滑块验证失败');
|
||
// 显示错误信息,1.5秒后自动刷新新验证码
|
||
},
|
||
onRefresh: function() {
|
||
console.log('用户点击刷新');
|
||
}
|
||
});
|
||
```
|
||
|
||
### 4. 在表单提交中使用
|
||
|
||
```javascript
|
||
document.getElementById('login-form').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
const formData = {
|
||
account: document.getElementById('account').value,
|
||
password: document.getElementById('password').value,
|
||
slider_captcha_key: captcha.captchaKey, // 从组件获取
|
||
slider_captcha_x: captcha.finalXPosition
|
||
};
|
||
|
||
const response = await fetch('/user/login/', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(formData)
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.code === 0) {
|
||
// 登录成功
|
||
window.location.href = '/dashboard/';
|
||
} else {
|
||
// 显示错误,刷新验证码
|
||
captcha.refresh();
|
||
}
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 在后端视图中集成滑块验证
|
||
|
||
### 1. 在现有视图中添加滑块验证码保护
|
||
|
||
参考 [user.py](file:///c:/Users/12914/Desktop/vscode/chunyu_project/user/views/user.py) 中的 `LoginView` 实现:
|
||
|
||
```python
|
||
from rest_framework.views import APIView
|
||
from rest_framework import status
|
||
|
||
from utils.captcha import check_captcha_required, record_failure, reset_failures
|
||
from utils.slider_captcha import verify_slider_captcha, SliderCaptchaError
|
||
from utils.response_codes import ResponseCode, create_standardized_error_response
|
||
|
||
|
||
class MyProtectedView(APIView):
|
||
permission_classes = [AllowAny]
|
||
|
||
def _get_identifier(self, request):
|
||
"""获取客户端标识(用于失败计数)"""
|
||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||
if x_forwarded_for:
|
||
return x_forwarded_for.split(',')[0].strip()
|
||
return request.META.get('REMOTE_ADDR')
|
||
|
||
def post(self, request):
|
||
operation = 'my_operation'
|
||
identifier = self._get_identifier(request)
|
||
|
||
# 步骤1: 检查是否需要滑块验证
|
||
if check_captcha_required(operation, identifier):
|
||
captcha_key = request.data.get('slider_captcha_key')
|
||
captcha_x = request.data.get('slider_captcha_x')
|
||
|
||
if not captcha_key or captcha_x is None:
|
||
return create_standardized_error_response(
|
||
code=ResponseCode.CAPTCHA_REQUIRED,
|
||
status_code=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
# 步骤2: 验证滑块
|
||
try:
|
||
verified = verify_slider_captcha(captcha_key, int(captcha_x))
|
||
if not verified:
|
||
record_failure(operation, identifier)
|
||
return create_standardized_error_response(
|
||
code=ResponseCode.CAPTCHA_ERROR,
|
||
status_code=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
except SliderCaptchaError:
|
||
return create_standardized_error_response(
|
||
code=ResponseCode.CAPTCHA_EXPIRED,
|
||
status_code=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
# 步骤3: 执行业务逻辑
|
||
result = do_something(request.data)
|
||
|
||
if result.success:
|
||
# 成功:重置失败计数
|
||
reset_failures(operation, identifier)
|
||
return create_standardized_response(
|
||
data=result.data,
|
||
code=ResponseCode.SUCCESS,
|
||
status_code=status.HTTP_200_OK
|
||
)
|
||
else:
|
||
# 失败:记录失败次数(下次可能需要验证码)
|
||
record_failure(operation, identifier)
|
||
return create_standardized_error_response(
|
||
code=ResponseCode.ERROR,
|
||
message=result.error_message,
|
||
status_code=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
```
|
||
|
||
### 2. 验证码触发阈值配置
|
||
|
||
在 `utils/captcha.py` 中定义了默认阈值:
|
||
|
||
```python
|
||
CAPTCHA_THRESHOLD = 3 # 失败3次后开始需要验证码
|
||
CAPTCHA_TRIGGER_TTL = 600 # 触发状态持续10分钟
|
||
```
|
||
|
||
---
|
||
|
||
## 安全特性总结
|
||
|
||
| 特性 | 说明 |
|
||
|-----|-----|
|
||
| **一次性使用** | 每个 `captcha_key` 验证后立即失效,防止重放攻击 |
|
||
| **过期机制** | 验证码有效期为5分钟,过期后需重新生成 |
|
||
| **位置容差** | 允许±5像素的误差,提升用户体验的同时保持安全性 |
|
||
| **轨迹分析** | 分析拖拽速度、停顿等特征,识别自动化脚本 |
|
||
| **失败计数** | 连续失败达到阈值后强制要求验证码,防止暴力破解 |
|
||
| **Redis存储** | 使用缓存存储验证码状态,高性能且易于扩展 |
|
||
|
||
---
|
||
|
||
## 测试指南
|
||
|
||
### 运行滑块验证码相关测试
|
||
|
||
```bash
|
||
cd chunyu_project
|
||
|
||
# 运行所有滑块验证码相关测试
|
||
python manage.py test user.tests.test_slider_captcha --keepdb -v 2
|
||
python manage.py test user.tests.test_slider_captcha_api --keepdb -v 2
|
||
python manage.py test user.tests.test_login_with_slider --keepdb -v 2
|
||
|
||
# 同时运行所有测试
|
||
python manage.py test user.tests.test_slider_captcha user.tests.test_slider_captcha_api user.tests.test_login_with_slider --keepdb -v 2
|
||
```
|
||
|
||
### 测试覆盖范围
|
||
|
||
| 测试类 | 测试数 | 覆盖内容 |
|
||
|-------|-------|---------|
|
||
| `TestGenerateSliderCaptcha` | 4 | 验证码生成、字段完整性、缓存存储 |
|
||
| `TestVerifySliderCaptcha` | 4 | 正确位置通过、错误位置拒绝、过期处理、容差验证 |
|
||
| `TestVerifySliderTrajectory` | 2 | 正常轨迹通过、可疑轨迹拒绝 |
|
||
| `TestSliderCaptchaAPIView` | 6 | API端点测试、请求格式验证、响应格式验证 |
|
||
| `TestLoginWithSliderCaptcha` | 7 | 登录集成测试、验证触发、成功失败流程 |
|
||
| **总计** | **24** | |
|
||
|
||
### 在测试中Mock滑块验证
|
||
|
||
当编写使用滑块验证码的功能测试时,可以使用 `unittest.mock` 来隔离验证逻辑:
|
||
|
||
```python
|
||
from django.test import TestCase
|
||
from unittest.mock import patch
|
||
from rest_framework.test import APIClient
|
||
|
||
|
||
class MyViewTest(TestCase):
|
||
def setUp(self):
|
||
self.client = APIClient()
|
||
|
||
@patch('utils.captcha.check_captcha_required')
|
||
@patch('utils.slider_captcha.verify_slider_captcha')
|
||
def test_with_valid_captcha(self, mock_verify, mock_check):
|
||
# 模拟需要验证码
|
||
mock_check.return_value = True
|
||
|
||
# 模拟验证通过
|
||
mock_verify.return_value = True
|
||
|
||
response = self.client.post('/user/my-endpoint/', {
|
||
'slider_captcha_key': 'test-key',
|
||
'slider_captcha_x': 150,
|
||
# 其他参数...
|
||
}, format='json')
|
||
|
||
# 断言verify_slider_captcha被正确调用
|
||
mock_verify.assert_called_once_with('test-key', 150)
|
||
```
|
||
|
||
---
|
||
|
||
## 故障排查
|
||
|
||
### 常见问题
|
||
|
||
**问题1: 验证码验证总是返回false**
|
||
- 检查 `x_position` 是否为整数类型(而非字符串)
|
||
- 确认 `captcha_key` 与生成时的值一致
|
||
- 检查滑块图片的实际位置与背景凹槽位置是否对应
|
||
|
||
**问题2: 验证时返回"验证码已过期"**
|
||
- 检查是否在5分钟内完成验证
|
||
- 检查是否已使用过该 `captcha_key`(一次性使用)
|
||
- 检查Redis缓存是否正常运行
|
||
|
||
**问题3: 测试中的patch不生效**
|
||
- 确保patch路径是 `utils.slider_captcha.verify_slider_captcha` 而非 `user.views.user.verify_slider_captcha`
|
||
- 使用 `format='json'` 参数发送POST请求
|
||
|
||
---
|
||
|
||
## 文件位置索引
|
||
|
||
| 模块 | 路径 |
|
||
|-----|-----|
|
||
| 核心工具 | [utils/slider_captcha.py](file:///c:/Users/12914/Desktop/vscode/chunyu_project/utils/slider_captcha.py) |
|
||
| API视图 | [user/views/slider_captcha.py](file:///c:/Users/12914/Desktop/vscode/chunyu_project/user/views/slider_captcha.py) |
|
||
| 登录集成 | [user/views/user.py](file:///c:/Users/12914/Desktop/vscode/chunyu_project/user/views/user.py) |
|
||
| URL配置 | [user/urls.py](file:///c:/Users/12914/Desktop/vscode/chunyu_project/user/urls.py) |
|
||
| 前端JS | [static/js/slider-captcha.js](file:///c:/Users/12914/Desktop/vscode/chunyu_project/static/js/slider-captcha.js) |
|
||
| 前端CSS | [static/css/slider-captcha.css](file:///c:/Users/12914/Desktop/vscode/chunyu_project/static/css/slider-captcha.css) |
|
||
| 核心测试 | [user/tests/test_slider_captcha.py](file:///c:/Users/12914/Desktop/vscode/chunyu_project/user/tests/test_slider_captcha.py) |
|
||
| API测试 | [user/tests/test_slider_captcha_api.py](file:///c:/Users/12914/Desktop/vscode/chunyu_project/user/tests/test_slider_captcha_api.py) |
|
||
| 登录集成测试 | [user/tests/test_login_with_slider.py](file:///c:/Users/12914/Desktop/vscode/chunyu_project/user/tests/test_login_with_slider.py) |
|