前言

由于在一个前后端不分离的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
# OR
pnpm add canvas

注意,在安装canvas之前,你需要确保你的系统中已经安装了CairoPango这两个库,因为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;
};

我在字符集中去除了如Oo0Il等容易混淆的字符,以免给用户带来困扰。

绘制验证码

按照传统的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); // 画布大小为80x30,你可以根据自己的需求调整
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) => {
// draw strokes
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();
}
// draw captcha text
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); // 参数:文本,x坐标,y坐标,最大宽度
}
// draw dots
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 = () => {
// ... 省略Canvas初始化等代码
const captcha = genCaptcha();
drawCaptcha(ctx, captcha);

const img = canvas.toDataURL();
const token = Math.random().toString(16).slice(2); // 生成一个随机token

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 BASE

FROM BASE AS BUILD
## 安装相关依赖,构建时需要使用 -dev 的库
RUN apk add --no-cache python3 make g++ py3-pkgconfig cairo-dev pango-dev pixman-dev jpeg-dev giflib-dev librsvg-dev
## ...省略其他构建步骤

FROM BASE AS PROD
## 安装运行时所需要的依赖
RUN apk add --no-cache cairo pango pixman jpeg giflib librsvg
## ...省略其他运行时步骤