← 返回博客

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

在 Tokio 上的 Axum 做注册/登录邮箱验证码:OsRng、HMAC 落 DashMap/Redis/SQL、Tower 限流、用 reqwest 调 POST /emails 并带 Idempotency-Key——对照 Actix 提取器与 service;优先 HTTP 而非 lettre;Rovela 只负责投递。

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

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

Axum(Tokio)本身已经给注册/登录划出清晰边界:Router 路由、类型化提取器(State、Json、Extension)、Tower 中间件做限流,以及 Arc 背后的共享存储。你不需要 Rovela 的 Rust SDK、也不需要一个「OTP 产品」接口,更不需要把邮件 API 伪装成身份提供方。

你真正需要的是 邮箱验证码示例 里的配方:应用用 rand::rngs::OsRng(或 getrandom)生成六位数字,只存 HMAC(永不存明文),设过期与尝试次数,再通过 reqwest 带上 html 与 text 调用 POST /emails。Rovela 负责入队、DKIM 签名、重试与事件记录。它不会生成验证码、校验验证码,也不会创建会话。

Rovela 没有 OTP 功能。 密码学、存储条目、重发节流、兑换逻辑、会话 cookie / JWT 都由你的 Axum 服务拥有。Rovela 只负责投递。

本文聚焦 Axum(Axum 0.7+ + Tokio):「发码」与「验码」处理器、用 State<AppState> 共享 OTP 服务与邮件客户端、Tower 层做按 IP 限流,以及内存或 Redis 的 HMAC 存储,避免超时重试入队两封互相矛盾的验证码。这不是魔法链接教程——魔法链接见 Next.js、NestJS、FastAPI。同系列 OTP 文:Actix Web(最接近的 Rust 兄弟篇——路由/提取器 vs Actix service)、Go、Spring Boot、Ktor。数字码 vs 链接:邮箱验证:验证码还是确认链接。语言无关 OTP:用邮件发送 OTP。通用 Rust 传输层(reqwest 批量、lettre SMTP、Axum 接线、webhook)——不是 OTP 所有权——见 在 Rust 中发送邮件;本文不重复信号量扇出或 lettre 中继。

你拥有什么,Rovela 发送什么

你的 Axum 服务拥有:

  • 操作系统 CSPRNG → 六位补零数字(绝不用非密码学 PRNG 生成密钥)
  • 用 hmac + sha2 对 email + ":" + code 做 HMAC-SHA256,服务器端 pepper(CODE_PEPPER)
  • 过期(文档示例约 10 分钟)、尝试计数、「每地址每分钟一封」重发闸门
  • 按规范化邮箱 upsert,新发送使旧码失效(DashMap、Redis 或 SQL)
  • 兑换时常量时间比较(subtle::ConstantTimeEq);成功后删除 / 失效
  • 是否暴露账户存在性由产品决定,对外响应宜保持一致
  • 对 /auth/otp/send 与 /auth/otp/verify 限流(Tower / governor / 反向代理)

Rovela 拥有:

  • POST https://api.rovela.dev/emails,Authorization: Bearer + sending_access 密钥(环境变量 ROVELA_API_KEY——永不提交、永不放进 WASM/前端包、永不打印日志)
  • 排队、签名、重试、退信/投诉处理

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

环境变量与精简 Rovela 客户端(reqwest + Idempotency-Key)

# Cargo.toml(节选)
[dependencies]
axum = "0.7"
tower = "0.5"
tower-http = { version = "0.6", features = ["trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net"] }
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
rand = "0.8"
subtle = "2"
dashmap = "6"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
thiserror = "1"
// src/config.rs
use std::env;
use std::time::Duration;

#[derive(Clone)]
pub struct Config {
    pub api_key: String,
    pub code_pepper: Vec<u8>,
    pub from: String,
    pub ttl: Duration,
    pub resend_cooldown: Duration,
    pub max_attempts: u32,
}

