← 返回博客

Django 发送邮箱验证码(2026):用 HTTP API 发 OTP

在 Django 里做注册/登录邮箱验证码:secrets.randbelow、HMAC 落库或缓存、限流、用 urllib/httpx 调 POST /emails,以及 Idempotency-Key——Rovela 只负责投递。

更新于 2026年11月4日
Django 发送邮箱验证码(2026):用 HTTP API 发 OTP

为什么用 Django + 邮箱验证码(而不是魔法链接 SDK)

Django 本来就适合做注册/登录:函数视图或类视图、Form / DRF Serializer、中间件与限流、以及服务端存储(表或缓存)。你不需要 Rovela 的 Django 包、「OTP 产品」接口,或把邮件 API 当成身份提供商的认证后端。

你需要的是文档里的配方——邮箱验证码:应用用 secrets.randbelow 生成六位码,只存 HMAC(永不落明文),设过期与尝试次数,再用 urllib 或 httpx 调 POST /emails,同时带上 html 与 text。Rovela 负责入队、DKIM、重试与事件。它不会生成验证码、校验验证码,也不会建会话。

Rovela 不提供 OTP 功能。 Django 负责密码学、模型或缓存项、重发节流、核销逻辑以及 session / JWT。Rovela 只负责投递。

本文面向 Django(views + 小型 service,可选 DRF):「发码」与「验码」处理、薄封装的 Rovela 客户端,以及 Idempotency-Key,避免超时重试入队两封不一致的码。这不是魔法链接教程——魔法链接见 FastAPI、Next.js、NestJS。Express 同款 OTP 见 Express.js 发送邮箱验证码。数字码 vs 链接的产品选择见 邮箱验证:验证码 vs 链接。语言无关指南见 用邮件发 OTP;通用 Python 发送见 Python 发邮件 与 How to send email with Python——本文不重复 SMTP/smtplib 或「hello world」发送。

你负责什么,Rovela 发什么

Django 应用负责:

  • secrets.randbelow(1_000_000) → 六位、左侧补零(不要用 random.randint)
  • 用 hmac + hashlib 对 email + ":" + code 做 HMAC-SHA256,密钥是服务端 pepper(CODE_PEPPER)
  • 过期(文档示例约 10 分钟)、尝试计数、以及「同一地址一分钟只发一次」的重发闸
  • 按规范化邮箱 upsert,新发会使旧码失效(ORM 行或缓存键)
  • 核销时用 hmac.compare_digest 常量时间比较;成功后删除 / 失效
  • 是否暴露账号存在性由产品决定,但公开响应宜保持一致
  • 对发码/验码视图做限流(Django 缓存节流、DRF throttling 或反代)

Rovela 负责:

  • POST https://api.rovela.dev/emails,Authorization: Bearer + sending_access 密钥(环境变量 ROVELA_API_KEY——勿提交、勿下发浏览器、勿打印到日志)
  • 入队、签名、重试、退信/投诉处理

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

配置与薄封装客户端

密钥放环境变量(或密钥管理服务),不要写进会进仓库的 settings.py:

# 环境变量示意
# ROVELA_API_KEY=re_...          # sending_access
# CODE_PEPPER=long-random-secret
# OTP_FROM=Acme <security@mail.acme.com>
# OTP_TTL_MINUTES=10
# OTP_RESEND_COOLDOWN_SEC=60
# OTP_MAX_ATTEMPTS=5
# otp/rovela.py
from __future__ import annotations

import json
import os
import urllib.error
import urllib.request
from typing import Any


def send_email(
    *,
    to: str,
    subject: str,
    html: str,
    text: str,
    idempotency_key: str,
) -> dict[str, Any]:
    api_key = os.environ["ROVELA_API_KEY"]  # sending_access
    from_addr = os.environ.get(
        "OTP_FROM", "Acme <security@mail.acme.com>"
    )
    body = json.dumps(
        {
            "from": from_addr,
            "to": to,
            "subject": subject,
            "html": html,
            "text": text,
        }
    ).encode("utf-8")
    req = urllib.request.Request(
        "https://api.rovela.dev/emails",
        data=body,
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "Idempotency-Key": idempotency_key,
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        err = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"Rovela send failed: {e.code} {err}") from e

在异步 Django / Channels worker 里可换成 httpx——同一 URL、请求头与 JSON。像其它服务端密钥一样接入 ROVELA_API_KEY 与 CODE_PEPPER(Authentication、Quick Start)。

落库要哈希:secrets + HMAC,不要裸 SHA-256

六位码只有一百万种可能。泄露一张裸 SHA-256 表,一秒内就能撞完。按文档:用永不离开服务器的 pepper 做 HMAC。

# otp/crypto.py
from __future__ import annotations

import hashlib
import hmac
import os
import secrets


def normalize_email(raw: str) -> str:
    return raw.strip().lower()


