FastAPI 魔法登录链接(2026):用 HTTP 邮件 API 发信
在 FastAPI 里做无密码魔法登录:Pydantic 模型、哈希 token、用 httpx.AsyncClient 调 POST /emails、Idempotency-Key,以及安全链关闭点击追踪。
为什么是 FastAPI + 明文 HTTP 邮件 API
FastAPI 已经给无密码登录准备好了干净结构:Pydantic 请求模型、负责「给我发链接」和「核销 token」的 APIRouter,以及用 httpx.AsyncClient 做异步外呼。你不需要 Rovela 的 Python SDK、FastAPI「魔法登录」插件,也不需要专门的魔法链接产品接口。
你需要的是与密码重置链接相同的 token 配方:应用生成一次性 token、只存哈希、把原始 token 只放进邮件 URL,再用 POST /emails 投递。Rovela 负责入队与 DKIM 签名;它不会创建会话,也不会校验 token。
Rovela 不提供魔法链接功能。 密码学、数据库行、过期、一次性核销、会话 Cookie / JWT 都在你的 FastAPI 应用里;Rovela 只负责把信送出去。
本文聚焦 FastAPI + Python(httpx、Pydantic、路由处理)。Nest 模块写法见 NestJS 魔法登录;App Router 见 Next.js 魔法登录;与框架无关的配方见魔法登录邮件链接;码 vs 链接选型见邮箱验证:验证码 vs 链接。通用 Python 发信见用 Python 发邮件—本文不重复那套。
你负责什么,Rovela 发送什么
FastAPI 应用负责:
secrets.token_urlsafe(32)(或 32 字节随机 → URL-safe)作为原始 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——勿提交仓库,勿暴露给浏览器或公开 OpenAPI「试一下」)- 入队、签名、重试、退信 / 投诉处理
发送接口返回 200 表示已接受并入队,不等于「已进收件箱」。
配置与 Pydantic 模型
密钥放在环境变量 / Settings 对象里——不要放进请求体:
# app/settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
rovela_api_key: str # ROVELA_API_KEY — sending_access
magic_from: str # 例如 "Acme <security@mail.acme.com>"
magic_link_base_url: str # 例如 "https://app.example.com"
magic_ttl_minutes: int = 30
settings = Settings()
# app/schemas/magic.py
from pydantic import BaseModel, EmailStr, Field
class MagicRequestIn(BaseModel):
email: EmailStr
class MagicRequestOut(BaseModel):
message: str = (
"If that address has an account, a sign-in link is on its way."
)
class MagicRedeemIn(BaseModel):
token: str = Field(min_length=16, max_length=256)
class MagicRedeemOut(BaseModel):
ok: bool = True
用与 FastAPI 管理其他服务端密钥相同的方式注入 ROVELA_API_KEY、MAGIC_FROM、MAGIC_LINK_BASE_URL。
Token 辅助(32 字节、只存哈希、短 TTL)
# app/auth/magic_token.py
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
def create_magic_token(ttl_minutes: int = 30) -> tuple[str, str, datetime]:
token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
return token, token_hash, expires_at
按用户 upsert:新请求替换未使用的旧链接,保证只有最新邮件有效。对高熵 URL-safe token,明文 SHA-256 足够(六位 OTP 则需要 HMAC + pepper——见验证码示例)。
Service:创建、存储、用 httpx.AsyncClient 发送
# app/auth/magic_service.py
import hashlib
import logging
from typing import Optional
import httpx
from app.auth.magic_token import create_magic_token
from app.settings import settings
# from app.db import users, magic_logins # 你的存储
log = logging.getLogger("magic")
SAFE_MSG = "If that address has an account, a sign-in link is on its way."
async def request_link(raw_email: str) -> dict:
email = raw_email.strip().lower()
user = await users.find_by_email(email)
if user:
token, token_hash, expires_at = create_magic_token(settings.magic_ttl_minutes)
await magic_logins.upsert(
user_id=user.id, token_hash=token_hash, expires_at=expires_at
)
link = f"{settings.magic_link_base_url.rstrip('/')}/auth/magic?token={token}"
await send_magic_email(email, link, token_hash)
return {"message": SAFE_MSG}
async def redeem(token: str) -> Optional[str]:
if not token or not isinstance(token, str):
return None
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
# 一步核销,避免并行双击都成功
return await magic_logins.consume_if_valid(token_hash) # user_id 或 None
async def send_magic_email(to: str, link: str, token_hash: str) -> None:
headers = {
"Authorization": f"Bearer {settings.rovela_api_key}",
"Content-Type": "application/json",
"Idempotency-Key": f"magic-{token_hash[:32]}",
}
body = {
"from": settings.magic_from,
"to": to,
"subject": "Your Acme sign-in link",
"html": (
"<p>Use this link to sign in to Acme.</p>"
f'<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"
f"Sign in: {link}\n\n"
"The link works once and expires in 30 minutes."
),
}
async with httpx.AsyncClient(timeout=20.0) as client:
res = await client.post(
"https://api.rovela.dev/emails",
headers=headers,
json=body,
)
if res.status_code >= 400:
# 仅服务端打日志;request_link 仍返回同一对外文案
log.error("rovela send failed %s %s", res.status_code, res.text)
务必同时发 html 与 text,并在 text 里放完整 URL。认证信用已验证子域(security@mail.…)。用 token 哈希派生 Idempotency-Key,超时重试不会入队第二封;有意「重发」则换新 token、新 key(API: emails、Authentication)。
若发信频繁,可在应用 lifespan 上复用一个长生命周期的 httpx.AsyncClient——上面为清晰起见用了短生命周期客户端。
路由:request + redeem
# app/routers/magic.py
from fastapi import APIRouter, HTTPException, Response, status
from app.auth import magic_service
from app.schemas.magic import (
MagicRedeemIn,
MagicRedeemOut,
MagicRequestIn,
MagicRequestOut,
)
# from app.sessions import create_session_cookie
router = APIRouter(prefix="/auth/magic", tags=["auth"])
@router.post("/request", response_model=MagicRequestOut)
async def magic_request(payload: MagicRequestIn) -> MagicRequestOut:
# 在此做限流(IP + email):slowapi、反代或 Redis
data = await magic_service.request_link(payload.email)
return MagicRequestOut(**data)
@router.post("/redeem", response_model=MagicRedeemOut)
async def magic_redeem(payload: MagicRedeemIn, response: Response) -> MagicRedeemOut:
user_id = await magic_service.redeem(payload.token)
if not user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="expired_or_used",
)
cookie = await create_session_cookie(user_id)
response.set_cookie(
key="session",
value=cookie,
httponly=True,
secure=True,
samesite="lax",
max_age=60 * 60 * 24 * 14,
)
return MagicRedeemOut(ok=True)
挂到应用上:
# app/main.py
from fastapi import FastAPI
from app.routers.magic import router as magic_router
app = FastAPI()
app.include_router(magic_router)
在 /auth/magic 提供页面:读取 ?token=,再 POST JSON { "token": "…" } 到 /auth/magic/redeem。失败时展示笼统的「链接过期或已使用」,并提供重新申请按钮——不要区分未知 / 已用 / 过期,以免帮到攻击者。
优先用 POST redeem,不要在 GET 上直接建会话:邮件扫描器与链接预取常会打 GET。若必须支持 GET 落地页,先展示「Continue」,等用户明确操作后再发会话。
用于「确认邮箱归属」(不是登录)时,同一服务形态换表与路径即可——见邮箱验证链接。密码重置是文档孪生:安全密码重置邮件。
登录 URL 请关闭点击追踪
打开 / 点击追踪是按域配置的。开启点击追踪时,Rovela 会把链接改写成追踪主机。重定向后登录仍可用,但 token 会多一跳。
对魔法链接(以及密码重置 / 邀请 token),更推荐单独发送域或子域,并关闭点击追踪,让 token 直达你的 FastAPI 应用。这与密码重置示例一致。若认证信仍进垃圾箱,按 OTP / 魔法链接进垃圾箱 排查。
限流 / 一次性 / 过期清单
- 签发限流 — 例如每邮箱每分钟一条新链接;同时按 IP 封顶。
- 短 TTL — 登录约 15–30 分钟;step-up 可以更短。
- 只存哈希 — 存 SHA-256 十六进制;原始 token 只在邮件 URL。
- Upsert — 新请求作废该用户未使用的旧哈希。
- 原子核销 —
DELETE … WHERE hash AND expires_at > now() RETURNING(或等价),双击竞态安全失败。 - 防枚举请求 — 无论地址是否存在,状态与正文一致。
- Idempotency-Key — 每次逻辑发送稳定(
magic-+ 哈希前缀);新 token → 新 key。 - 防预取核销 — 优先 POST redeem;若必须 GET,在明确动作前不要建会话。
curl 等价(agent / 冒烟)
与 FastAPI 服务打出的线路格式相同——适合本地冒烟与直接调 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)。
同一 FastAPI 应用里:魔法链接 vs OTP
| 魔法链接 | OTP(六位) | |
|---|---|---|
| 文档形态 | 同密码重置 | 验证码 |
| FastAPI 落点 | magic_service + /auth/magic/* | 单独的 otp_service + 路由 |
| UX | 一键 | 输入数字 |
| 存储 | 高熵 token 的 SHA-256 | email:code 的 HMAC + pepper |
| 典型 TTL | ~30 分钟 | ~10 分钟 |
| 适合 | 桌面 / 手机浏览器登录 | 共享屏幕、step-up |
两者都调同一个 POST /emails。分开表与服务——不要把 OTP 行当魔法链接 token 用。产品选型:码 vs 链接。
上线清单
- 域名已验证;认证子域 SPF/DKIM/DMARC 正常。
- 该子域(或专用安全域)关闭点击追踪。
- 仅在服务端环境 / 密钥管理放带
sending_access的ROVELA_API_KEY——勿进 SPA 包或公开文档「试一下」。 MAGIC_FROM用已验证子域;html + text 都有;text 含完整 URL。- 每次逻辑发送带
Idempotency-Key;核销用 delete-and-return。 - 请求响应防枚举;对请求(与核销)限流。
- 发送
200只表示已入队;需要投递真相再用 webhook(Webhooks)。
本文不是什么
- 不是完整生产鉴权框架(OAuth2、refresh 轮换、MFA)。
- 不是 NestJS、Next.js 或通用魔法登录的重复——那些分别讲 Nest 模块、App Router 与框架无关流程。
- 不是通用Python 发邮件教程的复述。
- 不是声称 Rovela 会存储、校验或过期你的 token。
- 不是国内 ESP 对比,也不编造产品功能。
下一步
- 按密码重置示例实现 token + FastAPI 服务。
- 用 POST /emails · Quick Start · Authentication 发送。
- 投递加固:OTP / 魔法链接进垃圾箱。
- UX 选型:码 vs 链接。
- 姊妹栈:NestJS 魔法登录 · Next.js 魔法登录。
- 额度:Pricing。
一句话: 把魔法登录放进 FastAPI 路由——哈希 token,再用一次 httpx POST 调 Rovela;安全域关闭点击追踪,永远别把邮件 API 当成身份提供商。