← 返回博客

NestJS 魔法登录链接(2026):用 HTTP 邮件 API 发信

在 NestJS 模块里做无密码魔法登录:哈希 token、过期、用 fetch/HttpService 调 POST /emails、Idempotency-Key,以及安全链关闭点击追踪。

更新于 2026年11月4日
NestJS 魔法登录链接(2026):用 HTTP 邮件 API 发信

为什么是 NestJS + 明文 HTTP 邮件 API

NestJS 已经给无密码登录准备好了结构:AuthModule、负责密码学与持久化的 Service,以及暴露「给我发链接」和「核销 token」的 Controller。你不需要 Rovela SDK、Passport 的「魔法登录」策略,也不需要专门的魔法链接产品接口。

你需要的是与密码重置链接相同的 token 配方:应用生成一次性 token、只存哈希、把原始 token 只放进邮件 URL,再用 POST /emails 投递。Rovela 负责入队与 DKIM 签名;它不会创建会话,也不会校验 token。

Rovela 不提供魔法链接功能。 密码学、数据库行、过期、一次性核销、会话 Cookie 都在你的 Nest 应用里;Rovela 只负责把信送出去。

本文聚焦 Nest 模块写法。App Router Route Handler 见 Next.js 魔法登录;与框架无关的配方见魔法登录邮件链接;码 vs 链接选型见邮箱验证:验证码 vs 链接。

你负责什么,Rovela 发送什么

NestJS 应用负责:

  • crypto.randomBytes(32) → base64url token
  • 数据库里只存 token 的 SHA-256 十六进制(永不存明文)
  • 过期时间(文档示例对重置类链接约 30 分钟)
  • 指向你自己源站的绝对 URL:https://app.example.com/auth/magic?token=…
  • 原子核销(delete-and-return)+ 会话 / JWT 签发
  • 无论邮箱是否注册,对外响应一致
  • 对「请求」与「核销」做限流

Rovela 负责:

  • POST https://api.rovela.dev/emails,Authorization: Bearer + 带 sending_access 的密钥(环境变量 ROVELA_API_KEY——勿提交仓库,勿暴露给浏览器)
  • 入队、签名、重试、退信 / 投诉处理

发送接口返回 200 表示已接受并入队,不等于「已进收件箱」。

模块草图

把魔法登录放在 auth 内,不要塞进还发 newsletter 的泛「邮件」模块:

// auth/auth.module.ts
import { Module } from '@nestjs/common'
import { HttpModule } from '@nestjs/axios' // 若偏好 HttpService 可选
import { MagicLinkService } from './magic-link.service'
import { MagicLinkController } from './magic-link.controller'

@Module({
  imports: [HttpModule],
  controllers: [MagicLinkController],
  providers: [MagicLinkService],
})
export class AuthModule {}

用 ConfigModule / 环境变量注入 ROVELA_API_KEY、MAGIC_LINK_BASE_URL(应用源站)、MAGIC_FROM(已验证子域地址)——与 Nest 管理其他密钥的方式相同。

Token 辅助(32 字节、只存哈希、短 TTL)

// auth/magic-token.ts
import crypto from 'node:crypto'

export function createMagicToken(ttlMinutes = 30) {
  const token = crypto.randomBytes(32).toString('base64url')
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex')
  const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000)
  return { token, tokenHash, expiresAt }
}

按用户 upsert:新请求替换未使用的旧链接,保证只有最新邮件有效。对 32 字节随机值,明文 SHA-256 足够(六位 OTP 则需要 HMAC + pepper——见验证码示例)。

Service:创建、存储、用 fetch(或 HttpService)发送

// auth/magic-link.service.ts
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import crypto from 'node:crypto'
import { createMagicToken } from './magic-token'
// import { UsersRepo, MagicLoginsRepo } from '../db' // 你的存储

@Injectable()
export class MagicLinkService {
  private readonly log = new Logger(MagicLinkService.name)

  constructor(private readonly config: ConfigService) {}

