Passport.js 邮箱 OTP + Rovela(2026):自定义策略与会话
Express + Passport 自定义邮箱 OTP 策略:应用用 crypto.randomInt + HMAC 生成验证码,Rovela POST /emails 投递,再经 req.login 建立 Passport 会话;与不含 Passport 的纯 Express OTP 指南区分。
Passport 管会话 —— Rovela 只负责投递验证码
Passport.js 是 Express 上经典的认证中间件:策略负责校验凭据,随后通过 req.login / passport.session() 建立服务端会话(或你配置的序列化方式)。Passport 不会生成邮箱 OTP、存储 HMAC 哈希,也不会直接对接邮件 API。官方并没有一个「替你调用邮件 API 的邮箱 OTP 策略」。
本文接入一个自定义 Passport 策略:接收 { email, code },对照你自己的 OTP 存储校验,并用 Passport 的会话栈登录用户。应用仍用 crypto.randomInt 生成六位验证码,只存 HMAC(从不存明文),再调用 Rovela POST https://api.rovela.dev/emails,并带上 Idempotency-Key,保证投递可重试且不重复(邮箱验证码示例、Send Email API、Quick Start)。
Rovela 不提供 OTP 或 Passport 功能。 它不会创建用户、校验验证码、调用 req.login 或设置 Cookie。Express + Passport 栈拥有密码学、数据库行、限流、兑换逻辑与会话;Rovela 只负责入队、DKIM 签名与投递。
与下列文章区分:
- 在 Express.js 中发送 OTP 邮件 — 纯 Express 路由发送/校验验证码,再自行签发会话或 JWT,没有 Passport 的策略 /
req.login集成 - Auth.js / NextAuth + Rovela / Better Auth + Rovela — 其他认证框架,不是 Passport 策略
- 通过邮件发送 OTP — 与语言无关的 OTP 总览
Passport 做什么,Rovela 发什么
| 职责 | 归属 |
|---|---|
用 crypto.randomInt 生成验证码 + HMAC 落库 | 你的应用 |
POST /emails、DKIM、队列、退信 | Rovela |
自定义策略的 verify / authenticate | Passport + 你的 OTP 存储 |
serializeUser / deserializeUser / 会话 Cookie | Passport + express-session(或等价物) |
| 发送/校验接口限流 | 你的 Express 中间件 |
Rovela 返回 200 表示已接受并入队,并不等于「已经进收件箱」(Quick Start)。
前置条件
- 在 Rovela 控制台完成发信域名验证。未验证域名的
from会被400拒绝(Quick Start)。认证邮件建议用独立子域,例如mail.example.com(OTP/魔法链接进垃圾箱原因)。 - 形如
re_…的 API 密钥。生产发信优先sending_access(Authentication)。 - 已配置
express-session(或 Passport 可用的其他会话存储)、passport,以及按邮箱查找用户的能力。 ROVELA_API_KEY、CODE_PEPPER与会话密钥只放在服务端,绝不打进浏览器包。sending_access绝不能出现在客户端。
环境变量
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <security@mail.example.com>'
export CODE_PEPPER='long-random-server-secret'
export SESSION_SECRET='another-long-random-secret'
轮换 Rovela 密钥时:先创建新密钥、完成部署,再吊销旧密钥(Authentication)。
OTP 密码学与存储(应用自有)
与文档示例相同:CSPRNG 数字、带 pepper 的 HMAC、过期时间、尝试计数、每地址每分钟最多发一次(验证码示例)。
// src/otp/crypto.ts
import crypto from "node:crypto";
const PEPPER = process.env.CODE_PEPPER!;
export function normalizeEmail(raw: string) {
return raw.trim().toLowerCase();
}
export function mintCode(): string {
// 使用操作系统 CSPRNG — Math.random() 不适合密钥
return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
}
export function hashCode(email: string, code: string) {
return crypto
.createHmac("sha256", PEPPER)
.update(`${email}:${code}`)
.digest("hex");
}
export function safeEqualHex(a: string, b: string) {
const ba = Buffer.from(a, "hex");
const bb = Buffer.from(b, "hex");
if (ba.length !== bb.length) return false;
return crypto.timingSafeEqual(ba, bb);
}
建议的表结构(每个邮箱一条有效验证码):
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()
);
Rovela 发信助手
把 fetch 集中起来,让发送路由共用请求头、错误处理与幂等规则。不要让 Rovela 发明验证码——只把你已经生成的数字写入 html/text。
// src/rovela.ts
const ROVELA_URL = "https://api.rovela.dev/emails";
export async function sendEmail(opts: {
to: string;
subject: string;
html: string;
text: string;
idempotencyKey: string;
}) {
const apiKey = process.env.ROVELA_API_KEY!;
const from = process.env.ROVELA_FROM!;
const key = opts.idempotencyKey.slice(0, 256);
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({
from,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
// 不要记录 API 密钥 —— 只记状态与提供商 message
console.error("rovela_send_failed", res.status, data.message);
throw new Error(`Rovela send failed: ${res.status}`);
}
return res.json() as Promise<{ object: string; id: string }>;
}
与 Rovela 文档对齐的要点:
- 尽量同时发送
html与text(Send Email API)。 Idempotency-Key(≤ 256 字符):24 小时内相同请求重试会返回原先的受理结果,而不会再发一封。优先用验证码哈希派生(对本次挑战稳定),不要在已经落库之后每次网络重试都换新 UUID。from必须落在已验证域名上,否则 API 返回400。
发送路由:生成 → 存储 → Rovela(尚未进入 Passport)
Passport 出现在校验 + 登录。发信仍是普通 Express 处理器——传输层与 Express OTP 指南相同,下一节才是 Passport 特有部分。
// src/routes/otpSend.ts
import { Router } from "express";
import rateLimit from "express-rate-limit";
import { mintCode, hashCode, normalizeEmail } from "../otp/crypto.js";
import { sendEmail } from "../rovela.js";
// import { db } from "../db.js";
export const otpSendRouter = Router();
const sendLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
});
otpSendRouter.post("/auth/otp/send", sendLimiter, async (req, res) => {
const email = normalizeEmail(String(req.body?.email ?? ""));
if (!email.includes("@")) {
return res.status(400).json({ error: "invalid_email" });
}
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 res.status(429).json({ error: "too_soon" });
}
const code = mintCode();
const codeHash = hashCode(email, code);
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, codeHash],
);
await sendEmail({
to: email,
subject: `你的 Acme 验证码:${code}`,
html:
`<p>你的验证码是</p>` +
`<p style="font-size:28px;letter-spacing:6px"><b>${code}</b></p>` +
`<p>10 分钟内有效。若非你本人操作,可忽略本邮件。</p>`,
text: `你的验证码是 ${code}。10 分钟内有效。`,
idempotencyKey: `passport-otp-${codeHash.slice(0, 32)}`,
});
// 若需隐藏账号是否存在,统一返回文案
return res.status(200).json({
message: "若该地址可收信,验证码正在发送。",
});
});
把验证码放进主题有助于锁屏预览——在高度共享设备上可省略(验证码文档)。
自定义 Passport 策略:校验 OTP → req.login
Passport 的 Strategy API 要求你调用 done(err, user)(失败时 done(null, false))。实现一个从请求体读取邮箱与验证码的策略:用常量时间比较检查存储、成功后删除行,并返回将由 Passport 序列化进会话的用户记录。
// src/auth/otpStrategy.ts
import passport from "passport";
import { Strategy } from "passport-strategy";
import {
hashCode,
normalizeEmail,
safeEqualHex,
} from "../otp/crypto.js";
// import { db, findUserByEmail } from "../db.js";
type Done = (err: any, user?: Express.User | false, info?: object) => void;
class EmailOtpStrategy extends Strategy {
name = "email-otp";
authenticate(this: Strategy & { success: Function; fail: Function; error: Function }, req: any) {
const email = normalizeEmail(String(req.body?.email ?? ""));
const code = String(req.body?.code ?? "").trim();
if (!email.includes("@") || !/^\d{6}$/.test(code)) {
return this.fail({ message: "invalid" }, 400);
}
void (async () => {
try {
// 在同一语句中占用尝试次数并读取哈希(与文档思路一致)
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 this.fail({ message: "invalid" }, 401);
}
const expected = rows[0].code_hash as string;
const actual = hashCode(email, code);
if (!safeEqualHex(expected, actual)) {
return this.fail({ message: "invalid" }, 401);
}
await db.query(`DELETE FROM verification_codes WHERE email = $1`, [email]);
let user = await findUserByEmail(email);
if (!user) {
// 产品选择:首次 OTP 自动建号,或直接拒绝
user = await createUser({ email });
}
return this.success(user);
} catch (err) {
return this.error(err);
}
})();
}
}
export function registerOtpStrategy() {
passport.use(new EmailOtpStrategy());
}
会话序列化与任意 Passport 应用相同——OTP 不会改变这部分:
// src/auth/session.ts
import passport from "passport";
// import { findUserById } from "../db.js";
passport.serializeUser((user: any, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id: string, done) => {
try {
const user = await findUserById(id);
done(null, user || false);
} catch (err) {
done(err);
}
});
Express 启动:session → Passport → 路由
// src/app.ts
import express from "express";
import session from "express-session";
import passport from "passport";
import { registerOtpStrategy } from "./auth/otpStrategy.js";
import "./auth/session.js";
import { otpSendRouter } from "./routes/otpSend.js";
const app = express();
app.use(express.json());
app.use(
session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
},
}),
);
app.use(passport.initialize());
app.use(passport.session());
registerOtpStrategy();
app.use(otpSendRouter);
app.post("/auth/otp/verify", (req, res, next) => {
passport.authenticate("email-otp", (err: any, user: Express.User | false, info: any) => {
if (err) return next(err);
if (!user) {
return res.status(401).json({ error: info?.message ?? "invalid" });
}
// Passport 会话集成 —— 这正是与纯 Express OTP 的差异
req.login(user, (loginErr) => {
if (loginErr) return next(loginErr);
return res.status(200).json({ ok: true, user: { id: (user as any).id, email: (user as any).email } });
});
})(req, res, next);
});
app.post("/auth/logout", (req, res, next) => {
req.logout((err) => {
if (err) return next(err);
req.session.destroy(() => res.status(204).end());
});
});
app.get("/me", (req, res) => {
if (!req.isAuthenticated?.() || !req.user) {
return res.status(401).json({ error: "unauthenticated" });
}
return res.json({ user: req.user });
});
对照 Express OTP:那篇文章止于「验证码匹配 → 自行签发会话/JWT」。这里匹配发生在 Passport 策略内部,并由 req.login + passport.session() 管理 Cookie 生命周期,使 req.isAuthenticated()、deserializeUser、登出等与你已有的 Local/OAuth 策略保持一致。
投递与运维清单
- 为
ROVELA_FROM所用子域配置 SPF/DKIM/DMARC(DKIM 设置、SaaS 的 DMARC)。 - 认证邮件使用独立子域,避免产品营销信拖累同一声誉(垃圾箱问题)。
- 生产发信优先
sending_access;需要读取消息事件时再用full_access(Authentication)。 - 让 OTP 有效期(示例:10 分钟)与邮件文案一致——Rovela 不会替你让验证码过期。
- 超时重试时复用由挑战派生的稳定
Idempotency-Key,避免用户收到两封互相矛盾的验证码(幂等)。 - 同时对
/auth/otp/send与/auth/otp/verify限流;Passport 不能替代防滥用控制。
排障
| 现象 | 常见原因 |
|---|---|
发送时 Rovela 400 | from 域名未验证,或缺少 html/text/template |
Rovela 401 | 缺少或无效的 Bearer 密钥(Authentication) |
| 发送返回 200 但收不到信 | 已入队但延迟/过滤——检查域名认证与垃圾箱;必要时用 full_access 查看事件 |
| 重试出现重复邮件 | 缺少或不稳定的 Idempotency-Key |
校验成功但 /me 仍 401 | 忘记 passport.session()、serializeUser 或 req.login |
| 策略从未执行 | passport.authenticate 策略名错误,或未挂 body parser |
小结
在 Express 应用内生成 OTP(crypto.randomInt + HMAC),用 Rovela POST /emails 与稳定的 Idempotency-Key 投递,再在自定义 Passport 策略中校验并用 req.login 建立与其余 Passport 栈一致的会话。Rovela 仍是事务邮件投递层——Passport 仍是会话层。
下一步:Quick Start · Send Email API · 邮箱验证码 · Express OTP(无 Passport)