Axum / Rust 发送邮箱验证码(2026):用 HTTP API 发 OTP
在 Tokio 上的 Axum 做注册/登录邮箱验证码:OsRng、HMAC 落 DashMap/Redis/SQL、Tower 限流、用 reqwest 调 POST /emails 并带 Idempotency-Key——对照 Actix 提取器与 service;优先 HTTP 而非 lettre;Rovela 只负责投递。
为什么选 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_state | web::Data<T> |
| JSON 体 | Json<T> 提取器 | web::Json<T> |
| 中间件 | 路由器上的 Tower 层 | Actix middleware / services |
| HTTP 客户端 | reqwest | reqwest |
| CSPRNG | rand::rngs::OsRng | rand::rngs::OsRng |
| 进程内存储 | DashMap | DashMap |
| 运行时 | 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 443 | 465 / 587(25 常被拦) |
| 幂等 | Idempotency-Key 头 | 应用自建去重 |
| 反馈 | 消息 id + webhook | 稍后 bounce DSN |
| 容器 / Cloud Run | 443 通常畅通 | 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 + pepper | URL token 熵高 → 通常对 token 做 SHA-256 即可 |
| 文档形态 | 验证码 | 密码重置 / 链接模式 |
| Axum 表面 | POST /auth/otp/send + POST /auth/otp/verify | POST /auth/magic/request + GET /auth/magic?token= |
| 点击追踪 | 次要顾虑 | 安全发信域上建议关闭 |
两者都调用同一个 POST /emails。分开存储——不要把 OTP 记录当魔法链接 token。无论哪种,都不要把 sending_access 下发到浏览器或 WASM 客户端。
上线前清单
OsRng/getrandom+ HMAC pepper;永不存明文码;绝不用弱 PRNG 做 OTP。- 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、按 IP 的 Tower / 代理限流。
- upsert / 覆盖使旧码失效;校验成功后删除。
- 同时发
html与text;可选主题行验证码便于预览。 ROVELA_API_KEY(sending_access)仅在服务端——永不进前端 / WASM。Idempotency-Key对每次逻辑发送稳定;新码 → 新 key。- 若隐藏账户存在性,对外文案保持一致。
- OTP 优先 HTTP API 而非 SMTP;发信域已验证;进箱不佳时对照垃圾箱清单。
一句话: 在 Axum 处理器里用 OsRng 铸造验证码,HMAC 落库(DashMap、Redis 或 SQL),用 Tower 限流,并以带 Idempotency-Key 的一次 reqwest POST 交给 Rovela——优先于 lettre SMTP,路由/提取器对照 Actix OTP 兄弟篇,永远不要把邮件 API 当成你的认证服务器。