一种无状态的邮箱验证码的设想

最近在尝试用 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 秒的有效期。

可以让服务端验证有效性的时候,往前多计算一个时间窗口,算出来两个验证码,二者任一相等即可,这样就保证了验证码有效期始终大于一个时间窗口的长度。

# 验证流程

综上,以下就是我设想的无状态的邮箱验证流程:

  1. 用户输入邮箱地址后,调用服务端邮箱验证接口
  2. 在邮箱验证接口中:
    1. 计算当前时间戳(毫秒) / 600000(10 分钟),称为 Time Window
    2. 拼接邮箱地址、Time Window,称为 Msg
    3. 使用配置的 Server SecretMsg 进行 HMAC-SHA256 签名,结果转化为 16 进制字符串,称为 Full Code
    4. 截断 Full Code 的前 6 位,作为验证码
    5. 发送验证码到邮箱
  3. 用户输入邮箱验证码后,调用注册接口
  4. 在注册接口中:
    1. 进行上面所述的计算,得到 Time WindowTime Window - 1 对应的验证码
    2. 判断是否任一相同  

# 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 里面就需要加上标识当前操作的字符串,保证验证码不被混用。

# 局限

  1. 只要验证码一生成,那么就没有办法撤回,在有效期内始终有效。这也是所有无状态方案的通病吧 XD
  2. 容易被刷邮件,这点可以通过加上验证码(比如 Cloudflare Turnstile、Google reCAPTCHA)来缓解
  3. ……欢迎补充 (?)

这个无状态邮箱验证码只是我一个非常初步的设想,可能会有其他的潜在安全隐患,正式环境没有别的限制的话,还是老老实实用传统的 Redis / Session 方案吧。

# 题外话

发现 Cloudflare 的中文官方译名叫做科赋锐,好喜感 2333333

使用 Hugo 构建
主题 StackJimmy 设计