邮箱验证码

在注册或登录时发送六位验证码,并安全地校验它。

一次性验证码是最常见的事务邮件:用户填了一个地址,你要证明这个地址是他的。Rovela 负责投递那一半——DKIM 签名、重试、退信处理,以及每封邮件的事件记录。另一半,生成和校验验证码,在你的应用里,而出错的地方通常就在这一半。本页两半都讲。

完整流程

  • 用密码学安全的随机数生成器生成验证码。
  • 存它的哈希、过期时间和尝试次数——绝不存验证码本身。
  • 用 POST /emails 发出,同时带 html 和 text。
  • 提交时做常量时间比较、计一次尝试,成功后删除。

整个流程用 sending_access 密钥就够了,而且应该用它:负责发验证码的服务器没有理由能管理域名。

单独看这次发送

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: verify-8f3a2c71" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "alice@example.com",
    "subject": "你的 Acme 验证码:482913",
    "html": "<p>你的验证码是</p><p style=\"font-size:28px;font-weight:600;letter-spacing:4px\">482913</p><p>10 分钟内有效。如果不是你本人操作,忽略这封邮件即可。</p>",
    "text": "你的验证码是 482913,10 分钟内有效。如果不是你本人操作,忽略这封邮件即可。"
  }'

返回的是邮件 id,之后可以在控制台里查到这封邮件:

{"object": "email", "id": "9f1c2e40-..."}

存储验证码

每个地址一行。验证码本身从不落库——只存带密钥的哈希:

CREATE TABLE verification_codes (
  email      TEXT PRIMARY KEY,           -- 进来之前先转小写
  code_hash  TEXT NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  attempts   INT NOT NULL DEFAULT 0,
  sent_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
用带服务端密钥的 HMAC,不要用裸 SHA-256。六位数字只有一百万种可能,没有密钥的哈希表一旦泄露,不到一秒就能被还原。密钥放在环境变量里,不放数据库。

Node.js

用内置的 fetch 和 crypto,PostgreSQL 用 pg。换什么框架都一样:这就是注册和登录接口要调用的两个函数。

import crypto from "node:crypto"
import pg from "pg"

const db = new pg.Pool()
const PEPPER = process.env.CODE_PEPPER      // 足够长的随机密钥
const API_KEY = process.env.ROVELA_API_KEY  // sending_access 就够了

function hashCode(email, code) {
  return crypto.createHmac("sha256", PEPPER).update(email + ":" + code).digest("hex")
}

export async function sendCode(rawEmail) {
  const email = rawEmail.trim().toLowerCase()

  // 每个地址每分钟最多一封,免得「发送验证码」按钮被拿来轰炸别人的收件箱,
  // 或者烧光你的发信额度。
  const recent = await db.query(
    "SELECT 1 FROM verification_codes WHERE email = $1 AND sent_at > NOW() - INTERVAL '60 seconds'",
    [email]
  )
  if (recent.rowCount > 0) return { ok: false, error: "too_soon" }

  // randomInt 用的是操作系统的随机源。Math.random() 不能用于机密。
  const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, "0")

  // 覆盖这一行,同时也让这个地址之前的验证码失效。
  await db.query(
    `INSERT INTO verification_codes (email, code_hash, expires_at, attempts, sent_at)
     VALUES ($1, $2, NOW() + INTERVAL '10 minutes', 0, NOW())
     ON CONFLICT (email) DO UPDATE
       SET code_hash = EXCLUDED.code_hash, expires_at = EXCLUDED.expires_at,
           attempts = 0, sent_at = NOW()`,
    [email, hashCode(email, code)]
  )

  const res = await sendWithRetry({
    from: "Acme <security@mail.acme.com>",
    to: email,
    subject: "你的 Acme 验证码:" + code,
    html:
      '<p>你的验证码是</p>' +
      '<p style="font-size:28px;font-weight:600;letter-spacing:4px">' + code + '</p>' +
      '<p>10 分钟内有效。如果不是你本人操作,忽略这封邮件即可。</p>',
    text: "你的验证码是 " + code + ",10 分钟内有效。",
  })

  if (res.ok) return { ok: true }
  const body = await res.json().catch(() => ({}))
  if (res.status === 400 && String(body.message).startsWith("recipient is suppressed")) {
    // 这个地址之前退过信或把你标成了垃圾邮件。这时候告诉用户
    // 「请查收邮件」是在骗他——让他换一个地址。
    return { ok: false, error: "undeliverable" }
  }
  throw new Error("send failed: " + res.status + " " + body.message)
}

