Skip to main content

authx_plugins/email_otp/
service.rs

1use std::time::Duration;
2
3use chrono::Utc;
4use tracing::instrument;
5
6use authx_core::{
7    brute_force::KeyedRateLimiter,
8    crypto::sha256_hex,
9    error::{AuthError, Result},
10    events::{AuthEvent, EventBus},
11    models::{CreateSession, Session, User},
12};
13use authx_storage::ports::{SessionRepository, UserRepository};
14
15use crate::one_time_token::{OneTimeTokenStore, TokenKind};
16
17/// Maximum OTP requests per email per 10-minute window before throttling.
18const ISSUE_RATE_MAX: u32 = 3;
19const ISSUE_RATE_WINDOW: Duration = Duration::from_secs(10 * 60);
20
21#[derive(Debug)]
22pub struct EmailOtpVerifyResponse {
23    pub user: User,
24    pub session: Session,
25    pub token: String,
26}
27
28/// Email OTP authentication — issues a short-lived one-time code (token)
29/// that the caller sends to the user's email address.
30///
31/// # Flow
32/// 1. `issue(email)` → raw token (send in email). Returns `None` for unknown emails.
33///    Rate-limited to [`ISSUE_RATE_MAX`] requests per email per 10-minute window.
34/// 2. `verify(raw_token, ip)` → session created, `EmailOtpVerifyResponse` returned.
35pub struct EmailOtpService<S> {
36    storage: S,
37    events: EventBus,
38    token_store: OneTimeTokenStore,
39    session_ttl_secs: i64,
40    issue_limiter: KeyedRateLimiter,
41}
42
43impl<S> EmailOtpService<S>
44where
45    S: UserRepository + SessionRepository + Clone + Send + Sync + 'static,
46{
47    pub fn new(storage: S, events: EventBus, session_ttl_secs: i64) -> Self {
48        Self {
49            storage,
50            events,
51            token_store: OneTimeTokenStore::new(Duration::from_secs(10 * 60)),
52            session_ttl_secs,
53            issue_limiter: KeyedRateLimiter::new(ISSUE_RATE_MAX, ISSUE_RATE_WINDOW),
54        }
55    }
56
57    /// Issue an OTP token for the given email. Returns `None` for unknown emails
58    /// (avoids user enumeration). Rate-limited per email address.
59    #[instrument(skip(self), fields(email = %email))]
60    pub async fn issue(&self, email: &str) -> Result<Option<String>> {
61        if !self.issue_limiter.check_and_record(email) {
62            tracing::warn!(email = %email, "email otp issue rate limit exceeded");
63            return Err(AuthError::AccountLocked);
64        }
65
66        let user = match UserRepository::find_by_email(&self.storage, email).await? {
67            Some(u) => u,
68            None => {
69                tracing::debug!("email otp requested for unknown email");
70                return Ok(None);
71            }
72        };
73        let token = self.token_store.issue(user.id, TokenKind::EmailOtp);
74        tracing::info!(user_id = %user.id, "email otp issued");
75        Ok(Some(token))
76    }
77
78    /// Consume the OTP token and create an authenticated session.
79    #[instrument(skip(self, raw_token), fields(ip = %ip))]
80    pub async fn verify(&self, raw_token: &str, ip: &str) -> Result<EmailOtpVerifyResponse> {
81        let user_id = self
82            .token_store
83            .consume(raw_token, TokenKind::EmailOtp)
84            .ok_or(AuthError::InvalidToken)?;
85
86        let user = UserRepository::find_by_id(&self.storage, user_id)
87            .await?
88            .ok_or(AuthError::UserNotFound)?;
89
90        let raw: [u8; 32] = rand::Rng::r#gen(&mut rand::thread_rng());
91        let raw_str = hex::encode(raw);
92        let token_hash = sha256_hex(raw_str.as_bytes());
93
94        let session = SessionRepository::create(
95            &self.storage,
96            CreateSession {
97                user_id: user.id,
98                token_hash,
99                device_info: serde_json::Value::Null,
100                ip_address: ip.to_owned(),
101                org_id: None,
102                expires_at: Utc::now() + chrono::Duration::seconds(self.session_ttl_secs),
103            },
104        )
105        .await?;
106
107        self.events.emit(AuthEvent::SignIn {
108            user: user.clone(),
109            session: session.clone(),
110        });
111        tracing::info!(user_id = %user_id, session_id = %session.id, "email otp sign-in complete");
112        Ok(EmailOtpVerifyResponse {
113            user,
114            session,
115            token: raw_str,
116        })
117    }
118}