  /** 对外文案始终相同(防枚举)。 */
  async requestLink(rawEmail: string) {
    const email = String(rawEmail ?? '').trim().toLowerCase()
    const user = await this.users.findByEmail(email)

    if (user) {
      const { token, tokenHash, expiresAt } = createMagicToken(30)
      await this.magicLogins.upsert({ userId: user.id, tokenHash, expiresAt })

      const base = this.config.getOrThrow<string>('MAGIC_LINK_BASE_URL')
      const link = `${base}/auth/magic?token=${token}`
      await this.sendMagicEmail(email, link, tokenHash)
    }

    return {
      message: 'If that address has an account, a sign-in link is on its way.',
    }
  }

  async redeem(token: string) {
    if (!token || typeof token !== 'string') return null
    const tokenHash = crypto.createHash('sha256').update(token).digest('hex')

    // 一步核销,避免两次并行点击都成功。
    const userId = await this.magicLogins.consumeIfValid(tokenHash)
    return userId // 过期 / 已用 / 未知则为 null
  }

  private async sendMagicEmail(to: string, link: string, tokenHash: string) {
    const apiKey = this.config.getOrThrow<string>('ROVELA_API_KEY')
    const from = this.config.getOrThrow<string>('MAGIC_FROM')
    // 例如 "Acme <security@mail.acme.com>"

    const res = await fetch('https://api.rovela.dev/emails', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': `magic-${tokenHash.slice(0, 32)}`,
      },
      body: JSON.stringify({
        from,
        to,
        subject: 'Your Acme sign-in link',
        html:
          `<p>Use this link to sign in to Acme.</p>` +
          `<p><a href="${link}">Sign in</a></p>` +
          `<p>The link works once and expires in 30 minutes. ` +
          `If you did not request it, ignore this email.</p>`,
        text:
          `Use this link to sign in to Acme.\n\n` +
          `Sign in: ${link}\n\n` +
          `The link works once and expires in 30 minutes.`,
      }),
    })

    if (!res.ok) {
      // 仅服务端打日志;requestLink 仍返回同一客户端文案。
      this.log.error(`rovela send failed ${res.status} ${await res.text()}`)
    }
  }
}

Nest 10+ / Node 18+ 可直接用内置 fetch。若项目统一用 @nestjs/axios,同一 URL、请求头与 JSON 体用 HttpService.post 即可——不必给 Rovela 包一层自定义 SDK。

务必同时发 html 与 text,并在纯文本里放完整 URL。认证信用已验证子域(security@mail.…)。Idempotency-Key 从 token 哈希派生,超时重试不会再入队一封;用户主动「重发」则新 token、新 key(API:发信、鉴权)。

Controller:请求 + 核销

// auth/magic-link.controller.ts
import { Body, Controller, HttpCode, Post, Res } from '@nestjs/common'
import type { Response } from 'express'
import { MagicLinkService } from './magic-link.service'
// import { SessionService } from './session.service'

@Controller('auth/magic')
export class MagicLinkController {
  constructor(
    private readonly magic: MagicLinkService,
    private readonly sessions: SessionService,
  ) {}

  @Post('request')
  @HttpCode(200)
  async request(@Body('email') email: string) {
    // 在此做限流(IP + 邮箱):Nest Throttler 或反向代理。
    return this.magic.requestLink(email)
  }

  @Post('verify')
  async verify(@Body('token') token: string, @Res({ passthrough: true }) res: Response) {
    const userId = await this.magic.redeem(token)
    if (!userId) {
      return res.status(400).json({ error: 'expired_or_used' })
    }
    const cookie = await this.sessions.createCookie(userId)
    res.setHeader('Set-Cookie', cookie)
    return { ok: true }
  }
}

在 /auth/magic 页面读取 ?token=,再 POST 到 /auth/magic/verify。失败时展示统一的「链接过期或已使用」,并提供重新申请按钮——不要区分未知 / 已用 / 过期,以免帮助攻击者。

若是邮箱归属确认(不是登录),同一 Nest Service 形态即可,换表与路径——见邮箱验证链接。密码重置是文档孪生:安全的密码重置邮件。