def mint_code() -> str:
    # CSPRNG — random.randint / random.random 不适合机密
    return f"{secrets.randbelow(1_000_000):06d}"


def hash_code(email: str, code: str) -> str:
    pepper = os.environ["CODE_PEPPER"].encode("utf-8")
    return hmac.new(
        pepper, f"{email}:{code}".encode("utf-8"), hashlib.sha256
    ).hexdigest()


def safe_equal_hex(a: str, b: str) -> bool:
    return hmac.compare_digest(a, b)

建议表结构(每个邮箱一条活跃码),与 验证码示例 一致:

CREATE TABLE verification_codes (
  email      TEXT PRIMARY KEY,
  code_hash  TEXT NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  attempts   INT NOT NULL DEFAULT 0,
  sent_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Django 模型示意:

# otp/models.py
from django.db import models


class VerificationCode(models.Model):
    email = models.EmailField(primary_key=True)
    code_hash = models.CharField(max_length=64)
    expires_at = models.DateTimeField()
    attempts = models.PositiveSmallIntegerField(default=0)
    sent_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = "verification_codes"

缓存替代: 在 otp:{email} 存 {code_hash, attempts},TTL = 过期时间;重发冷却可用第二个键 otp:cooldown:{email}。短时验证码用缓存即可;需要审计或多 worker 一致性且没有共享 Redis 时用数据库。

Service:发送与核销

# otp/service.py
from __future__ import annotations

import os
from datetime import timedelta

from django.db import transaction
from django.utils import timezone

from otp.crypto import hash_code, mint_code, normalize_email, safe_equal_hex
from otp.models import VerificationCode
from otp.rovela import send_email

SAFE_SEND_MSG = "If that address can receive mail, a code is on its way."


def send_otp(raw_email: str) -> dict:
    email = normalize_email(raw_email)
    ttl_min = int(os.environ.get("OTP_TTL_MINUTES", "10"))
    cooldown = int(os.environ.get("OTP_RESEND_COOLDOWN_SEC", "60"))

    now = timezone.now()
    existing = VerificationCode.objects.filter(email=email).first()
    if existing and (now - existing.sent_at).total_seconds() < cooldown:
        return {"ok": False, "error": "too_soon"}

    code = mint_code()
    code_hash = hash_code(email, code)
    expires_at = now + timedelta(minutes=ttl_min)

    VerificationCode.objects.update_or_create(
        email=email,
        defaults={
            "code_hash": code_hash,
            "expires_at": expires_at,
            "attempts": 0,
            "sent_at": now,
        },
    )

    # 同一逻辑发送用稳定键;新码 ⇒ 新键
    idempotency_key = f"otp-{code_hash[:32]}"

    send_email(
        to=email,
        subject=f"Your Acme code: {code}",
        html=(
            "<p>Your verification code is</p>"
            f'<p style="font-size:28px;font-weight:600;letter-spacing:4px">{code}</p>'
            f"<p>It expires in {ttl_min} minutes. "
            "If you did not ask for it, ignore this email.</p>"
        ),
        text=(
            f"Your verification code is {code}. "
            f"It expires in {ttl_min} minutes. "
            "If you did not ask for it, ignore this email."
        ),
        idempotency_key=idempotency_key,
    )
    return {"ok": True, "message": SAFE_SEND_MSG}


@transaction.atomic
def verify_otp(raw_email: str, raw_code: str) -> dict:
    email = normalize_email(raw_email)
    code = raw_code.strip()
    max_attempts = int(os.environ.get("OTP_MAX_ATTEMPTS", "5"))

    if not (len(code) == 6 and code.isdigit()):
        return {"ok": False, "error": "invalid"}

    row = (
        VerificationCode.objects.select_for_update()
        .filter(email=email)
        .first()
    )
    if row is None:
        return {"ok": False, "error": "invalid"}

    if row.expires_at < timezone.now():
        row.delete()
        return {"ok": False, "error": "expired"}

    if row.attempts >= max_attempts:
        return {"ok": False, "error": "locked"}

    match = safe_equal_hex(hash_code(email, code), row.code_hash)
    if not match:
        VerificationCode.objects.filter(email=email).update(
            attempts=row.attempts + 1
        )
        return {"ok": False, "error": "invalid"}

    row.delete()
    # 在此发放 session / JWT
    return {"ok": True}

把验证码放进主题方便锁屏预览——许多产品适用;多人共用屏幕则建议去掉(验证码文档)。务必同时发送 html 与 text,并在 text 里带上数字(POST /emails)。

Django views + 限流示意

# otp/views.py
import json

from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from otp.service import send_otp, verify_otp

# 浏览器表单优先走带 CSRF 的 session。
# 这里 csrf_exempt 仅示意由 SPA 用其它鉴权调用的 JSON API。


@csrf_exempt
@require_POST
def otp_send(request):
    try:
        payload = json.loads(request.body.decode("utf-8") or "{}")
    except json.JSONDecodeError:
        return JsonResponse({"error": "invalid_json"}, status=400)

    email = str(payload.get("email") or "")
    if "@" not in email:
        return JsonResponse({"error": "invalid_email"}, status=400)

    result = send_otp(email)
    if not result.get("ok") and result.get("error") == "too_soon":
        return JsonResponse({"error": "too_soon"}, status=429)
    return JsonResponse({"message": result.get("message", "ok")})


@csrf_exempt
@require_POST
def otp_verify(request):
    try:
        payload = json.loads(request.body.decode("utf-8") or "{}")
    except json.JSONDecodeError:
        return JsonResponse({"error": "invalid_json"}, status=400)

    email = str(payload.get("email") or "")
    code = str(payload.get("code") or "")
    result = verify_otp(email, code)
    if not result.get("ok"):
        err = result.get("error")
        status = 429 if err in {"locked", "too_soon"} else 400
        return JsonResponse({"error": err}, status=status)
    # 设置 session / 返回 JWT
    return JsonResponse({"ok": True})
# otp/urls.py
from django.urls import path

from otp import views

urlpatterns = [
    path("auth/otp/send", views.otp_send, name="otp_send"),
    path("auth/otp/verify", views.otp_verify, name="otp_verify"),
]

Service 内的按邮箱冷却,与按 IP 限流(Django cache、DRF AnonRateThrottle、Cloudflare 或反代)解决不同问题——两者都要用。加固清单见 Email OTP security。

Idempotency-Key:重试不发双码

Worker、代理、Celery 重试与 AI agent 都会重试。没有 Idempotency-Key,一次超时就可能让用户收到两封不一致的验证码。

  • Header ≤ 256 字符;Rovela 记住 24 小时,重放返回原响应(API: emails)。
  • 用码哈希派生键(otp- + 前 32 个十六进制字符),一次逻辑发码对应一封入队邮件。
  • 冷却后的刻意重发会 mint 新码 → 新哈希 → 新键。不要把旧键套在新码上。
curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer $ROVELA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: otp-smoke-django-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "you@example.com",
    "subject": "Your Acme code: 482913",
    "html": "<p>Your verification code is <b>482913</b>. Expires in 10 minutes.</p>",
    "text": "Your verification code is 482913. Expires in 10 minutes."
  }'

