最近在尝试用 Cloudflare Pages 及其 Functions 来实现一个类似于 CAS 的简单统一认证系统。因为 Serverless 本身无状态的局限性,再加上我不想再引入 Serverless 的 Redis 服务(Upstash 之类的),所以在邮件验证码的这个场景下,传统的依赖 Redis / Session 的方案也就没法用了 (;´д`)ゞ
#
TOTP
说到无状态的验证码,就不得不提 TOTP,也是一种常见二因验证码。TOTP 生成的思路大概就是把当前时间戳除一个有效期,再通过 HMAC-SHA1 / HMAC-SHA256 之类的签名算法进行签名,最后把签名截断出固定位数作为验证码。在服务端进行相同的运算就可以验证 TOTP 的有效性。
那么,要实现相似的无状态的邮件验证码也可以运用这一思路,把邮箱地址和时间窗口号(应该可以这么称呼吧?)拼接在一起,截断前几位作为邮箱验证码,服务端进行相同的运算也就可以验证邮箱的有效性。
同时,为了防止类似于 TOTP 的“卡点过期”:
比如现在时间戳是 1721284009
,设置有效期为 10 秒,计算得到时间窗口号 1721284009
/ 10 = 172128400
1 秒后,时间窗口号变为 172128401
,TOTP 失效,那么此时生成的 TOTP 也就只有 1 秒的有效期。
可以让服务端验证有效性的时候,往前多计算一个时间窗口,算出来两个验证码,二者任一相等即可,这样就保证了验证码有效期始终大于一个时间窗口的长度。
#
验证流程
综上,以下就是我设想的无状态的邮箱验证流程:
- 用户输入邮箱地址后,调用服务端邮箱验证接口
- 在邮箱验证接口中:
- 计算当前时间戳(毫秒) / 600000(10 分钟),称为
Time Window
- 拼接邮箱地址、
Time Window
,称为 Msg
- 使用配置的
Server Secret
对 Msg
进行 HMAC-SHA256 签名,结果转化为 16 进制字符串,称为 Full Code
- 截断
Full Code
的前 6 位,作为验证码
- 发送验证码到邮箱
- 用户输入邮箱验证码后,调用注册接口
- 在注册接口中:
- 进行上面所述的计算,得到
Time Window
和 Time Window
- 1 对应的验证码
- 判断是否任一相同
#
TypeScript 实现
Cloudflare 的 Edge Runtime 没有办法使用 Node.js crypto
库,但是幸运的是它支持了 Web Crypto API,可以用来做上述的 HMAC-SHA256 签名,并且转化为 16 进制字符串:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
export async function hmacSha256String(msg: string, key: string) {
const encoder = new TextEncoder()
const keyBuffer = encoder.encode(key)
const msgBuffer = encoder.encode(msg)
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign'],
)
const signatureBuffer = await crypto.subtle.sign('HMAC', cryptoKey, msgBuffer)
const signatureArray = new Uint8Array(signatureBuffer)
const hexString = Array.from(signatureArray)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
return hexString
}
|
然后,根据上述流程可以写出这样的生成和验证函数:
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
|
function getCurrentTimeWindow() {
const timestamp = new Date().getTime()
return Math.floor(timestamp / CODE_TIME_WINDOW_SIZE)
}
async function genCodeWithTimeWindow(
payload: string[],
timeWindow: number,
) {
const msg = payload.join('-') + timeWindow.toString()
const fullCode = await hmacSha256String(msg, SERVER_SECRET)
return fullCode.substring(0, 6)
}
export async function genCode(
payload: string[],
): Promise<{ code: string; expiration: number }> {
const timeWindow = getCurrentTimeWindow()
const code = await genCodeWithTimeWindow(payload, timeWindow)
const expiration = (timeWindow + 2) * CODE_TIME_WINDOW_SIZE
return {
code,
expiration,
}
}
export async function checkCode(payload: string[], code: string) {
const timeWindow = getCurrentTimeWindow()
const codes = await Promise.all([
genCodeWithTimeWindow(payload, timeWindow),
genCodeWithTimeWindow(payload, timeWindow - 1),
])
if (codes.includes(code)) {
return true
}
return false
}
|
如果只是想验证邮箱的话,Payload 只传入邮箱地址就足够了。但是如果想要区分不同的操作(比如区分删库前进行邮箱验证与注册前进行邮箱验证),Payload 里面就需要加上标识当前操作的字符串,保证验证码不被混用。
#
局限
- 只要验证码一生成,那么就没有办法撤回,在有效期内始终有效。这也是所有无状态方案的通病吧 XD
- 容易被刷邮件,这点可以通过加上验证码(比如 Cloudflare Turnstile、Google reCAPTCHA)来缓解
- ……欢迎补充 (?)
这个无状态邮箱验证码只是我一个非常初步的设想,可能会有其他的潜在安全隐患,正式环境没有别的限制的话,还是老老实实用传统的 Redis / Session 方案吧。
#
题外话
发现 Cloudflare 的中文官方译名叫做科赋锐,好喜感 2333333