登录 URL 请关闭点击追踪

打开 / 点击追踪是按域名配置的。开启后 Rovela 会把链接改写到追踪主机。登录仍可用,但 token 会多一跳。

魔法链接(以及密码重置 / 邀请 token)更宜使用关闭点击追踪的独立发送域或子域,让 token 直达你的 Nest 应用。这与密码重置示例一致。若认证信仍进垃圾箱,按为什么验证码与魔法登录邮件进垃圾箱排查。

限流 / 一次性 / 过期清单

  1. 签发节流——例如每邮箱每分钟一条新链接;同时按 IP 封顶。
  2. 短 TTL——登录约 15–30 分钟;升权可以更短。
  3. 落库只存哈希——SHA-256 十六进制;明文只出现在邮件 URL。
  4. Upsert——新请求作废该用户上一条未使用的哈希。
  5. 原子核销——DELETE … WHERE hash AND expires_at > now() RETURNING(或等价写法),避免双击竞态。
  6. 防枚举的请求响应——地址是否存在,状态码与正文相同。
  7. Idempotency-Key——每次逻辑发送稳定(magic- + 哈希前缀);新 token → 新 key。
  8. 预取安全的核销——优先 POST verify(如上);若必须支持 GET,先展示「继续」再显式动作建会话(扫描器会预取 GET)。

curl 等价(agent / 冒烟)

与 Nest Service 发出的线路格式相同——便于本地冒烟与直接调 HTTP 的 AI agent(AI agent 发邮件):

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: magic-smoke-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "alice@example.com",
    "subject": "Your Acme sign-in link",
    "html": "<p><a href=\"https://app.acme.com/auth/magic?token=TEST\">Sign in</a></p><p>Expires in 30 minutes.</p>",
    "text": "Sign in: https://app.acme.com/auth/magic?token=TEST\n\nExpires in 30 minutes."
  }'

仅在非生产用 TEST 占位。生产密钥需要 sending_access;from 必须落在已验证域名(快速开始)。

同一 Nest 应用里:魔法链接 vs OTP

魔法链接OTP(六位)
文档形态同密码重置验证码
Nest 落点MagicLinkService + /auth/magic/*独立 OtpService + 路由
UX一键输入数字
存储32 字节 token 的 SHA-256email:code 的 HMAC + pepper
典型 TTL约 30 分钟约 10 分钟
适合桌面 / 手机浏览器登录共用屏幕、升权

两者都调同一条 POST /emails。表与 Service 分开——不要把 OTP 行当魔法链接 token 用。产品选型见验证码 vs 链接。

上线清单

  1. 域名已验证;认证子域 SPF/DKIM/DMARC 正常。
  2. 该子域(或专用安全域)关闭点击追踪。
  3. ROVELA_API_KEY(sending_access)只放服务端环境 / 密钥管理——永不进 Nest 前端包。
  4. MAGIC_FROM 用已验证子域;同时有 html + text;text 含完整 URL。
  5. 每次逻辑发送带 Idempotency-Key;核销为 delete-and-return。
  6. 请求接口防枚举;请求(与核销)限流。
  7. 把发送 200 当「已入队」;需要投递真相时用 webhook(Webhook)。

本文不是什么

  • 不是完整生产鉴权框架(Passport 策略、刷新轮换、MFA)。
  • 不是 Next.js 魔法登录 或通用魔法登录 的换皮——那些分别覆盖 App Router 与框架无关流程。
  • 不宣称 Rovela 替你存、校验或过期 token。
  • 不是国内 ESP 对比,也不编造产品功能。

下一步

  1. 按密码重置示例实现 token + Nest Service。
  2. 用 POST /emails · 快速开始 · 鉴权 发送。
  3. 投递加固:验证码 / 魔法登录进垃圾箱。
  4. UX 原语对比:验证码 vs 链接。
  5. 额度:定价。

一句话: 把魔法登录放进 Nest AuthModule 的 Service——哈希 token,一次 fetch 调 Rovela;安全域关闭点击追踪;永远别把邮件 API 当成身份提供方。