← 返回博客

Passport.js 邮箱 OTP + Rovela(2026):自定义策略与会话

Express + Passport 自定义邮箱 OTP 策略:应用用 crypto.randomInt + HMAC 生成验证码,Rovela POST /emails 投递,再经 req.login 建立 Passport 会话;与不含 Passport 的纯 Express OTP 指南区分。

更新于 2026年11月4日
Passport.js 邮箱 OTP + Rovela(2026):自定义策略与会话

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 签名与投递。

与下列文章区分:

Passport 做什么,Rovela 发什么

职责归属
用 crypto.randomInt 生成验证码 + HMAC 落库你的应用
POST /emails、DKIM、队列、退信Rovela
自定义策略的 verify / authenticatePassport + 你的 OTP 存储
serializeUser / deserializeUser / 会话 CookiePassport + express-session(或等价物)
发送/校验接口限流你的 Express 中间件

Rovela 返回 200 表示已接受并入队,并不等于「已经进收件箱」(Quick Start)。

前置条件

  1. 在 Rovela 控制台完成发信域名验证。未验证域名的 from 会被 400 拒绝(Quick Start)。认证邮件建议用独立子域,例如 mail.example.com(OTP/魔法链接进垃圾箱原因)。
  2. 形如 re_… 的 API 密钥。生产发信优先 sending_access(Authentication)。
  3. 已配置 express-session(或 Passport 可用的其他会话存储)、passport,以及按邮箱查找用户的能力。
  4. 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 策略保持一致。

投递与运维清单

  1. 为 ROVELA_FROM 所用子域配置 SPF/DKIM/DMARC(DKIM 设置、SaaS 的 DMARC)。
  2. 认证邮件使用独立子域,避免产品营销信拖累同一声誉(垃圾箱问题)。
  3. 生产发信优先 sending_access;需要读取消息事件时再用 full_access(Authentication)。
  4. 让 OTP 有效期(示例:10 分钟)与邮件文案一致——Rovela 不会替你让验证码过期。
  5. 超时重试时复用由挑战派生的稳定 Idempotency-Key,避免用户收到两封互相矛盾的验证码(幂等)。
  6. 同时对 /auth/otp/send 与 /auth/otp/verify 限流;Passport 不能替代防滥用控制。

排障

现象常见原因
发送时 Rovela 400from 域名未验证,或缺少 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)