认证数字码的可达性(不是魔法 URL)

OTP 邮件短、敏感。优先用已验证子域如 security@mail.example.com,对齐 SPF/DKIM/DMARC,HTML 尽量克制。正文(以及常见主题)里的数字不需要可点链接——点击追踪改写对它的影响小于魔法链接——但打开/点击追踪仍应落在你可控的域上。

若进垃圾箱,对照 为什么 OTP / 魔法链接进垃圾箱。产品该用数字码还是确认链接,见 code vs link。

Django 里:验证码 vs 魔法链接

六位 OTP(本文)魔法链接
用户动作输入数字点击 URL
秘密熵低 → 必须 HMAC + pepper高熵 URL token → 通常 SHA-256 哈希即可
文档形态验证码密码重置 / 链接模式
Django 表面POST /auth/otp/send + POST /auth/otp/verifyPOST /auth/magic/request + GET /auth/magic?token=
点击追踪次要顾虑安全发信域上建议关闭

两者都调同一个 POST /emails。模型与 service 分开——不要把 OTP 行复用成魔法链接 token。Python 魔法链接栈见 FastAPI magic link。

上线前清单

  1. secrets.randbelow + HMAC pepper;永不存明文码。
  2. 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、按 IP 限流。
  3. Upsert 使旧码失效;核销成功即删除(用 DB 时考虑 select_for_update)。
  4. 同时发 html 和 text;主题是否带码按产品权衡。
  5. ROVELA_API_KEY(sending_access)只留在服务端。
  6. Idempotency-Key 对同一次逻辑发送稳定;新码 → 新键。
  7. 若隐藏账号存在性,对外文案保持一致。
  8. From 域已验证;进垃圾箱则对照 spam 清单。

相关阅读

  1. 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
  2. 产品无关 OTP:用邮件发 OTP · Email OTP security。
  3. UX 选择:邮箱验证:验证码 vs 链接。
  4. 可达性:为什么 OTP / 魔法链接进垃圾箱。
  5. 框架兄弟篇:Express OTP · 魔法链接 FastAPI / Next.js / NestJS。
  6. 仅 Python 传输:Python 发邮件 · How to send email with Python。

一句话: 在 Django views 里用 secrets 发码、HMAC 落库(模型或缓存)、限流发码/验码,再用一次 urllib/httpx POST 调 Rovela 并带上 Idempotency-Key——永远别把邮件 API 当成你的认证服务器。