use std::time::Duration;
use chrono::Utc;
use tracing::instrument;
use authx_core::{
brute_force::KeyedRateLimiter,
crypto::sha256_hex,
error::{AuthError, Result},
events::{AuthEvent, EventBus},
models::{CreateSession, Session, User},
};
use authx_storage::ports::{SessionRepository, UserRepository};
use crate::one_time_token::{OneTimeTokenStore, TokenKind};
const ISSUE_RATE_MAX: u32 = 3;
const ISSUE_RATE_WINDOW: Duration = Duration::from_secs(10 * 60);
#[derive(Debug)]
pub struct EmailOtpVerifyResponse {
pub user: User,
pub session: Session,
pub token: String,
}
pub struct EmailOtpService<S> {
storage: S,
events: EventBus,
token_store: OneTimeTokenStore,
session_ttl_secs: i64,
issue_limiter: KeyedRateLimiter,
}
impl<S> EmailOtpService<S>
where
S: UserRepository + SessionRepository + Clone + Send + Sync + 'static,
{
pub fn new(storage: S, events: EventBus, session_ttl_secs: i64) -> Self {
Self {
storage,
events,
token_store: OneTimeTokenStore::new(Duration::from_secs(10 * 60)),
session_ttl_secs,
issue_limiter: KeyedRateLimiter::new(ISSUE_RATE_MAX, ISSUE_RATE_WINDOW),
}
}
#[instrument(skip(self), fields(email = %email))]
pub async fn issue(&self, email: &str) -> Result<Option<String>> {
if !self.issue_limiter.check_and_record(email) {
tracing::warn!(email = %email, "email otp issue rate limit exceeded");
return Err(AuthError::AccountLocked);
}
let user = match UserRepository::find_by_email(&self.storage, email).await? {
Some(u) => u,
None => {
tracing::debug!("email otp requested for unknown email");
return Ok(None);
}
};
let token = self.token_store.issue(user.id, TokenKind::EmailOtp);
tracing::info!(user_id = %user.id, "email otp issued");
Ok(Some(token))
}
#[instrument(skip(self, raw_token), fields(ip = %ip))]
pub async fn verify(&self, raw_token: &str, ip: &str) -> Result<EmailOtpVerifyResponse> {
let user_id = self
.token_store
.consume(raw_token, TokenKind::EmailOtp)
.ok_or(AuthError::InvalidToken)?;
let user = UserRepository::find_by_id(&self.storage, user_id)
.await?
.ok_or(AuthError::UserNotFound)?;
let raw: [u8; 32] = rand::Rng::r#gen(&mut rand::thread_rng());
let raw_str = hex::encode(raw);
let token_hash = sha256_hex(raw_str.as_bytes());
let session = SessionRepository::create(
&self.storage,
CreateSession {
user_id: user.id,
token_hash,
device_info: serde_json::Value::Null,
ip_address: ip.to_owned(),
org_id: None,
expires_at: Utc::now() + chrono::Duration::seconds(self.session_ttl_secs),
},
)
.await?;
self.events.emit(AuthEvent::SignIn {
user: user.clone(),
session: session.clone(),
});
tracing::info!(user_id = %user_id, session_id = %session.id, "email otp sign-in complete");
Ok(EmailOtpVerifyResponse {
user,
session,
token: raw_str,
})
}
}