Phoenix / Elixir 发送邮箱验证码(2026):用 HTTP API 发 OTP
在 Phoenix 里做注册/登录邮箱验证码::crypto.strong_rand_bytes、HMAC 落库、LiveView 或 Controller、用 Req/Finch 调 POST /emails,以及 Idempotency-Key——Rovela 只负责投递。
为什么是 Phoenix + 验证码(不是魔法链接 SDK)
Phoenix 本来就适合做注册/登录:Context 负责持久化,Controller 或 LiveView 不把密钥放到浏览器,运行时配置放 API Key。你不需要 Rovela 的 Elixir SDK,也不需要所谓「OTP 产品」接口,更不要写一个 Plug 把邮件 API 装成身份提供方。
你需要的是文档里的配方:邮箱验证码——用 :crypto.strong_rand_bytes 生成六位数字(若必须用 :rand.uniform 需清楚熵更弱——优先操作系统 CSPRNG),只存 HMAC(绝不存明文),设过期与尝试次数,再用 POST /emails 同时发 html 与 text。Rovela 负责排队、DKIM 签名、重试与事件。它不会生成验证码、校验验证码,也不会建会话。
Rovela 不提供 OTP 功能。 密码学、数据库行、重发节流、兑换逻辑、Session / Token 都属于你的 Phoenix Context;Rovela 只负责投递。
本文面向 Phoenix(LiveView 表单 → 服务端事件,或经典 Controller + JSON API):用 Req 或 Finch 调 Rovela,以及 Idempotency-Key,避免超时重试发出两封互相打架的验证码。这不是魔法链接教程——那些见 Next.js、NestJS、FastAPI。数字码 vs 链接的产品选择见 邮箱验证:验证码 vs 确认链接。与语言无关的 OTP 总览是 用邮件 API 发 OTP。泛化的 Elixir HTTP 发信(HTTPoison / Finch 批处理、Webhook)见 用 Elixir 发邮件——本文不重复纯传输层写法。
你负责什么,Rovela 发什么
Phoenix 应用负责:
- 用
:crypto.strong_rand_bytes/1取0..999_999并补零成六位(不要用Enum.random/1做密钥材料;不要自造 PRNG) - 对
email <> ":" <> code做 HMAC-SHA256(CODE_PEPPER,放在 runtime config / 环境变量) - 过期(文档示例约 10 分钟)、尝试计数、「每地址每分钟最多一封」重发门闩
- 按规范化邮箱 upsert,新发送作废旧码
- 校验时用
Plug.Crypto.secure_compare/2恒定时间比较;成功后删除行 - 是否暴露「账号是否存在」由产品决定,但对外文案要一致
- 对 LiveView 事件 / Controller 的 send、verify 做限流
Rovela 负责:
POST https://api.rovela.dev/emails,Authorization: Bearer+sending_access密钥(runtime 里的ROVELA_API_KEY——勿提交仓库,绝不放进assets/、LiveView 客户端 hook 或window)- 排队、签名、重试、退信/投诉处理
发送接口返回 200 只表示已接受并入队,不等于「已进收件箱」。
运行时配置:sending_access 不要进浏览器
# config/runtime.exs
import Config
if config_env() == :prod do
config :acme, Acme.Mailer,
rovela_api_key: System.fetch_env!("ROVELA_API_KEY"),
code_pepper: System.fetch_env!("CODE_PEPPER"),
otp_from: System.get_env("OTP_FROM") || "Acme <security@mail.acme.com>",
otp_ttl_minutes: String.to_integer(System.get_env("OTP_TTL_MINUTES") || "10"),
resend_cooldown_sec: String.to_integer(System.get_env("OTP_RESEND_COOLDOWN_SEC") || "60"),
max_attempts: String.to_integer(System.get_env("OTP_MAX_ATTEMPTS") || "5")
end
LiveView assigns 与 phx- hook 绝不能拿到 ROVELA_API_KEY。只有调用 Rovela 的 BEAM 进程才该读它。生产 OTP 发送用 sending_access 就够、也更合适(鉴权、快速开始)。
用 Req 写精简 Rovela 客户端(含 Idempotency-Key)
# lib/acme/rovela.ex
defmodule Acme.Rovela do
@moduledoc false
@endpoint "https://api.rovela.dev/emails"
def send_email!(%{to: to, subject: subject, html: html, text: text, idempotency_key: key}) do
api_key = Application.fetch_env!(:acme, Acme.Mailer)[:rovela_api_key]
from = Application.fetch_env!(:acme, Acme.Mailer)[:otp_from]
case Req.post(@endpoint,
json: %{from: from, to: to, subject: subject, html: html, text: text},
headers: [
{"authorization", "Bearer #{api_key}"},
{"idempotency-key", key}
]
) do
{:ok, %{status: status, body: body}} when status in 200..299 ->
body
{:ok, %{status: status, body: body}} ->
raise "Rovela send failed: #{status} #{inspect(body)}"
{:error, reason} ->
raise "Rovela send transport error: #{inspect(reason)}"
end
end
end
Finch 同理:用 Finch.build(:post, …) 带上同样的 Header 与 JSON。关键 Header 是 Idempotency-Key(最长 256 字符;记住 24 小时——API: emails)。
落库哈希:strong_rand_bytes + HMAC,不是裸 SHA-256
六位数字只有一百万种可能。泄露一张裸 SHA-256 表,瞬间就能反推。按文档:用永远不离开服务器的 pepper 做 HMAC。
# lib/acme/accounts/otp_crypto.ex
defmodule Acme.Accounts.OtpCrypto do
@moduledoc false
def normalize_email(raw) when is_binary(raw) do
raw |> String.trim() |> String.downcase()
end
def mint_code do
# 操作系统 CSPRNG — 不要用 Enum.random/1 生成密钥材料
<<n::unsigned-integer-32>> = :crypto.strong_rand_bytes(4)
rem(n, 1_000_000) |> Integer.to_string() |> String.pad_leading(6, "0")
end
def hash_code(email, code) do
pepper = Application.fetch_env!(:acme, Acme.Mailer)[:code_pepper]
:crypto.mac(:hmac, :sha256, pepper, email <> ":" <> code)
|> Base.encode16(case: :lower)
end
def safe_equal_hex(a, b) when is_binary(a) and is_binary(b) do
Plug.Crypto.secure_compare(a, b)
end
end
建议表结构(每个邮箱一条有效码),与验证码示例一致:
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()
);
用 Ecto(Acme.Accounts.VerificationCode)映射即可:唯一 email、code_hash、expires_at、attempts、sent_at。
Context:发送与校验
# lib/acme/accounts/otp.ex
defmodule Acme.Accounts.Otp do
import Ecto.Query
alias Acme.Repo
alias Acme.Accounts.{OtpCrypto, VerificationCode}
alias Acme.Rovela
@safe_send_msg "若该地址可收信,验证码已在路上。"
def send_otp(raw_email) do
email = OtpCrypto.normalize_email(raw_email)
cfg = Application.fetch_env!(:acme, Acme.Mailer)
cooldown = cfg[:resend_cooldown_sec]
ttl = cfg[:otp_ttl_minutes]
recent? =
from(v in VerificationCode,
where: v.email == ^email and v.sent_at > ago(^cooldown, "second")
)
|> Repo.exists?()
if recent? do
{:error, :too_soon}
else
code = OtpCrypto.mint_code()
code_hash = OtpCrypto.hash_code(email, code)
expires_at = DateTime.utc_now() |> DateTime.add(ttl * 60, :second)
%VerificationCode{}
|> VerificationCode.changeset(%{
email: email,
code_hash: code_hash,
expires_at: expires_at,
attempts: 0,
sent_at: DateTime.utc_now()
})
|> Repo.insert(
on_conflict: {:replace, [:code_hash, :expires_at, :attempts, :sent_at]},
conflict_target: :email
)
# 每次逻辑发送稳定:哈希前缀。新码 ⇒ 新 Key。
idempotency_key = "otp-" <> String.slice(code_hash, 0, 32)
Rovela.send_email!(%{
to: email,
subject: "Your Acme code: #{code}",
html: """
<p>Your verification code is</p>
<p style="font-size:28px;font-weight:600;letter-spacing:4px">#{code}</p>
<p>It expires in #{ttl} minutes. If you did not ask for it, ignore this email.</p>
""",
text:
"Your verification code is #{code}. It expires in #{ttl} minutes. If you did not ask for it, ignore this email.",
idempotency_key: idempotency_key
})
{:ok, @safe_send_msg}
end
end
def verify_otp(raw_email, raw_code) do
email = OtpCrypto.normalize_email(raw_email)
code = String.trim(to_string(raw_code))
max_attempts = Application.fetch_env!(:acme, Acme.Mailer)[:max_attempts]
with true <- Regex.match?(~r/^\d{6}$/, code),
%VerificationCode{} = row <- Repo.get(VerificationCode, email) do
cond do
DateTime.compare(row.expires_at, DateTime.utc_now()) == :lt ->
Repo.delete(row)
{:error, :expired}
row.attempts >= max_attempts ->
{:error, :locked}
true ->
candidate = OtpCrypto.hash_code(email, code)
if OtpCrypto.safe_equal_hex(candidate, row.code_hash) do
Repo.delete(row)
# 在此签发 session / token
:ok
else
row
|> Ecto.Changeset.change(attempts: row.attempts + 1)
|> Repo.update!()
{:error, :invalid}
end
end
else
_ -> {:error, :invalid}
end
end
end
把验证码放进主题有利于锁屏预览——多数产品可以,公共大屏慎用(验证码文档)。务必同时发 html 与 text,并在纯文本里带上数字(POST /emails)。
在并发下若要更严的尝试计数,优先用文档里的单条 UPDATE … SET attempts = attempts + 1 WHERE … RETURNING code_hash,而不是先读后写。
LiveView 表单(仅服务端事件)或 Controller
LiveView 让密钥留在服务端:浏览器只提交 email/code 给 LiveView 进程;进程再调 Context。
# lib/acme_web/live/otp_live.ex
defmodule AcmeWeb.OtpLive do
use AcmeWeb, :live_view
alias Acme.Accounts.Otp
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, email: "", code: "", flash_msg: nil)}
end
@impl true
def handle_event("send", %{"email" => email}, socket) do
# 另加每 IP / 每用户节流(Hammer、PlugAttack 或自建 ETS)
case Otp.send_otp(email) do
{:ok, msg} -> {:noreply, assign(socket, email: email, flash_msg: msg)}
{:error, :too_soon} -> {:noreply, assign(socket, flash_msg: "请稍后再重发。")}
end
end
def handle_event("verify", %{"email" => email, "code" => code}, socket) do
case Otp.verify_otp(email, code) do
:ok ->
# put session / redirect
{:noreply, push_navigate(socket, to: ~p"/app")}
{:error, reason} ->
{:noreply, assign(socket, flash_msg: "无法校验(#{reason})。")}
end
end
end
若面向 SPA 或移动端,可用 JSON Controller:
# lib/acme_web/controllers/otp_controller.ex
defmodule AcmeWeb.OtpController do
use AcmeWeb, :controller
alias Acme.Accounts.Otp
def send(conn, %{"email" => email}) do
case Otp.send_otp(email) do
{:ok, message} -> json(conn, %{message: message})
{:error, :too_soon} -> conn |> put_status(429) |> json(%{error: "too_soon"})
end
end
def verify(conn, %{"email" => email, "code" => code}) do
case Otp.verify_otp(email, code) do
:ok -> json(conn, %{ok: true})
{:error, reason} ->
status = if reason in [:locked, :too_soon], do: 429, else: 400
conn |> put_status(status) |> json(%{error: reason})
end
end
end
Context 内「每邮箱冷却」与边缘「每 IP」限流解决的是不同问题——两者都要。加固清单见 邮箱 OTP 安全。
Idempotency-Key:重试不发双码
Oban Worker、代理、AI agent 都会重试。没有 Idempotency-Key,一次超时就可能发出两封内容不一致的验证码。
- Header 最长 256 字符;Rovela 记住 24 小时,重放返回原响应(API: emails)。
- 用码哈希派生(
otp-+ 前 32 位 hex),一次逻辑 mint 对应一封入队邮件。 - 冷却后的主动重发会 mint 新码 → 新哈希 → 新 Key。不要拿旧 Key 发新码。
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer $ROVELA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-smoke-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 / 魔法链接进垃圾箱 排查。要不要用数字码还是确认链接,见 验证码 vs 链接。
Phoenix 里:验证码 vs 魔法链接
| 六位 OTP(本文) | 魔法链接 | |
|---|---|---|
| 用户动作 | 输入数字 | 点击 URL |
| 密钥熵 | 低 → 必须 HMAC + pepper | 高熵 URL token → 通常对 token 做 SHA-256 即可 |
| 文档形态 | 验证码 | 密码重置 / 链接模式 |
| Phoenix 表面 | LiveView send/verify 或 POST /auth/otp/* | POST /auth/magic/request + GET /auth/magic?token= |
| 点击追踪 | 次要 | 安全发送域建议关闭 |
两者都走同一个 POST /emails。Schema 与 Context 分开——不要把 OTP 行复用成魔法链接 token。无论哪种,都不要把 sending_access 塞进 LiveView 客户端 JS。
上线前清单
:crypto.strong_rand_bytes+ HMAC pepper;永不存明文码。- 约 10 分钟 TTL、尝试上限、每邮箱重发冷却、每 IP 限流。
- Upsert 作废旧码;校验成功删除。
- 同时有
html与text;主题是否带码按场景决定。 ROVELA_API_KEY(sending_access)只放 runtime config / 环境变量——不进 assets、不进 LiveView assigns。- 每次逻辑发送带稳定
Idempotency-Key;新码 → 新 Key。 - 若隐藏账号存在性,对外文案统一。
- From 域已验证;进垃圾箱则走 spam 清单。
延伸阅读
- 文档:邮箱验证码 · POST /emails · 快速开始 · 鉴权。
- 通用 OTP:用邮件 API 发 OTP · 邮箱 OTP 安全。
- 产品选择:验证码 vs 确认链接。
- 投递:OTP / 魔法链接进垃圾箱。
- Elixir 纯传输兄弟篇:用 Elixir 发邮件。
- 魔法链接兄弟篇(另一套原语):Next.js · NestJS · FastAPI。
- 其它 OTP 栈:Express · Rails · Django · Go。
一句话: 把注册/登录 OTP 放进 Phoenix Context——用 CSPRNG 生成、HMAC 落库、限流 send/verify,再用带 Idempotency-Key 的一次 Req(或 Finch)POST 交给 Rovela——别把 sending_access 放进 LiveView 客户端,也别把邮件 API 当成你的认证服务器。