邮箱验证码
在注册或登录时发送六位验证码,并安全地校验它。
一次性验证码是最常见的事务邮件:用户填了一个地址,你要证明这个地址是他的。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发送没成功时
400recipient is suppressed: …——这个地址之前硬退信或投诉过,在你的抑制列表里。让用户换个地址,而不是显示「验证码已发送」。- 点名你域名的
400——from的域名在这个组织里还没验证。这是配置问题,不是用户的问题;该报警给自己。 429——频率限制或套餐额度。响应带Retry-After,上面的重试循环会遵守它。
200 表示邮件已被受理,不代表已送达。如果用户说没收到,控制台里这封邮件的页面会显示每一步;订阅 email.bounced webhook 则不用等人来问就能知道——见 处理退信与投诉。
值得注意的细节
- 验证码放进主题。这样用户在通知栏就能看到,不用打开邮件。如果你的用户经常共享屏幕或设备,就别放。
- 两种情况同一个回答。在登录表单上,不管这个地址有没有账号都给一样的响应,否则表单就成了查询「谁是你的客户」的工具。
- 用子域名发信。从
mail.acme.com发信,能把这类邮件的信誉和营销邮件分开——验证码是你最承受不起进垃圾箱的邮件。