use std::sync::Arc;
use async_trait::async_trait;
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use dashmap::DashMap;
use rand::Rng;
use serde::{Deserialize, Serialize};
use crate::{
audit::logger::{AuditEventType, SecretType, get_audit_logger},
error::{AuthError, Result},
session::{SessionStore, unix_now},
};
pub const OTP_TTL_SECS: u64 = 600;
pub(super) const MAX_VERIFY_ATTEMPTS: u32 = 3;
pub(super) const OTP_RATE_WINDOW_SECS: u64 = 900;
pub(super) const OTP_RATE_MAX: u32 = 3;
pub const OTP_PROVIDER: &str = "otp";
mod allow_list;
pub mod postgres;
pub use allow_list::{OTP_DELIVERY_QUEUE_CAPACITY, OtpAllowGate, OtpAllowList, PgOtpAllowList};
#[derive(Debug, Clone)]
struct OtpRecord {
code: String,
link_hash: Vec<u8>,
expires: u64,
attempts: u32,
}
pub struct OtpIssue {
pub code: String,
pub link_token: String,
}
impl std::fmt::Debug for OtpIssue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OtpIssue").finish_non_exhaustive()
}
}
impl OtpIssue {
#[must_use]
pub fn generate() -> Self {
use base64::Engine as _;
let code = format!("{:06}", rand::rng().random_range(0u32..1_000_000));
let bytes: [u8; 32] = rand::rng().random();
Self {
code,
link_token: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes),
}
}
}
pub(crate) fn link_token_hash(token: &str) -> Vec<u8> {
use sha2::Digest as _;
sha2::Sha256::digest(token.as_bytes()).to_vec()
}
impl OtpRecord {
fn is_expired(&self) -> bool {
Self::is_expired_at(self.expires, unix_now().ok())
}
fn is_expired_at(expires: u64, now: Option<u64>) -> bool {
now.is_none_or(|now| now >= expires)
}
}
#[derive(Debug, Clone)]
struct RateRecord {
count: u32,
window_start: u64,
guesses: u32,
}
#[async_trait]
pub trait OtpStore: Send + Sync {
async fn sweep_expired(&self) -> Result<u64>;
async fn create_otp(&self, email: &str) -> Result<OtpIssue>;
async fn verify_otp(&self, email: &str, code: &str) -> Result<()>;
async fn redeem_link(&self, link_token: &str) -> Result<String>;
}
pub struct InMemoryOtpStore {
codes: DashMap<String, OtpRecord>,
rate_limits: DashMap<String, RateRecord>,
}
impl InMemoryOtpStore {
#[must_use]
pub fn new() -> Self {
Self {
codes: DashMap::new(),
rate_limits: DashMap::new(),
}
}
fn charge_guess(&self, email: &str, now: u64) -> u32 {
let mut entry = self.rate_limits.entry(email.to_string()).or_insert(RateRecord {
count: 0,
window_start: now,
guesses: 0,
});
if now >= entry.window_start + OTP_RATE_WINDOW_SECS {
entry.count = 0;
entry.window_start = now;
entry.guesses = 0;
}
entry.guesses += 1;
entry.guesses
}
#[must_use]
pub fn pending_count(&self) -> usize {
self.codes.len()
}
}
impl Default for InMemoryOtpStore {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl OtpStore for InMemoryOtpStore {
async fn sweep_expired(&self) -> Result<u64> {
let now = unix_now()?;
let before = self.codes.len() + self.rate_limits.len();
self.codes.retain(|_, r| r.expires > now);
self.rate_limits.retain(|_, r| now < r.window_start + OTP_RATE_WINDOW_SECS);
Ok((before - (self.codes.len() + self.rate_limits.len())) as u64)
}
async fn create_otp(&self, email: &str) -> Result<OtpIssue> {
let now = unix_now()?;
{
let mut entry = self.rate_limits.entry(email.to_string()).or_insert(RateRecord {
count: 0,
window_start: now,
guesses: 0,
});
if now >= entry.window_start + OTP_RATE_WINDOW_SECS {
entry.count = 0;
entry.window_start = now;
entry.guesses = 0;
}
if entry.count >= OTP_RATE_MAX {
return Err(AuthError::RateLimited {
retry_after_secs: (entry.window_start + OTP_RATE_WINDOW_SECS)
.saturating_sub(now),
});
}
entry.count += 1;
}
let issue = OtpIssue::generate();
self.codes.insert(
email.to_string(),
OtpRecord {
code: issue.code.clone(),
link_hash: link_token_hash(&issue.link_token),
expires: now + OTP_TTL_SECS,
attempts: 0,
},
);
Ok(issue)
}
async fn verify_otp(&self, email: &str, code: &str) -> Result<()> {
let mut entry = self.codes.get_mut(email).ok_or_else(|| AuthError::InvalidToken {
reason: "no pending OTP for email".into(),
})?;
if entry.is_expired() {
drop(entry);
self.codes.remove(email);
return Err(AuthError::InvalidToken {
reason: "OTP has expired".into(),
});
}
entry.attempts += 1;
let guesses = self.charge_guess(email, unix_now()?);
if entry.attempts > MAX_VERIFY_ATTEMPTS || guesses > MAX_VERIFY_ATTEMPTS {
drop(entry);
self.codes.remove(email);
return Err(AuthError::RateLimited {
retry_after_secs: OTP_RATE_WINDOW_SECS,
});
}
if !crate::constant_time::ConstantTimeOps::compare_str(&entry.code, code) {
return Err(AuthError::InvalidToken {
reason: "invalid OTP code".into(),
});
}
drop(entry);
self.codes.remove(email);
Ok(())
}
async fn redeem_link(&self, link_token: &str) -> Result<String> {
let presented = link_token_hash(link_token);
let holder = self
.codes
.iter()
.find(|entry| entry.value().link_hash == presented)
.map(|entry| entry.key().clone());
let (email, record) = holder
.and_then(|email| {
self.codes.remove_if(&email, |_, record| record.link_hash == presented)
})
.ok_or_else(|| AuthError::InvalidToken {
reason: "no pending link for token".into(),
})?;
if record.is_expired() {
return Err(AuthError::InvalidToken {
reason: "link has expired".into(),
});
}
Ok(email)
}
}
#[async_trait]
pub trait EmailDelivery: Send + Sync {
async fn send_otp(&self, email: &str, issue: &OtpIssue) -> Result<String>;
}
pub struct NoopEmailDelivery;
#[async_trait]
impl EmailDelivery for NoopEmailDelivery {
async fn send_otp(&self, email: &str, issue: &OtpIssue) -> Result<String> {
let code = &issue.code;
tracing::info!(email, code, "NoopEmailDelivery: OTP code (NOT sent via real email)");
Ok(format!("noop-{email}-{code}"))
}
}
#[derive(Clone)]
pub struct OtpRouteState {
pub otp_store: Arc<dyn OtpStore>,
pub email_delivery: Arc<dyn EmailDelivery>,
pub session_store: Arc<dyn SessionStore>,
pub account_store: Option<Arc<dyn crate::account_linking::AccountStore>>,
pub allow_gate: Option<OtpAllowGate>,
pub session_cookies: Option<crate::SessionCookies>,
}
#[derive(Debug, Deserialize)]
pub struct OtpRequest {
pub email: String,
}
#[derive(Debug, Serialize)]
pub struct OtpResponse {
pub message_id: String,
}
#[derive(Deserialize)]
pub struct VerifyRequest {
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub token: Option<String>,
}
pub async fn otp_send(
State(state): State<Arc<OtpRouteState>>,
Json(req): Json<OtpRequest>,
) -> Response {
let email = req.email.trim().to_lowercase();
if email.is_empty() {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({
"error": "invalid_email",
"message": "email must not be blank"
})),
)
.into_response();
}
let issue = match state.otp_store.create_otp(&email).await {
Ok(issue) => issue,
Err(AuthError::RateLimited { retry_after_secs }) => {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({
"error": "rate_limited",
"retry_after_secs": retry_after_secs
})),
)
.into_response();
},
Err(e) => {
tracing::error!(error = %e, "OTP store error");
return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
},
};
let message_id = if let Some(gate) = &state.allow_gate {
gate.deliver_if_admitted(&email, issue).await;
String::new()
} else {
match state.email_delivery.send_otp(&email, &issue).await {
Ok(id) => id,
Err(e) => {
tracing::error!(error = %e, "Email delivery failed");
return (StatusCode::INTERNAL_SERVER_ERROR, "delivery failed").into_response();
},
}
};
let logger = get_audit_logger();
logger.log_success(
AuditEventType::OauthStart,
SecretType::CsrfToken,
None,
&format!("otp_send:{email}"),
);
(StatusCode::OK, Json(OtpResponse { message_id })).into_response()
}
fn invalid_otp() -> Response {
(
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({
"error": "invalid_otp",
"message": "invalid or expired OTP code"
})),
)
.into_response()
}
fn invalid_request() -> Response {
(
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({
"error": "invalid_request",
"message": "send either `token`, or `email` and `code`"
})),
)
.into_response()
}
pub async fn otp_verify(
State(state): State<Arc<OtpRouteState>>,
Json(req): Json<VerifyRequest>,
) -> Response {
let logger = get_audit_logger();
let redeemed = match (req.token, req.email, req.code) {
(Some(token), None, None) => state.otp_store.redeem_link(&token).await,
(None, Some(email), Some(code)) => {
let email = email.trim().to_lowercase();
state.otp_store.verify_otp(&email, &code).await.map(|()| email)
},
_ => return invalid_request(),
};
let email = match redeemed {
Ok(email) => email,
Err(AuthError::RateLimited { retry_after_secs }) => {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({
"error": "rate_limited",
"retry_after_secs": retry_after_secs
})),
)
.into_response();
},
Err(e) => {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::CsrfToken,
None,
"otp_verify",
&e.to_string(),
);
return invalid_otp();
},
};
if let Some(gate) = &state.allow_gate {
if !gate.admits(&email).await {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::CsrfToken,
None,
"otp_verify",
"address refused by otp_allowed_query",
);
return invalid_otp();
}
}
let user_id = match &state.account_store {
Some(accounts) => {
match accounts
.link_or_create_user(None, Some(&email), true, OTP_PROVIDER, &email)
.await
{
Ok(result) => result.user_id,
Err(e) => {
tracing::error!(error = %e, "OTP account resolution failed");
return (StatusCode::INTERNAL_SERVER_ERROR, "user resolution failed")
.into_response();
},
}
},
None => format!("otp:{email}"),
};
let expires_at = match unix_now() {
Ok(now) => now + 3_600, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response(),
};
let tokens = match state.session_store.create_session(&user_id, expires_at).await {
Ok(t) => t,
Err(e) => {
if let Some(resp) = crate::totp_mfa::second_factor_response(&e) {
return resp;
}
tracing::error!(error = %e, "Session creation failed after OTP verify");
return (StatusCode::INTERNAL_SERVER_ERROR, "session creation failed").into_response();
},
};
logger.log_success(
AuditEventType::AuthSuccess,
SecretType::SessionToken,
Some(user_id),
"otp_verify",
);
crate::session_cookies::session_response(state.session_cookies.as_ref(), tokens)
}
#[cfg(test)]
mod tests;