文章目录
- 基于Nodejs作为服务端,React作为前端框架,axios作为通讯框架,实现滑块验证
- 1. 为什么要自己写滑块验证
- 2. 滑块验证的整体思路
- 3. 具体实现
- 4. 总结
基于Nodejs作为服务端,React作为前端框架,axios作为通讯框架,实现滑块验证
1. 为什么要自己写滑块验证
- 之前我面试一位前端的童鞋,应聘的是高级前端开发工程师,我问他在项目中有没有实现过滑块验证,他说有,我说你怎么做的,他说有很多的现成的框架可以用,我说不用框架,现在想让你自己设计一个滑块验证,你会怎么设计,他支支吾吾好半天,大概表达这么几点:
-
- 前端实现一个容器图片和和滑块图片
-
- 拖动滑块图片,判断边界,到达制定边界则表示验证成功
- 这听起来貌似没啥问题吧,听我接下来怎么问,我说你这验证都放前端了,那不相当于没验证,我直接模拟结果不就可以了
- 他想了想,又说,可以在服务端设定一个坐标点(x,y)然后把前端的坐标点传过去进行比较,判断是否完成验证
- 也貌似合理,我又问他,那你怎么保证重复验证和过期验证,或者说DOS的攻击
- 这次他想了很久,最后告诉我说,平时框架用的多,这个真的没弄过,很诚实,但是能看出来,缺乏思考。
- 这也是为什么我们要自己写滑块验证的根本原因,保证系统的安全性,防止DOS等安全问题
- 那具体怎么实现一个滑块验证呢,我们来大概阐述一下思路
2. 滑块验证的整体思路
-
- 前端领取接口,告知服务端准备验证
-
- 服务端创建会话,并管理会话周期
-
- 进行DOS攻击验证(访问频率限制)
-
- 定义主图尺寸,滑块尺寸
-
- 生成主图和滑块图
-
- 生成滑块随机位置,并保存在会话里
-
- 返回会话ID和图像
-
- 前端生成生成图像和滑块
-
- 监听开始滑动,滑动,滑动结束等事件
-
- 滑动事件结束后,请求服务端接口,返回会话ID和位置信息
-
- 服务端验证会话信息和DOS攻击处理
-
- 服务端验证是否完整滑块验证/成功->返回suc->删除会话/失败->返回fail
-
- 前端验证成功/失败的逻辑业务
3. 具体实现
3.1 服务端
const express = require('express');
const cors = require('cors');
const canvas = require('canvas');
const { v4: uuidv4 } = require('uuid');
const rateLimit = require('express-rate-limit');
const app = express();
app.use(cors());
app.use(express.json());
const generateVerificationLimiter = rateLimit({windowMs: 60 * 1000, max: 10, message: { success: false, message: '请求过于频繁,请1分钟后再试' },standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => {return req.ip;}
});
const verifyLimiter = rateLimit({windowMs: 60 * 1000, max: 20, message: { success: false, message: '验证请求过于频繁,请1分钟后再试' },standardHeaders: true,legacyHeaders: false,keyGenerator: (req) => {return req.ip;}
});
const verificationSessions = new Map();
const ipVerificationSuccess = new Map();
setInterval(() => {const now = Date.now();const oneHour = 60 * 60 * 1000; ipVerificationSuccess.forEach((record, ip) => {if (now - record.timestamp > oneHour) {ipVerificationSuccess.delete(ip);}});
}, 60 * 60 * 1000);
app.get('/api/generate-verification', generateVerificationLimiter, async (req, res) => {try {const clientIp = req.ip;const successRecord = ipVerificationSuccess.get(clientIp) || { count: 0, timestamp: Date.now() };if (successRecord.count > 50) { return res.status(429).json({success: false,message: '您的操作过于频繁,请稍后再试'});}const width = 300;const height = 150;const puzzleSize = 40;const puzzleX = Math.floor(Math.random() * (width - puzzleSize * 2)) + puzzleSize;const puzzleY = Math.floor(Math.random() * (height - puzzleSize));const mainCanvas = canvas.createCanvas(width, height);const mainCtx = mainCanvas.getContext('2d');mainCtx.fillStyle = '#f0f0f0';mainCtx.fillRect(0, 0, width, height);for (let i = 0; i < 10; i++) {mainCtx.fillStyle = `rgba(${Math.random() * 100 + 100}, ${Math.random() * 100 + 100}, ${Math.random() * 100 + 100}, 0.5)`;const size = Math.random() * 20 + 5;mainCtx.beginPath();mainCtx.arc(Math.random() * width,Math.random() * height,size,0,Math.PI * 2);mainCtx.fill();}const puzzleCanvas = canvas.createCanvas(puzzleSize, puzzleSize);const puzzleCtx = puzzleCanvas.getContext('2d');puzzleCtx.drawImage(mainCanvas, puzzleX, puzzleY, puzzleSize, puzzleSize, 0, 0, puzzleSize, puzzleSize );mainCtx.fillStyle = '#f0f0f0';mainCtx.fillRect(puzzleX, puzzleY, puzzleSize, puzzleSize);mainCtx.strokeStyle = '#ccc'; mainCtx.lineWidth = 2; mainCtx.strokeRect(puzzleX, puzzleY, puzzleSize, puzzleSize); const sessionId = uuidv4();verificationSessions.set(sessionId, {puzzleX, puzzleY, timestamp: Date.now(), clientIp });setTimeout(() => {verificationSessions.delete(sessionId);}, 5 * 60 * 1000); res.json({sessionId, mainImage: mainCanvas.toDataURL('image/png'), puzzleImage: puzzleCanvas.toDataURL('image/png'), puzzleSize });} catch (error) {console.error('生成验证图像失败:', error);res.status(500).json({ error: '生成验证图像失败' });}
});
app.post('/api/verify', verifyLimiter, (req, res) => {const { sessionId, positionX } = req.body;const clientIp = req.ip;const session = verificationSessions.get(sessionId);if (!session) {return res.json({ success: false, message: '验证会话已过期,请重试' });}if (session.clientIp !== clientIp) {verificationSessions.delete(sessionId); return res.json({ success: false, message: '验证异常,请重试' });}verificationSessions.delete(sessionId);const tolerance = 5;const isSuccess = Math.abs(positionX - session.puzzleX) <= tolerance;if (isSuccess) {const now = Date.now();const successRecord = ipVerificationSuccess.get(clientIp) || { count: 0, timestamp: now };ipVerificationSuccess.set(clientIp, {count: successRecord.count + 1,timestamp: now});}res.json({success: isSuccess,message: isSuccess ? '验证成功' : '验证失败,请重试'});
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {console.log(`服务器运行在端口 ${PORT}`);console.log(`已启用请求频率限制保护`);
});
3.2 前端
- 使用React hooks实现
- 滑块组件SliderVerification.jsx
import React, { useState, useEffect, useRef } from 'react';
import axios from 'axios';
const SliderVerification = ({ onVerifySuccess }) => {const [mainImage, setMainImage] = useState('');const [puzzleImage, setPuzzleImage] = useState('');const [puzzleSize, setPuzzleSize] = useState(40);const [sessionId, setSessionId] = useState('');const [isDragging, setIsDragging] = useState(false);const [positionX, setPositionX] = useState(0);const [message, setMessage] = useState('请拖动滑块完成验证');const [isVerifying, setIsVerifying] = useState(false);const [isSuccess, setIsSuccess] = useState(null);const sliderRef = useRef(null);const puzzleRef = useRef(null);const containerRef = useRef(null);useEffect(() => {fetchVerificationImage();}, []); const fetchVerificationImage = async () => {try {setMessage('加载验证图像中...');const response = await axios.get('http://localhost:5000/api/generate-verification');const { sessionId, mainImage, puzzleImage, puzzleSize } = response.data;setSessionId(sessionId); setMainImage(mainImage); setPuzzleImage(puzzleImage); setPuzzleSize(puzzleSize); setPositionX(0); setMessage('请拖动滑块完成验证'); setIsSuccess(null); } catch (error) {console.error('获取验证图像失败:', error);setMessage('加载验证失败,请刷新重试');}};const handleStart = (e) => {if (isVerifying || isSuccess !== null) return;setIsDragging(true);e.preventDefault();};const handleMove = (e) => {if (!isDragging) return;const containerRect = containerRef.current.getBoundingClientRect();let clientX;if (e.type.includes('mouse')) {clientX = e.clientX;} else {clientX = e.touches[0].clientX;}let newPositionX = clientX - containerRect.left - puzzleSize / 2;const maxX = containerRect.width - puzzleSize;newPositionX = Math.max(0, Math.min(newPositionX, maxX));setPositionX(newPositionX);};const handleEnd = async () => {if (!isDragging) return;setIsDragging(false);setIsVerifying(true);setMessage('验证中...');try {const response = await axios.post('http://localhost:5000/api/verify', {sessionId, positionX });const { success, message } = response.data;setIsSuccess(success); setMessage(message); setIsVerifying(false); if (success && onVerifySuccess) {onVerifySuccess();}} catch (error) {console.error('验证失败:', error);setMessage('验证失败,请重试');setIsVerifying(false);}};const handleRefresh = () => {fetchVerificationImage();};return (<div className="verification-container" ref={containerRef} style={{width: '300px',border: '1px solid #ddd',borderRadius: '8px',padding: '15px',boxShadow: '0 2px 10px rgba(0,0,0,0.1)',background: '#fff'}}>{}<div className="image-container" style={{width: '100%',height: '150px',position: 'relative', overflow: 'hidden', borderRadius: '4px',marginBottom: '15px'}}>{}{mainImage && (<img src={mainImage} alt="验证背景图,包含一个需要填充的缺口" style={{ width: '100%', height: '100%', objectFit: 'cover' }}/>)}{}{puzzleImage && (<div ref={puzzleRef}style={{position: 'absolute', left: `${positionX}px`, top: '0',width: `${puzzleSize}px`,height: `${puzzleSize}px`,boxShadow: '0 0 10px rgba(0,0,0,0.3)', pointerEvents: 'none' }}><img src={puzzleImage} alt="需要拖动到缺口位置的滑块拼图" style={{ width: '100%', height: '100%', objectFit: 'cover' }}/></div>)}</div>{}<div className="slider-container" style={{width: '100%',height: '40px',background: '#f0f0f0',borderRadius: '20px',position: 'relative', overflow: 'hidden' }}>{}<div style={{width: isDragging || isSuccess ? `${(positionX / (300 - puzzleSize)) * 100}%` : '0%',height: '100%',background: isSuccess ? '#52c41a' : '#1890ff',transition: isSuccess ? 'width 0.3s' : 'none'}}/>{}<divref={sliderRef}style={{position: 'absolute', left: `${positionX}px`, top: '0',width: '40px',height: '40px',background: '#fff',border: '1px solid #ddd',borderRadius: '50%', display: 'flex',alignItems: 'center',justifyContent: 'center',cursor: 'pointer',boxShadow: '0 2px 5px rgba(0,0,0,0.2)' }}onMouseDown={handleStart} onMouseMove={handleMove} onMouseUp={handleEnd} onMouseLeave={handleEnd} onTouchStart={handleStart} onTouchMove={handleMove} onTouchEnd={handleEnd} >{}<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={isSuccess ? '#52c41a' : '#1890ff'} strokeWidth="2"style={{ pointerEvents: 'none' }} >{}{isSuccess ? (<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />) : (<path d="M5 12h14M12 5l7 7-7 7" />)}</svg></div>{}<div style={{position: 'absolute',width: '100%',height: '100%',display: 'flex',alignItems: 'center',justifyContent: 'center',pointerEvents: 'none', fontSize: '14px',color: isSuccess ? '#52c41a' : '#666'}}>{message}</div></div>{}<button onClick={handleRefresh}style={{marginTop: '10px',background: 'none',border: 'none',color: '#1890ff',cursor: 'pointer',fontSize: '12px',display: 'flex',alignItems: 'center',padding: '5px 0',}}>{}<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#1890ff" strokeWidth="2" style={{ marginRight: '5px' }}><path d="M23 4v6h-6M1 20v-6h6" /><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" /></svg>刷新验证</button></div>);
};
export default SliderVerification;
import React, { useState } from 'react';
import SliderVerification from './SliderVerification';
const App = () => {const [isVerified, setIsVerified] = useState(false);const handleVerificationSuccess = () => {setIsVerified(true);};return (<div style={{display: 'flex',flexDirection: 'column',alignItems: 'center',justifyContent: 'center',minHeight: '100vh', background: '#f5f5f5', padding: '20px' }}>{}<h2 style={{ color: '#333', marginBottom: '30px' }}>滑块验证示例</h2>{}{!isVerified ? (<div><p style={{ color: '#666', textAlign: 'center', marginBottom: '20px' }}>请完成下方滑块验证以证明您不是机器人</p>{}{}<SliderVerification onVerifySuccess={handleVerificationSuccess} /></div>) : (<div style={{textAlign: 'center',padding: '30px',background: 'white',borderRadius: '8px',boxShadow: '0 2px 10px rgba(0,0,0,0.1)' }}>{}<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#52c41a" strokeWidth="2" style={{ margin: '0 auto 20px' }}><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>{}<h3 style={{ color: '#333', marginBottom: '10px' }}>验证成功!</h3>{}<p style={{ color: '#666' }}>您已成功完成验证,可以继续使用服务。</p></div>)}</div>);
};
export default App;
4. 总结
- 作为一个高级前端开发工程师或者再往上技术专家/架构师,一定要有自己设计实现的思考能力,才能在具体的业务中做到安全防控