前言

最近在用node.js写一个后端项目的时候,注册需要实现一个验证码功能,果断的向好友寻求帮助,然后bakptr将他的一种无状态验证码思路推荐给了我,后面再一段时间摸索后终于完成了相应的部署,并成功实现了验证码功能。今晚有空在这里记录一下,再次感谢bakptr的帮助。

实现思路

TOTP

首先,我们需要了解TOTP(Time-Based One-Time Password)这种验证码的原理,它是一种基于时间的一次性密码算法,可以用于验证用户身份。其原理是使用一个密钥和一个时间戳来生成一个一次性密码,每次生成的时间戳都会不同,因此可以保证验证码的唯一性和安全性。

大概思路为,我们先获取当前的时间戳timeWindow 然后将emailtimeWindow拼接成一个字符串,使用HMAC-SHA256算法对这个字符串进行签名,最后将签名结果的前codeLength个字符作为验证码。

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

function getCurrentTimeWindow() {
const timestamp = new Date().getTime();
return Math.floor(timestamp / 600000);
}

async function genCodeWithTimeWindow(email, timeWindow) {
// 添加 HMAC-SHA256 签名
const secretKey = process.env.SECRET || "your-secret-key"; // 替换成你的密钥

const hmac = crypto.createHmac("sha256", secretKey);
const newStr = `${email}-${timeWindow}`;
hmac.update(newStr);
const signature = hmac.digest("hex"); // 将签名转为 16 进制字符串
const code = signature.slice(0, 7); // 截取前 codeLength 个字符作为验证码
return code;
}

async function genCode(email) {
const timeWindow = getCurrentTimeWindow();
const code = await genCodeWithTimeWindow(email, timeWindow);
const expiration = new Date((timeWindow + 2) * 600000);
const timeLimit = formatDateTime(expiration);
console.log("验证码:", code);
return {
code,
expiration,
timeLimit,
};
}

检验验证码

既然我们要注册那么肯定要检验验证码是否正确呢,但正如标题一种无状态的验证码,既然无状态我们又该怎么实现呢?不妨先看下述代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async function checkCode(email, code) {
const timeWindow = getCurrentTimeWindow();

const codes = await Promise.all([
genCodeWithTimeWindow(email, timeWindow),
genCodeWithTimeWindow(email, timeWindow - 1),
]);
console.log(codes);
if (codes.includes(code)) {
console.log("验证码正确");
return true;
}
console.log("验证码错误");
return false;
}

不知道大家发现没,在上面我们获取当前时间时使用的getCurrentTimeWindow函数返回的是Math.floor(timestamp / 600000)是当前的时间戳除以600000,
也就是10分钟。剩下的时间戳结果的每个1就代表10分钟,也就是说getCurrentTimeWindow让基本单位从原来的1毫秒变成了10分钟

回归正题,checkCode里面我们重现基于现在的时间计算了timeWindowtimeWindow - 1调用genCodeWithTimeWindow函数生成的验证码,最后判断code是否在codes数组中,如果在则说明验证码正确,否则验证码错误。

bakptr:
同时,为了防止类似于 TOTP 的“卡点过期”:
比如现在时间戳是 1721284009,设置有效期为 10 秒,计算得到时间窗口号 1721284009 / 10 = 172128400
1 秒后,时间窗口号变为 172128401,TOTP 失效,那么此时生成的 TOTP 也就只有 1 秒的有效期。
可以让服务端验证有效性的时候,往前多计算一个时间窗口,算出来两个验证码,二者任一相等即可,这样就保证了验证码有效期始终大于一个时间窗口的长度。

这也就解释了为什么我们要用两个时间节点区计算而不是一个

缺点

相对于于传统的验证码,这种无状态验证码的缺点也很明显在这段设置的单位时间内生成的验证码是相同的

于此我的想法是,我们能否基于闭包的原理,将验证码的生成函数封装起来,然后添加一个生成随机16位字符串的函数将返回结果赋予变量secret密钥供genCodeWithTimeWindow使用,当下次请求的时候重新生成随机16位字符串的函数将返回结果赋予变量secret就行了,这样就可以保证在单位时间内生成的验证码是不同的,这样就可以避免上述的缺点了。不过感觉还是有一点问题,这里就提供一种思路