Astro 魔法登录链接(2026):用 HTTP 邮件 API 发信
在 Astro SSR 里做无密码魔法登录:APIRoute 接口、token 哈希落库、仅在 POST redeem 写 cookie session、fetch 调 POST /emails 并带 Idempotency-Key——Rovela 只负责投递。
为什么用 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 / 魔法链接会进垃圾箱 的清单排查。
限流 / 一次性 / 过期清单
- 签发限流 — 例如同一邮箱每分钟最多一条新链接;request 接口再按 IP 封顶。
- 短 TTL — 登录约 15–30 分钟;step-up 可以更短。
- 落库只存哈希 — 只存 SHA-256 hex;原文只在邮件 URL。
- Upsert — 新请求作废该用户此前未使用的哈希。
- 原子消费 —
DELETE … RETURNING(或加锁删除),双击竞态也能安全失败。 - 防枚举 request — 无论地址是否存在,状态码与正文相同。
- Idempotency-Key — 每次逻辑发送稳定(
magic-+ 哈希前缀);新 token → 新 key。 - 防预抓取 redeem —
.astro页面可以读?token=;只有 POSTAPIRoute才建 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 / loader | App Router Route Handlers / Server Actions |
| Session | context.cookies.set(+ 自建存储) | cookies.set(+ 自建存储) | Remix cookie session storage | Next 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-256 | email:code 的 HMAC + pepper |
| 典型 TTL | ~30 分钟 | ~10 分钟 |
| 适合 | 桌面/手机浏览器登录 | 共享屏幕、step-up |
两者都调用同一个 POST /emails。分表存储——不要把 OTP 行复用成魔法链接 token。产品选择:验证码 vs 链接。边缘 OTP 见 Hono OTP;Nuxt OTP 见 Nuxt OTP。
上线清单
- 域名已验证;认证子域 SPF/DKIM/DMARC 绿灯。
- 该子域(或独立安全域)关闭点击追踪。
- 仅有
sending_access的ROVELA_API_KEY放在私有服务端环境——绝不用PUBLIC_ROVELA_API_KEY。 MAGIC_FROM用已验证子域;html + text 都有;text 含完整 URL。- 每次逻辑发送带
Idempotency-Key;redeem 用 delete-and-return。 - request 防枚举;对 request(与 redeem)限流。
- 把发信
200当入队;若要投递真相,用 webhook 看退信/投诉(Webhooks)。
本文不是什么
- 不是完整生产级认证框架(Lucia Auth 深潜、Auth.js、OAuth2、MFA)。
- 不是 SvelteKit、Remix、Next.js、NestJS、FastAPI、Rails 或 Workers 文的重复。
- 不声称 Rovela 会存储、校验或过期你的 token。
- 不是中国境内 ESP 对比,也不编造产品功能。
下一步
- 按 password-reset 示例 实现 token + Astro
APIRoute。 - 用 POST /emails · Quick Start · Authentication 发信。
- 加固投递:OTP / 魔法链接进垃圾箱。
- 对比 UX 原语:验证码 vs 链接。
- 兄弟栈:SvelteKit · Remix · Next.js · NestJS · FastAPI · Rails。
- 额度:Pricing。
一句话: 把魔法登录放进 Astro SSR 的 APIRoute:对 crypto.randomBytes token 做哈希、仅在 POST redeem 写 cookie session,再 fetch 一次 POST /emails 到 Rovela——安全域关掉点击追踪,也别把邮件 API 当成身份提供方。