← 返回博客

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

在 Astro SSR 里做无密码魔法登录:APIRoute 接口、token 哈希落库、仅在 POST redeem 写 cookie session、fetch 调 POST /emails 并带 Idempotency-Key——Rovela 只负责投递。

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

为什么用 Astro + 纯 HTTP 邮件 API

Astro(开启 SSR / output: "server")做无密码登录本来就很顺:一个可渐进增强的 <form method="POST">、在 src/pages/api/… 里导出 APIRoute 的服务端路由发 token 并 upsert、以及一个 .astro 页面只读 ?token=、再用第二个 POST 接口消费 token 并写入 httpOnly session cookie。你不需要 Rovela 的 Astro 插件、名为「magic link」的产品接口,也不需要 SvelteKit 那种 +page.server.ts form action 文件。

你需要的是与 密码重置链接 相同的 token 配方:应用用 crypto.randomBytes 生成一次性 token、只存 SHA-256 哈希、把原始 token 只放进邮件 URL,再 fetch POST /emails 发出去。Rovela 负责排队与 DKIM 签名;它不会签发 session,也不会校验 token。

Rovela 不提供魔法链接功能。 密码学、数据库行、过期、一次性核销和 cookie 都由你的 Astro 应用负责。Rovela 只负责投递。

本文聚焦 Astro / TypeScript(src/pages/api 的 APIRoute、仅服务端模块、Node crypto、适配器环境变量、服务端 fetch)。它不是 Remix 魔法登录(Remix loader/action)、SvelteKit 魔法登录(+page.server.ts actions)或 Next.js 魔法登录(App Router Route Handlers / Server Actions)的换皮复述。Nest 模块见 NestJS 魔法登录;FastAPI 见 FastAPI 魔法登录;Rails 见 Rails 魔法登录。产品无关配方:魔法登录邮件链接。验证码 vs 链接:邮箱验证:验证码还是链接。

你负责什么,Rovela 发什么

你的 Astro 应用负责:

  • crypto.randomBytes(32).toString("base64url") 作为原始 token
  • 数据库中只存 token 的 SHA-256 hex(绝不存原文)
  • 过期时间(文档示例对重置类链接约 30 分钟)
  • 你自己源站上的绝对 URL:https://app.example.com/auth/magic?token=…
  • 原子核销(delete-and-return)+ cookies.set 写 session
  • 无论邮箱是否已注册,接口响应都相同
  • 对 request / redeem 接口做限流

Rovela 负责:

  • POST https://api.rovela.dev/emails,带 Authorization: Bearer + sending_access 密钥(ROVELA_API_KEY 放在服务端环境变量——不要提交进仓库,不要用 PUBLIC_ 前缀以免泄漏到客户端包)
  • 排队、签名、重试、退信/投诉处理

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

环境变量与精简 Rovela 客户端(fetch)

开启 SSR(output: "server")并使用 Node(或兼容)适配器。密钥只放在服务端模块与 APIRoute 能读到的进程环境——永远不要用 PUBLIC_*:

// src/lib/server/env.ts
export function requireEnv(name: string): string {
  const v = process.env[name] ?? (import.meta as any).env?.[name];
  if (!v) throw new Error(`missing_env:${name}`);
  return v;
}

// ROVELA_API_KEY=re_...          // sending_access — 不要 PUBLIC_
// MAGIC_FROM=Acme <security@mail.acme.com>
// MAGIC_LINK_BASE_URL=https://app.example.com
// MAGIC_TTL_MINUTES=30
// SESSION_SECRET=long-random-string
// src/lib/server/rovela.ts
import { requireEnv } from "./env";

export async function sendEmail(opts: {
  to: string;
  subject: string;
  html: string;
  text: string;
  idempotencyKey: string;
}) {
  const apiKey = requireEnv("ROVELA_API_KEY"); // sending_access
  const from = process.env.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",
      Accept: "application/json",
      "Idempotency-Key": opts.idempotencyKey.slice(0, 256),
    },
    body: JSON.stringify({
      from,
      to: opts.to,
      subject: opts.subject,
      html: opts.html,
      text: opts.text,
    }),
  });

  if (!res.ok) {
    // 不要把 API key 打进日志。
    console.error("rovela_send_failed", res.status);
    throw new Error("send_failed");
  }
  return (await res.json()) as { object: string; id: string };
}