impl Config {
    pub fn from_env() -> Self {
        Self {
            api_key: must_env("ROVELA_API_KEY"), // sending_access
            code_pepper: must_env("CODE_PEPPER").into_bytes(),
            from: env::var("OTP_FROM")
                .unwrap_or_else(|_| "Acme <security@mail.acme.com>".into()),
            ttl: Duration::from_secs(
                env::var("OTP_TTL_MINUTES").ok().and_then(|v| v.parse().ok()).unwrap_or(10) * 60,
            ),
            resend_cooldown: Duration::from_secs(
                env::var("OTP_RESEND_COOLDOWN_SEC")
                    .ok()
                    .and_then(|v| v.parse().ok())
                    .unwrap_or(60),
            ),
            max_attempts: env::var("OTP_MAX_ATTEMPTS")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(5),
        }
    }
}

fn must_env(k: &str) -> String {
    env::var(k).unwrap_or_else(|_| panic!("missing env {k}"))
}
// src/rovela.rs
use reqwest::Client;
use serde_json::{json, Value};
use thiserror::Error;

#[derive(Clone)]
pub struct MailClient {
    http: Client,
    api_key: String,
    from: String,
}

pub struct SendInput {
    pub to: String,
    pub subject: String,
    pub html: String,
    pub text: String,
    pub idempotency_key: String,
}

#[derive(Debug, Error)]
pub enum MailError {
    #[error("transport: {0}")]
    Transport(#[from] reqwest::Error),
    #[error("rovela send failed: {0} {1}")]
    Status(u16, String),
}

impl MailClient {
    pub fn new(api_key: String, from: String) -> Self {
        Self {
            http: Client::builder()
                .timeout(std::time::Duration::from_secs(15))
                .build()
                .expect("reqwest client"),
            api_key,
            from,
        }
    }

    pub async fn send(&self, in_: SendInput) -> Result<Value, MailError> {
        let body = json!({
            "from": self.from,
            "to": in_.to,
            "subject": in_.subject,
            "html": in_.html,
            "text": in_.text,
        });
        let res = self
            .http
            .post("https://api.rovela.dev/emails")
            .header("Authorization", format!("Bearer {}", self.api_key))
            .header("Idempotency-Key", in_.idempotency_key)
            .header("Accept", "application/json")
            .json(&body)
            .send()
            .await?;
        let status = res.status().as_u16();
        let text = res.text().await.unwrap_or_default();
        if !(200..300).contains(&status) {
            return Err(MailError::Status(status, text));
        }
        Ok(serde_json::from_str(&text).unwrap_or(json!({})))
    }
}

像对待其他服务端密钥一样接入 ROVELA_API_KEY 与 CODE_PEPPER(Authentication、Quick Start)。生产环境发 OTP 用 sending_access 就够——也更合适。不要把密钥放进可能被序列化进 JSON 响应的结构,也不要通过公开处理器或 AppState 的 Debug 输出泄露。

静态存储:OsRng + HMAC,而不是裸 SHA-256

六位数字只有一百万种可能。泄露的裸 SHA-256 表不到一秒就能反推。按文档来:用永不离开服务器的 pepper 做 HMAC。

// src/crypto.rs
use hmac::{Hmac, Mac};
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::Sha256;
use subtle::ConstantTimeEq;

type HmacSha256 = Hmac<Sha256>;

pub fn normalize_email(raw: &str) -> String {
    raw.trim().to_ascii_lowercase()
}

pub fn mint_code() -> String {
    // CSPRNG — ThreadRng / 小型 PRNG 不适合密钥
    let n = OsRng.next_u32() % 1_000_000;
    format!("{n:06}")
}

pub fn hash_code(pepper: &[u8], email: &str, code: &str) -> String {
    let mut mac = HmacSha256::new_from_slice(pepper).expect("HMAC key");
    mac.update(format!("{email}:{code}").as_bytes());
    hex::encode(mac.finalize().into_bytes())
}

pub fn safe_equal_hex(a: &str, b: &str) -> bool {
    let (Ok(ab), Ok(bb)) = (hex::decode(a), hex::decode(b)) else {
        return false;
    };
    if ab.len() != bb.len() {
        return false;
    }
    ab.ct_eq(&bb).into()
}

建议的表结构(每邮箱一条有效码),与验证码示例一致:

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()
);

