← 返回博客

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

在 FastAPI 里做无密码魔法登录:Pydantic 模型、哈希 token、用 httpx.AsyncClient 调 POST /emails、Idempotency-Key,以及安全链关闭点击追踪。

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

为什么是 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 / 魔法链接进垃圾箱 排查。

限流 / 一次性 / 过期清单

  1. 签发限流 — 例如每邮箱每分钟一条新链接;同时按 IP 封顶。
  2. 短 TTL — 登录约 15–30 分钟;step-up 可以更短。
  3. 只存哈希 — 存 SHA-256 十六进制;原始 token 只在邮件 URL。
  4. Upsert — 新请求作废该用户未使用的旧哈希。
  5. 原子核销 — DELETE … WHERE hash AND expires_at > now() RETURNING(或等价),双击竞态安全失败。
  6. 防枚举请求 — 无论地址是否存在,状态与正文一致。
  7. Idempotency-Key — 每次逻辑发送稳定(magic- + 哈希前缀);新 token → 新 key。
  8. 防预取核销 — 优先 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-256email:code 的 HMAC + pepper
典型 TTL~30 分钟~10 分钟
适合桌面 / 手机浏览器登录共享屏幕、step-up

两者都调同一个 POST /emails。分开表与服务——不要把 OTP 行当魔法链接 token 用。产品选型:码 vs 链接。

上线清单

  1. 域名已验证;认证子域 SPF/DKIM/DMARC 正常。
  2. 该子域(或专用安全域)关闭点击追踪。
  3. 仅在服务端环境 / 密钥管理放带 sending_access 的 ROVELA_API_KEY——勿进 SPA 包或公开文档「试一下」。
  4. MAGIC_FROM 用已验证子域;html + text 都有;text 含完整 URL。
  5. 每次逻辑发送带 Idempotency-Key;核销用 delete-and-return。
  6. 请求响应防枚举;对请求(与核销)限流。
  7. 发送 200 只表示已入队;需要投递真相再用 webhook(Webhooks)。

本文不是什么

  • 不是完整生产鉴权框架(OAuth2、refresh 轮换、MFA)。
  • 不是 NestJS、Next.js 或通用魔法登录的重复——那些分别讲 Nest 模块、App Router 与框架无关流程。
  • 不是通用Python 发邮件教程的复述。
  • 不是声称 Rovela 会存储、校验或过期你的 token。
  • 不是国内 ESP 对比,也不编造产品功能。

下一步

  1. 按密码重置示例实现 token + FastAPI 服务。
  2. 用 POST /emails · Quick Start · Authentication 发送。
  3. 投递加固:OTP / 魔法链接进垃圾箱。
  4. UX 选型:码 vs 链接。
  5. 姊妹栈:NestJS 魔法登录 · Next.js 魔法登录。
  6. 额度:Pricing。

一句话: 把魔法登录放进 FastAPI 路由——哈希 token,再用一次 httpx POST 调 Rovela;安全域关闭点击追踪,永远别把邮件 API 当成身份提供商。