鉴权:Authentication · 报文格式:API: emails · 首次发信:Quick Start。

Session cookie(Astro APIContext.cookies)

Astro 接口在请求上下文里暴露 cookies。一个签名的不透明 session id(或密封 cookie)对本配方就够用——若用随机 id,请在服务端存映射:

// src/lib/server/session.ts
import type { APIContext } from "astro";
import crypto from "node:crypto";

const COOKIE = "__acme_session";

export function setSessionCookie(cookies: APIContext["cookies"], userId: string) {
  // 更推荐把随机 session id 存进 DB;这里直接塞 userId 仅作示意。
  const sessionId = crypto.randomBytes(32).toString("base64url");
  // await db.session.create({ data: { id: sessionId, userId, ... } });
  void userId;
  cookies.set(COOKIE, sessionId, {
    path: "/",
    httpOnly: true,
    sameSite: "lax",
    secure: import.meta.env.PROD,
    maxAge: 60 * 60 * 24 * 14,
  });
  return sessionId;
}

Token 助手:只存哈希,短 TTL

// src/lib/server/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 };
}

export function hashToken(token: string) {
  return crypto.createHash("sha256").update(token).digest("hex");
}

按用户 upsert,保证只有最新未使用的链接有效。对高熵、URL 安全的 token,纯 SHA-256 足够(六位 OTP 则需要 HMAC + pepper——见 验证码示例)。

服务层:创建、落库、发送

// src/lib/server/magic.ts
import { createMagicToken, hashToken } from "./magic-token";
import { sendEmail } from "./rovela";
import { requireEnv } from "./env";

const SAFE_MSG =
  "If that address has an account, a sign-in link is on its way.";

