NestJS 魔法登录链接(2026):用 HTTP 邮件 API 发信
在 NestJS 模块里做无密码魔法登录:哈希 token、过期、用 fetch/HttpService 调 POST /emails、Idempotency-Key,以及安全链关闭点击追踪。
为什么是 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)→base64urltoken- 数据库里只存 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 应用。这与密码重置示例一致。若认证信仍进垃圾箱,按为什么验证码与魔法登录邮件进垃圾箱排查。
限流 / 一次性 / 过期清单
- 签发节流——例如每邮箱每分钟一条新链接;同时按 IP 封顶。
- 短 TTL——登录约 15–30 分钟;升权可以更短。
- 落库只存哈希——SHA-256 十六进制;明文只出现在邮件 URL。
- Upsert——新请求作废该用户上一条未使用的哈希。
- 原子核销——
DELETE … WHERE hash AND expires_at > now() RETURNING(或等价写法),避免双击竞态。 - 防枚举的请求响应——地址是否存在,状态码与正文相同。
- Idempotency-Key——每次逻辑发送稳定(
magic-+ 哈希前缀);新 token → 新 key。 - 预取安全的核销——优先 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-256 | email:code 的 HMAC + pepper |
| 典型 TTL | 约 30 分钟 | 约 10 分钟 |
| 适合 | 桌面 / 手机浏览器登录 | 共用屏幕、升权 |
两者都调同一条 POST /emails。表与 Service 分开——不要把 OTP 行当魔法链接 token 用。产品选型见验证码 vs 链接。
上线清单
- 域名已验证;认证子域 SPF/DKIM/DMARC 正常。
- 该子域(或专用安全域)关闭点击追踪。
ROVELA_API_KEY(sending_access)只放服务端环境 / 密钥管理——永不进 Nest 前端包。MAGIC_FROM用已验证子域;同时有 html + text;text 含完整 URL。- 每次逻辑发送带
Idempotency-Key;核销为 delete-and-return。 - 请求接口防枚举;请求(与核销)限流。
- 把发送
200当「已入队」;需要投递真相时用 webhook(Webhook)。
本文不是什么
- 不是完整生产鉴权框架(Passport 策略、刷新轮换、MFA)。
- 不是 Next.js 魔法登录 或通用魔法登录 的换皮——那些分别覆盖 App Router 与框架无关流程。
- 不宣称 Rovela 替你存、校验或过期 token。
- 不是国内 ESP 对比,也不编造产品功能。
下一步
- 按密码重置示例实现 token + Nest Service。
- 用 POST /emails · 快速开始 · 鉴权 发送。
- 投递加固:验证码 / 魔法登录进垃圾箱。
- UX 原语对比:验证码 vs 链接。
- 额度:定价。
一句话: 把魔法登录放进 Nest AuthModule 的 Service——哈希 token,一次 fetch 调 Rovela;安全域关闭点击追踪;永远别把邮件 API 当成身份提供方。