演示可用按邮箱键控的 DashMap<String, Record>。多副本请改用 Redis 或 Postgres。

服务层:发送 + 校验(DashMap 存储)

// src/service.rs
use crate::config::Config;
use crate::crypto::{hash_code, mint_code, normalize_email, safe_equal_hex};
use crate::rovela::{MailClient, SendInput};
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use std::sync::Arc;

#[derive(Clone)]
struct Record {
    code_hash: String,
    attempts: u32,
    expires_at: DateTime<Utc>,
    sent_at: DateTime<Utc>,
}

#[derive(Clone)]
pub struct OtpService {
    cfg: Config,
    mail: MailClient,
    store: Arc<DashMap<String, Record>>,
}

const SAFE_SEND_MSG: &str = "If that address can receive mail, a code is on its way.";

impl OtpService {
    pub fn new(cfg: Config, mail: MailClient) -> Self {
        Self {
            cfg,
            mail,
            store: Arc::new(DashMap::new()),
        }
    }

    pub async fn send(&self, raw_email: &str) -> Result<serde_json::Value, String> {
        let email = normalize_email(raw_email);
        let now = Utc::now();

        if let Some(rec) = self.store.get(&email) {
            if now.signed_duration_since(rec.sent_at)
                < chrono::Duration::from_std(self.cfg.resend_cooldown).unwrap()
            {
                return Ok(serde_json::json!({"ok": false, "error": "too_soon"}));
            }
        }

        let code = mint_code();
        let code_hash = hash_code(&self.cfg.code_pepper, &email, &code);
        self.store.insert(
            email.clone(),
            Record {
                code_hash: code_hash.clone(),
                attempts: 0,
                expires_at: now + chrono::Duration::from_std(self.cfg.ttl).unwrap(),
                sent_at: now,
            },
        );

        // 每个逻辑发送稳定:哈希前缀。新码 ⇒ 新 key。
        let idem = format!("otp-{}", &code_hash[..32]);
        let ttl_min = self.cfg.ttl.as_secs() / 60;
        self.mail
            .send(SendInput {
                to: email,
                subject: format!("Your Acme code: {code}"),
                html: format!(
                    "<p>Your verification code is</p>\
                     <p style=\"font-size:28px;font-weight:600;letter-spacing:4px\">{code}</p>\
                     <p>It expires in {ttl_min} minutes. If you did not ask for it, ignore this email.</p>"
                ),
                text: format!(
                    "Your verification code is {code}. It expires in {ttl_min} minutes. \
                     If you did not ask for it, ignore this email."
                ),
                idempotency_key: idem,
            })
            .await
            .map_err(|e| e.to_string())?;

        Ok(serde_json::json!({"ok": true, "message": SAFE_SEND_MSG}))
    }

    pub fn verify(&self, raw_email: &str, raw_code: &str) -> serde_json::Value {
        let email = normalize_email(raw_email);
        let code = raw_code.trim();
        if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
            return serde_json::json!({"ok": false, "error": "invalid"});
        }

        let Some(mut entry) = self.store.get_mut(&email) else {
            return serde_json::json!({"ok": false, "error": "invalid"});
        };
        let now = Utc::now();
        if now > entry.expires_at {
            drop(entry);
            self.store.remove(&email);
            return serde_json::json!({"ok": false, "error": "expired"});
        }
        if entry.attempts >= self.cfg.max_attempts {
            return serde_json::json!({"ok": false, "error": "locked"});
        }

        let candidate = hash_code(&self.cfg.code_pepper, &email, code);
        if !safe_equal_hex(&candidate, &entry.code_hash) {
            entry.attempts += 1;
            return serde_json::json!({"ok": false, "error": "invalid"});
        }

        drop(entry);
        self.store.remove(&email);
        // 在此签发 session / JWT
        serde_json::json!({"ok": true})
    }
}