// 同一次发送的每次重试用同一个 Idempotency-Key:如果第一次请求其实已经
// 到达、只是响应丢了,重试拿到的是原来的结果,而不会再发一封验证码。
async function sendWithRetry(payload) {
  const key = "verify-" + crypto.randomUUID()
  for (let attempt = 1; ; attempt++) {
    try {
      const res = await fetch("https://api.rovela.dev/emails", {
        method: "POST",
        headers: {
          Authorization: "Bearer " + API_KEY,
          "Content-Type": "application/json",
          "Idempotency-Key": key,
        },
        body: JSON.stringify(payload),
      })
      const retryable = res.status === 429 || res.status >= 500
      if (!retryable || attempt === 3) return res
      const wait = Number(res.headers.get("retry-after") ?? 1)
      await new Promise((r) => setTimeout(r, wait * 1000))
    } catch (err) {
      if (attempt === 3) throw err   // 网络错误:同一个 key,再试一次
    }
  }
}

export async function verifyCode(rawEmail, submitted) {
  const email = rawEmail.trim().toLowerCase()

  // 计数和读取哈希在同一条语句里完成。先读再加一的话,
  // 五十个并发猜测会在任何一个被计数之前,全部通过「少于五次」的检查。
  const { rows } = await db.query(
    `UPDATE verification_codes SET attempts = attempts + 1
     WHERE email = $1 AND attempts < 5 AND expires_at > NOW()
     RETURNING code_hash`,
    [email]
  )
  if (rows.length === 0) return false   // 没有验证码、已过期或次数用完

  const expected = Buffer.from(rows[0].code_hash, "hex")
  const actual = Buffer.from(hashCode(email, String(submitted).trim()), "hex")
  if (!crypto.timingSafeEqual(expected, actual)) return false

  // 一次性:用过之后还能用的验证码,就是一个密码。
  await db.query("DELETE FROM verification_codes WHERE email = $1", [email])
  return true
}

Python

同样的逻辑,用 requests。存储部分留作注释,就是上面那三条语句。

import hashlib, hmac, os, secrets, uuid
import requests

PEPPER = os.environ["CODE_PEPPER"].encode()
API_KEY = os.environ["ROVELA_API_KEY"]

def hash_code(email: str, code: str) -> str:
    return hmac.new(PEPPER, f"{email}:{code}".encode(), hashlib.sha256).hexdigest()

def send_code(email: str) -> None:
    email = email.strip().lower()
    code = f"{secrets.randbelow(1_000_000):06d}"   # 不要用 random.randint
    # db: upsert (email, hash_code(email, code), now + 10 分钟, attempts = 0)

    resp = requests.post(
        "https://api.rovela.dev/emails",
        headers={
            "Authorization": f"Bearer {API_KEY}",
            "Idempotency-Key": f"verify-{uuid.uuid4()}",
        },
        json={
            "from": "Acme <security@mail.acme.com>",
            "to": email,
            "subject": f"你的 Acme 验证码:{code}",
            "html": f"<p>你的验证码是 <strong>{code}</strong>。</p>"
                    "<p>10 分钟内有效。</p>",
            "text": f"你的验证码是 {code},10 分钟内有效。",
        },
        timeout=10,
    )
    resp.raise_for_status()

def verify_code(email: str, submitted: str) -> bool:
    email = email.strip().lower()
    # db: UPDATE ... SET attempts = attempts + 1
    #     WHERE email = %s AND attempts < 5 AND expires_at > NOW()
    #     RETURNING code_hash
    stored_hash = ...
    if stored_hash is None:
        return False
    if not hmac.compare_digest(stored_hash, hash_code(email, submitted.strip())):
        return False
    # db: DELETE FROM verification_codes WHERE email = %s
    return True

发送没成功时

  • 400 recipient is suppressed: …——这个地址之前硬退信或投诉过,在你的抑制列表里。让用户换个地址,而不是显示「验证码已发送」。
  • 点名你域名的 400——from 的域名在这个组织里还没验证。这是配置问题,不是用户的问题;该报警给自己。
  • 429——频率限制或套餐额度。响应带 Retry-After,上面的重试循环会遵守它。

200 表示邮件已被受理,不代表已送达。如果用户说没收到,控制台里这封邮件的页面会显示每一步;订阅 email.bounced webhook 则不用等人来问就能知道——见 处理退信与投诉。

值得注意的细节

  • 验证码放进主题。这样用户在通知栏就能看到,不用打开邮件。如果你的用户经常共享屏幕或设备,就别放。
  • 两种情况同一个回答。在登录表单上,不管这个地址有没有账号都给一样的响应,否则表单就成了查询「谁是你的客户」的工具。
  • 用子域名发信。从 mail.acme.com 发信,能把这类邮件的信誉和营销邮件分开——验证码是你最承受不起进垃圾箱的邮件。

相关

用链接代替验证码:密码重置链接。发送接口的全部字段:发送邮件。