Django 发送邮箱验证码(2026):用 HTTP API 发 OTP
在 Django 里做注册/登录邮箱验证码:secrets.randbelow、HMAC 落库或缓存、限流、用 urllib/httpx 调 POST /emails,以及 Idempotency-Key——Rovela 只负责投递。
为什么用 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/verify | POST /auth/magic/request + GET /auth/magic?token= |
| 点击追踪 | 次要顾虑 | 安全发信域上建议关闭 |
两者都调同一个 POST /emails。模型与 service 分开——不要把 OTP 行复用成魔法链接 token。Python 魔法链接栈见 FastAPI magic link。
上线前清单
secrets.randbelow+ HMAC pepper;永不存明文码。- 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、按 IP 限流。
- Upsert 使旧码失效;核销成功即删除(用 DB 时考虑
select_for_update)。 - 同时发
html和text;主题是否带码按产品权衡。 ROVELA_API_KEY(sending_access)只留在服务端。Idempotency-Key对同一次逻辑发送稳定;新码 → 新键。- 若隐藏账号存在性,对外文案保持一致。
- From 域已验证;进垃圾箱则对照 spam 清单。
相关阅读
- 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
- 产品无关 OTP:用邮件发 OTP · Email OTP security。
- UX 选择:邮箱验证:验证码 vs 链接。
- 可达性:为什么 OTP / 魔法链接进垃圾箱。
- 框架兄弟篇:Express OTP · 魔法链接 FastAPI / Next.js / NestJS。
- 仅 Python 传输:Python 发邮件 · How to send email with Python。
一句话: 在 Django views 里用 secrets 发码、HMAC 落库(模型或缓存)、限流发码/验码,再用一次 urllib/httpx POST 调 Rovela 并带上 Idempotency-Key——永远别把邮件 API 当成你的认证服务器。