export async function requestMagicLink(rawEmail: string) {
  const email = rawEmail.trim().toLowerCase();
  const user = await findUserByEmail(email); // 你的存储

  if (user) {
    const ttl = Number(process.env.MAGIC_TTL_MINUTES ?? "30");
    const { token, tokenHash, expiresAt } = createMagicToken(ttl);

    await upsertMagicLogin(user.id, tokenHash, expiresAt);

    const base = requireEnv("MAGIC_LINK_BASE_URL").replace(/\/$/, "");
    const link = `${base}/auth/magic?token=${token}`;

    await sendEmail({
      to: email,
      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.`,
      idempotencyKey: `magic-${tokenHash.slice(0, 32)}`,
    });
  }

  // 无论账号是否存在,返回同一句话。
  return { message: SAFE_MSG };
}

export async function redeemMagicToken(token: string) {
  if (!token || token.length < 16) return null;
  const tokenHash = hashToken(token);
  // 推荐 DELETE … WHERE token_hash = $1 AND expires_at > NOW() RETURNING user_id
  return await consumeMagicLogin(tokenHash);
}

// 占位 — 换成你的 DB:
declare function findUserByEmail(email: string): Promise<{ id: string } | null>;
declare function upsertMagicLogin(
  userId: string,
  tokenHash: string,
  expiresAt: Date,
): Promise<void>;
declare function consumeMagicLogin(tokenHash: string): Promise<string | null>;

始终同时发 html 与 text,并在 text 里放完整 URL。认证邮件用已验证子域(security@mail.…)。Idempotency-Key 从 token 哈希派生,超时重试就不会再入队一封;真正的「重发」应换新 token、新 key。

API 路由:请求链接(APIRoute POST)

// src/pages/api/auth/magic/request.ts
import type { APIRoute } from "astro";
import { requestMagicLink } from "../../../../lib/server/magic";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  // 同时按邮箱 + IP 限流(Redis / Upstash / 自建限流器)。
  const ct = request.headers.get("content-type") ?? "";
  let email = "";
  if (ct.includes("application/json")) {
    const body = (await request.json().catch(() => null)) as { email?: string } | null;
    email = String(body?.email ?? "");
  } else {
    const form = await request.formData();
    email = String(form.get("email") ?? "");
  }
  if (!email.includes("@")) {
    return new Response(JSON.stringify({ error: "invalid_email" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }
  const payload = await requestMagicLink(email);
  return new Response(JSON.stringify(payload), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
};

可渐进增强的表单可以直接 POST 到同一接口,无需客户端 JS:

---
// src/pages/auth/magic/request.astro
export const prerender = false;
---
<html lang="zh-CN">
  <body>
    <form method="POST" action="/api/auth/magic/request" enctype="application/x-www-form-urlencoded">
      <label>
        工作邮箱
        <input name="email" type="email" required autocomplete="email" />
      </label>
      <button type="submit">发我一封登录链接</button>
    </form>
  </body>
</html>

JSON SPA island 用 fetch("/api/auth/magic/request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }) }) 调同一 handler——不要复制一份加密逻辑。Rovela 密钥不会离开服务端进程。

验证页:frontmatter 展示 token,POST 核销

优先用 POST redeem,不要在 GET 上建 session:邮件扫描器与链接预抓取常会命中 GET。

---
// src/pages/auth/magic/index.astro
export const prerender = false;
const token = Astro.url.searchParams.get("token") ?? "";
// 不要在这里建 session — 扫描器会预抓取 GET。
---
<html lang="zh-CN">
  <body>
    <h1>继续登录</h1>
    <p>点一次完成登录。这一步能避免邮件扫描器把链接烧掉。</p>
    <form method="POST" action="/api/auth/magic/redeem">
      <input type="hidden" name="token" value={token} />
      <button type="submit" disabled={!token}>继续</button>
    </form>
  </body>
</html>
// src/pages/api/auth/magic/redeem.ts
import type { APIRoute } from "astro";
import { redeemMagicToken } from "../../../../lib/server/magic";
import { setSessionCookie } from "../../../../lib/server/session";

export const prerender = false;

export const POST: APIRoute = async ({ request, cookies, redirect }) => {
  const ct = request.headers.get("content-type") ?? "";
  let token = "";
  if (ct.includes("application/json")) {
    const body = (await request.json().catch(() => null)) as { token?: string } | null;
    token = String(body?.token ?? "");
  } else {
    const form = await request.formData();
    token = String(form.get("token") ?? "");
  }
  const userId = await redeemMagicToken(token);
  if (!userId) {
    return new Response(JSON.stringify({ error: "expired_or_used" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }
  setSessionCookie(cookies, userId);
  // 表单提交:重定向进应用。JSON 客户端可偏好 200 + { ok: true }。
  if (ct.includes("application/json")) {
    return new Response(JSON.stringify({ ok: true }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  }
  return redirect("/app", 303);
};

若是「确认邮箱归属」而非登录,同一服务形态换表和路径即可——见 邮箱验证链接。密码重置是文档孪生:安全的密码重置邮件 与 password-reset 示例。

登录 URL 请关掉点击追踪

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

对魔法链接(以及密码重置 / 邀请 token),建议单独发送域或子域并关闭点击追踪,让 token 直达你的 Astro 验证路由。这与 password-reset 示例 的建议一致。若认证邮件仍进垃圾箱,用 为什么 OTP / 魔法链接会进垃圾箱 的清单排查。

限流 / 一次性 / 过期清单

  1. 签发限流 — 例如同一邮箱每分钟最多一条新链接;request 接口再按 IP 封顶。
  2. 短 TTL — 登录约 15–30 分钟;step-up 可以更短。
  3. 落库只存哈希 — 只存 SHA-256 hex;原文只在邮件 URL。
  4. Upsert — 新请求作废该用户此前未使用的哈希。
  5. 原子消费 — DELETE … RETURNING(或加锁删除),双击竞态也能安全失败。
  6. 防枚举 request — 无论地址是否存在,状态码与正文相同。
  7. Idempotency-Key — 每次逻辑发送稳定(magic- + 哈希前缀);新 token → 新 key。
  8. 防预抓取 redeem — .astro 页面可以读 ?token=;只有 POST APIRoute 才建 session。

curl 等价写法(agent / 冒烟)

与你的 Astro 服务端 POST 同一报文——适合本地冒烟,以及直接打 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."
  }'

仅在非生产环境用真实 token 替换 TEST。生产密钥需要 sending_access;from 必须在已验证域名上(Quick Start)。

Astro vs SvelteKit vs Remix vs Next.js(对照)

本文(Astro)SvelteKit 魔法登录Remix 魔法登录Next.js 魔法登录
服务端入口src/pages/api/** APIRoute + SSR 页面+page.server.ts actions / load;可选 +server.ts路由 action / loaderApp Router Route Handlers / Server Actions
Sessioncontext.cookies.set(+ 自建存储)cookies.set(+ 自建存储)Remix cookie session storageNext cookies / 你的 auth 库
UI 表单渐进 <form method="POST"> → API 路由渐进表单 + use:enhance渐进 Remix <Form method="post">Server Actions / 客户端 fetch
焦点Astro SSR 无密码登录SvelteKit 无密码登录Remix 无密码登录Next 无密码登录

四者都调用同一个 POST /emails。不要把 Astro 服务端路由与 Cloudflare Email Workers 搞混(Workers 事务邮件)。

同一 Astro 应用里的魔法链接 vs OTP

魔法链接OTP(六位)
文档形态同 password reset验证码
Astro 落点/auth/magic + /api/auth/magic/*单独的 OTP API 路由
UX一点(+ Continue)输入数字
存储高熵 token 的 SHA-256email:code 的 HMAC + pepper
典型 TTL~30 分钟~10 分钟
适合桌面/手机浏览器登录共享屏幕、step-up

两者都调用同一个 POST /emails。分表存储——不要把 OTP 行复用成魔法链接 token。产品选择:验证码 vs 链接。边缘 OTP 见 Hono OTP;Nuxt OTP 见 Nuxt OTP。

上线清单

  1. 域名已验证;认证子域 SPF/DKIM/DMARC 绿灯。
  2. 该子域(或独立安全域)关闭点击追踪。
  3. 仅有 sending_access 的 ROVELA_API_KEY 放在私有服务端环境——绝不用 PUBLIC_ROVELA_API_KEY。
  4. MAGIC_FROM 用已验证子域;html + text 都有;text 含完整 URL。
  5. 每次逻辑发送带 Idempotency-Key;redeem 用 delete-and-return。
  6. request 防枚举;对 request(与 redeem)限流。
  7. 把发信 200 当入队;若要投递真相,用 webhook 看退信/投诉(Webhooks)。

本文不是什么

  • 不是完整生产级认证框架(Lucia Auth 深潜、Auth.js、OAuth2、MFA)。
  • 不是 SvelteKit、Remix、Next.js、NestJS、FastAPI、Rails 或 Workers 文的重复。
  • 不声称 Rovela 会存储、校验或过期你的 token。
  • 不是中国境内 ESP 对比,也不编造产品功能。

下一步

  1. 按 password-reset 示例 实现 token + Astro APIRoute。
  2. 用 POST /emails · Quick Start · Authentication 发信。
  3. 加固投递:OTP / 魔法链接进垃圾箱。
  4. 对比 UX 原语:验证码 vs 链接。
  5. 兄弟栈:SvelteKit · Remix · Next.js · NestJS · FastAPI · Rails。
  6. 额度:Pricing。

一句话: 把魔法登录放进 Astro SSR 的 APIRoute:对 crypto.randomBytes token 做哈希、仅在 POST redeem 写 cookie session,再 fetch 一次 POST /emails 到 Rovela——安全域关掉点击追踪,也别把邮件 API 当成身份提供方。