把验证码放进主题行有助于锁屏预览——多数产品可用;共享屏幕场景可省略(见验证码文档)。务必同时发送 html 与 text,并在文本部分包含数字(POST /emails)。

Redis:SET otp:{email} {json} 并设 TTL,外加 SET otp:cooldown:{email} 1 EX 60。SQL:INSERT … ON CONFLICT (email) DO UPDATE,事务内递增 attempts,成功后 DELETE。

处理器:Axum 提取器 + Tower 备注

Axum 与 Actix 的人体工学不同:你组合 Router,用 .with_state(...) 共享状态,在自由函数里提取 State<T> + Json<T>。Tower 层包裹整个路由器(或嵌套路由组),而不是像 Actix 那样在每个 App 上挂 middleware service。

// src/handlers.rs
use crate::service::OtpService;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use serde::Deserialize;
use std::sync::Arc;

#[derive(Clone)]
pub struct AppState {
    pub otp: OtpService,
}

#[derive(Deserialize)]
pub struct SendBody {
    email: String,
}

#[derive(Deserialize)]
pub struct VerifyBody {
    email: String,
    code: String,
}

pub async fn send_otp(
    State(state): State<Arc<AppState>>,
    Json(body): Json<SendBody>,
) -> impl IntoResponse {
    if body.email.trim().is_empty() {
        return (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({"error": "invalid_email"})),
        )
            .into_response();
    }
    match state.otp.send(&body.email).await {
        Ok(out)
            if out.get("ok") == Some(&serde_json::Value::Bool(false))
                && out.get("error") == Some(&serde_json::Value::String("too_soon".into())) =>
        {
            (
                StatusCode::TOO_MANY_REQUESTS,
                Json(serde_json::json!({"error": "too_soon"})),
            )
                .into_response()
        }
        Ok(out) => (
            StatusCode::OK,
            Json(serde_json::json!({"message": out["message"]})),
        )
            .into_response(),
        Err(_) => (
            StatusCode::BAD_GATEWAY,
            Json(serde_json::json!({"error": "send_failed"})),
        )
            .into_response(),
    }
}

