文章目錄 基于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. 總結
作為一個高級前端開發工程師或者再往上技術專家/架構師,一定要有自己設計實現的思考能力,才能在具體的業務中做到安全防控