authx_plugins/email_otp/
service.rs1use 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
17const 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
28pub 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 #[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 #[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}