pub async fn verify_otp(
    State(state): State<Arc<AppState>>,
    Json(body): Json<VerifyBody>,
) -> impl IntoResponse {
    let out = state.otp.verify(&body.email, &body.code);
    if out.get("ok") == Some(&serde_json::Value::Bool(true)) {
        return (StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response();
    }
    let err = out
        .get("error")
        .and_then(|v| v.as_str())
        .unwrap_or("invalid");
    let status = if matches!(err, "locked" | "too_soon") {
        StatusCode::TOO_MANY_REQUESTS
    } else {
        StatusCode::BAD_REQUEST
    };
    (status, Json(serde_json::json!({"error": err}))).into_response()
}
// src/main.rs
use axum::{routing::post, Router};
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::trace::TraceLayer;

mod config;
mod crypto;
mod handlers;
mod rovela;
mod service;

use config::Config;
use handlers::{send_otp, verify_otp, AppState};
use rovela::MailClient;
use service::OtpService;

#[tokio::main]
async fn main() {
    let cfg = Config::from_env();
    let mail = MailClient::new(cfg.api_key.clone(), cfg.from.clone());
    let state = Arc::new(AppState {
        otp: OtpService::new(cfg, mail),
    });

    let app = Router::new()
        .route("/auth/otp/send", post(send_otp))
        .route("/auth/otp/verify", post(verify_otp))
        // 在此加 Tower 限流层(如 tower_governor),或在代理处终止 TLS。
        .layer(TraceLayer::new_for_http())
        .with_state(state);

    let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
    let listener = tokio::net::TcpListener::bind(addr).await.expect("bind");
    axum::serve(listener, app).await.expect("serve");
}

Tower 中间件备注: 对 /auth/otp/* 优先使用独立的按 IP 限流(tower_governor、nginx、Cloudflare)。按邮箱冷却留在 OtpService 内——IP 限流与邮箱冷却解决不同滥用路径。不要把 ROVELA_API_KEY 放进处理器可能回显的请求作用域扩展。加固清单见 OTP / 魔法链接进垃圾箱 与验证码文档。

Axum vs Actix OTP(同一契约,不同提取器)

Axum(本文)Actix Web(兄弟篇)
路由Router + route(..., post(...))App + web::route(...).to(...)
共享状态State<Arc<AppState>> / .with_stateweb::Data<T>
JSON 体Json<T> 提取器web::Json<T>
中间件路由器上的 Tower 层Actix middleware / services
HTTP 客户端reqwestreqwest
CSPRNGrand::rngs::OsRngrand::rngs::OsRng
进程内存储DashMapDashMap
运行时Tokio(axum::serve)Actix runtime on Tokio

两者都调用同一个带 Idempotency-Key 的 POST /emails。都不会让 Rovela 生成或校验验证码。已统一 Tower 提取器与 hyper 时选 Axum;栈已在 Actix services 上则选 Actix——OTP 所有权相同。

Idempotency-Key:重试不双发

Worker、反向代理与 AI agent 都会重试。没有 Idempotency-Key,一次超时可能投递两枚互相矛盾的验证码。

  • Header ≤ 256 字符;Rovela 记住 24 小时,重放返回原始响应(API: emails)。
  • 由码哈希派生(otp- + 前 32 位十六进制),使一次逻辑铸造对应一封入队邮件。
  • 冷却后的有意重发会铸造新码 → 新哈希 → 新 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-axum-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."
  }'

为什么 Axum OTP 不优先用 lettre / SMTP

reqwest → POST /emails(本文)lettre SMTP
传输HTTPS 443465 / 587(25 常被拦)
幂等Idempotency-Key 头应用自建去重
反馈消息 id + webhook稍后 bounce DSN
容器 / Cloud Run443 通常畅通SMTP 常受限
OTP 契合度JSON html + text手写 MIME

已依赖 SMTP 时可继续用。新注册/登录数字码请走 HTTP API。更广的 Rust 传输模式(含 lettre 与 webhook 校验)见 在 Rust 中发送邮件。

认证数字码的投递备注(不是魔法 URL)

OTP 邮件短且敏感。优先用已验证子域如 security@mail.example.com,对齐 SPF/DKIM/DMARC,HTML 保持克制。正文(及常见主题行)里的数字不需要可点链接——因此点击追踪改写对魔法链接更关键——但开信/点击追踪仍应落在你控制的域名上。

若进垃圾箱,按 为什么 OTP 与魔法链接进垃圾箱 排查。产品该用数字码还是确认 URL,见 验证码 vs 链接。

Axum 里:验证码 vs 魔法链接

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

两者都调用同一个 POST /emails。分开存储——不要把 OTP 记录当魔法链接 token。无论哪种,都不要把 sending_access 下发到浏览器或 WASM 客户端。

上线前清单

  1. OsRng / getrandom + HMAC pepper;永不存明文码;绝不用弱 PRNG 做 OTP。
  2. 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、按 IP 的 Tower / 代理限流。
  3. upsert / 覆盖使旧码失效;校验成功后删除。
  4. 同时发 html 与 text;可选主题行验证码便于预览。
  5. ROVELA_API_KEY(sending_access)仅在服务端——永不进前端 / WASM。
  6. Idempotency-Key 对每次逻辑发送稳定;新码 → 新 key。
  7. 若隐藏账户存在性,对外文案保持一致。
  8. OTP 优先 HTTP API 而非 SMTP;发信域已验证;进箱不佳时对照垃圾箱清单。

一句话: 在 Axum 处理器里用 OsRng 铸造验证码,HMAC 落库(DashMap、Redis 或 SQL),用 Tower 限流,并以带 Idempotency-Key 的一次 reqwest POST 交给 Rovela——优先于 lettre SMTP,路由/提取器对照 Actix OTP 兄弟篇,永远不要把邮件 API 当成你的认证服务器。