前言 由于在一个前后端不分离的Next.js项目开发中遇到了在登录页面添加验证码的需求,于是就想着能否用纯Node.js实现一个离线的验证码生成器。
在网上找了一些资料发现是有些方案是用Canvas在浏览器端绘制验证码的,显然这样不安全,因为验证码的生成逻辑是暴露在前端的,用户可以通过查看源码的方式获取到验证码的生成逻辑,失去了验证码的意义。
于是我最开始就想着能否在Node.js中用使用OffScreenCanvas绘制验证码,再将渲染好的图像返回给前端。不过尝试之后发现OffScreenCanvas在Node.js中是获取不到2D上下文的,所以这个方案也行不通。 不过好在稍加搜索后找到了node-canvas
这个库,可以在Node.js中提供类Canvas的API,并提供了额外的保存Canvas中图像的方法。
实现 准备工作 首先需要安装node-canvas
(npm包名为canvas
):
1 2 3 npm install canvas pnpm add canvas
注意,在安装canvas
之前,你需要确保你的系统中已经安装了Cairo
和Pango
这两个库,因为canvas
是依赖这两个库的。服务器/Docker上编译运行时还需要进行相关依赖的安装,详情请看后文。
生成随机验证码 最开始需要一个随机生成的字符串,作为待使用的验证码,比如下面的代码将生成一个长度为4的验证码字符串:
1 2 3 4 5 6 7 8 const charSet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz123456789' ;const genCaptcha = ( ) => { const captcha = Array .from ( { length : 4 }, () => charSet[Math .floor (Math .random () * charSet.length )] ).join ('' ); return captcha; };
我在字符集中去除了如O
、o
、0
、I
、l
等容易混淆的字符,以免给用户带来困扰。
绘制验证码 按照传统的Canvas初始化流程,需要创建一个Canvas实例,然后再创建一个2D上下文。 不过,在此之前这里还需要注册一下字体,因为不能保证运行环境中有相关的字体文件,所以需要手动添加字体文件注册一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { createCanvas, registerFont, CanvasRenderingContext2D } from 'canvas' ;registerFont ('public/NotoSans-Black.ttf' , { family : 'NotoSans' }); const canvas = createCanvas (80 , 30 ); const ctx = canvas.getContext ('2d' );if (!ctx) { throw new Error ({ code : 'INTERNAL_SERVER_ERROR' , message : 'Init Canvas context failed.' }); }
这里我使用了粗体的NotoSans
,以保证文字的渲染效果,你可以在Google Noto Fonts 中下载到相关字体文件。
接下来就是绘制验证码的过程了,通过ctx
的API就可以直接开始在画布上绘制线条、图形、文字了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 const randomColor = ( ) => { return Math .floor (Math .random () * 256 ); }; const drawCaptcha = (ctx : CanvasRenderingContext2D , captcha : string ) => { for (let i = 0 ; i < 30 ; i++) { ctx.beginPath (); ctx.strokeStyle = `rgb(${randomColor()} , ${randomColor()} , ${randomColor()} )` ; ctx.lineWidth = Math .random () + 1 ; ctx.moveTo (Math .random () * 80 , Math .random () * 30 ); ctx.lineTo (Math .random () * 80 , Math .random () * 30 ); ctx.stroke (); } ctx.font = '30px NotoSans' ; for (let i = 0 ; i < captcha.length ; ++i) { const color = Math .floor (Math .random () * 64 ); ctx.fillStyle = `rgb(${color} , ${color} , ${color} )` ; ctx.fillText (captcha.charAt (i), 10 + i * 15 , 23 , 15 ); } ctx.fillStyle = '#000' ; for (let i = 0 ; i < 50 ; i++) { ctx.fillRect (Math .random () * 80 , Math .random () * 30 , 1 , 1 ); } };
我在代码中随机添加了一些干扰线、干扰点,以增加验证码的难度。当然这里的干扰还是比较简单的,你可以根据自己的需求添加更多的干扰元素。生成的验证码如下图所示:
保存验证码 绘制完成后,我们可以将Canvas中的图像保存为PNG格式,方便返回给前端使用。好在node-canvas
库为Canvas提供了一个保存为DataURL的方法:
1 2 3 drawCaptcha (ctx, captcha);const imgData = canvas.toDataURL ('image/png' );
返回的imgData
就是一个Base64编码的PNG图像数据(以data:image/png;base64,
开头),可以直接在前端中使用。
验证码生成后可以将验证码字符串存储在内存或者数据库中,然后给用户返回一个token和验证码图片,用户在登录时需要输入验证码,然后将验证码字符串和token一并发送给后端进行验证。
简单起见,我将验证码字符存储在global对象中,实际使用时可以存储在Redis等数据库中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 const CAPTCHA_CLEAR_INTERVAL = parseInt (process.env .CAPTCHA_CLEAR_INTERVAL ?? '300000' );export const CAPTCHA_EXPIRATION = 120 * 1000 ;export const globalStore = globalThis as typeof globalThis & { captchas : { [key : string ]: { value : string ; exp : Date } }; }; export const clearCaptcha = ( ) => { const now = new Date (); Object .keys (globalStore.captchas ).forEach ((key ) => { if (globalStore.captchas [key].exp < now) { console .log ('Clear unused captcha:' , key); delete globalStore.captchas [key]; } }); }; export const deleteCaptcha = (token : string ) => { delete globalStore.captchas [token]; }; export const setCaptcha = (token : string , value : string ) => { globalStore.captchas [token] = { value, exp : new Date (Date .now () + CAPTCHA_EXPIRATION ) }; }; export const getCaptcha = (token : string ): string | undefined => { const captcha = globalStore.captchas [token]; if (captcha) { if (captcha.exp .getTime () < Date .now ()) { delete globalStore.captchas [token]; return undefined ; } return captcha.value .toLowerCase (); } return undefined ; }; const startClearCaptcha = ( ) => { clearCaptcha (); setTimeout (startClearCaptcha, CAPTCHA_CLEAR_INTERVAL ); }; export const initCaptchaStore = ( ) => { if (!globalStore.captchas ) { globalStore.captchas = {}; } startClearCaptcha (); };
与登录接口结合 生成验证码时,将验证码字符串存储在globalStore
中,比如下面获取验证码的接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import { setCaptcha } from './captcha' ;export const handler = ( ) => { const captcha = genCaptcha (); drawCaptcha (ctx, captcha); const img = canvas.toDataURL (); const token = Math .random ().toString (16 ).slice (2 ); setCaptcha (token, captcha); return { token : token, img : img }; }
登录接口进行验证码校验:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { getCaptcha, deleteCaptcha } from './captcha' ;export const handler = (username : string , password : string , token : string , captcha : string ) => { const realCaptcha = getCaptcha (token); if (!realCaptcha || realCaptcha !== captcha) { return { code : 'UNAUTHORIZED' , message : 'Invalid captcha.' }; } deleteCaptcha (token); }
Docker环境配置 如上文所述,编译和运行时都需要相关的依赖库,下面是对于Docker Alpine环境的配置:
1 2 3 4 5 6 7 8 9 10 11 FROM node:20 -alpine AS BASEFROM BASE AS BUILDRUN apk add --no-cache python3 make g++ py3-pkgconfig cairo-dev pango-dev pixman-dev jpeg-dev giflib-dev librsvg-dev FROM BASE AS PRODRUN apk add --no-cache cairo pango pixman jpeg giflib librsvg