← 返回博客

Phoenix / Elixir 发送邮箱验证码(2026):用 HTTP API 发 OTP

在 Phoenix 里做注册/登录邮箱验证码::crypto.strong_rand_bytes、HMAC 落库、LiveView 或 Controller、用 Req/Finch 调 POST /emails,以及 Idempotency-Key——Rovela 只负责投递。

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

为什么是 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。

上线前清单

  1. :crypto.strong_rand_bytes + HMAC pepper;永不存明文码。
  2. 约 10 分钟 TTL、尝试上限、每邮箱重发冷却、每 IP 限流。
  3. Upsert 作废旧码;校验成功删除。
  4. 同时有 html 与 text;主题是否带码按场景决定。
  5. ROVELA_API_KEY(sending_access)只放 runtime config / 环境变量——不进 assets、不进 LiveView assigns。
  6. 每次逻辑发送带稳定 Idempotency-Key;新码 → 新 Key。
  7. 若隐藏账号存在性,对外文案统一。
  8. From 域已验证;进垃圾箱则走 spam 清单。

延伸阅读

  1. 文档:邮箱验证码 · POST /emails · 快速开始 · 鉴权。
  2. 通用 OTP:用邮件 API 发 OTP · 邮箱 OTP 安全。
  3. 产品选择:验证码 vs 确认链接。
  4. 投递:OTP / 魔法链接进垃圾箱。
  5. Elixir 纯传输兄弟篇:用 Elixir 发邮件。
  6. 魔法链接兄弟篇(另一套原语):Next.js · NestJS · FastAPI。
  7. 其它 OTP 栈:Express · Rails · Django · Go。

一句话: 把注册/登录 OTP 放进 Phoenix Context——用 CSPRNG 生成、HMAC 落库、限流 send/verify,再用带 Idempotency-Key 的一次 Req(或 Finch)POST 交给 Rovela——别把 sending_access 放进 LiveView 客户端,也别把邮件 API 当成你的认证服务器。