use std::sync::Arc;
use arcly_http_core::resilience::distributed_rate_limit::{RateDecision, RateLimitBackend};
use crate::error::{IdentityError, Result};
use crate::identity::IdentityService;
use crate::model::TokenResponse;
use crate::notify::{Channel, Notification, NotificationKind, Notifier};
use crate::ott::{OneTimeTokenService, OttPurpose};
use crate::store::UserStore;
struct SendLimit {
backend: Arc<dyn RateLimitBackend>,
max: u32,
window_secs: u32,
}
impl SendLimit {
async fn check(&self, destination: &str) -> Result<()> {
let key = format!("pwless_send::{}", destination.to_ascii_lowercase());
match self.backend.hit(&key, self.max, self.window_secs).await {
RateDecision::Allow { .. } => Ok(()),
RateDecision::Deny { .. } => Err(IdentityError::RateLimited),
RateDecision::Unavailable => Err(IdentityError::Unavailable),
}
}
}
pub struct PasswordlessService {
users: Arc<dyn UserStore>,
ott: Arc<OneTimeTokenService>,
notifier: Arc<dyn Notifier>,
identity: IdentityService,
link_base: String,
magic_ttl_secs: u64,
otp_ttl_secs: u64,
otp_digits: u32,
limit: Option<SendLimit>,
}
impl PasswordlessService {
pub fn new(
users: Arc<dyn UserStore>,
ott: Arc<OneTimeTokenService>,
notifier: Arc<dyn Notifier>,
identity: IdentityService,
link_base: impl Into<String>,
) -> Self {
Self {
users,
ott,
notifier,
identity,
link_base: link_base.into(),
magic_ttl_secs: 900,
otp_ttl_secs: 300,
otp_digits: 6,
limit: None,
}
}
pub fn with_send_limit(
mut self,
backend: Arc<dyn RateLimitBackend>,
max: u32,
window_secs: u32,
) -> Self {
self.limit = Some(SendLimit {
backend,
max,
window_secs,
});
self
}
pub async fn start_magic_link(&self, tenant: Option<&str>, email: &str) -> Result<()> {
if let Some(limit) = &self.limit {
limit.check(email).await?;
}
if let Some(user) = self.users.find_by_email(tenant, email).await? {
let token = self
.ott
.issue(&user.id, OttPurpose::MagicLink, self.magic_ttl_secs)
.await?;
let link = format!("{}?token={}", self.link_base, token);
self.notifier
.send(Notification {
to: email.to_owned(),
channel: Channel::Email,
kind: NotificationKind::MagicLink,
payload: link,
})
.await?;
}
Ok(())
}
pub async fn complete_magic_link(&self, token: &str) -> Result<TokenResponse> {
let user_id = self.ott.consume(token, OttPurpose::MagicLink).await?;
let user = self
.users
.find_by_id(&user_id)
.await?
.ok_or(IdentityError::NotFound)?;
self.identity.mint(&user).await
}
pub async fn start_otp(
&self,
tenant: Option<&str>,
destination: &str,
channel: Channel,
) -> Result<()> {
if let Some(limit) = &self.limit {
limit.check(destination).await?;
}
if let Some(user) = self.users.find_by_email(tenant, destination).await? {
let code = self
.ott
.issue_numeric(
&user.id,
OttPurpose::LoginOtp,
self.otp_digits,
self.otp_ttl_secs,
)
.await?;
self.notifier
.send(Notification {
to: destination.to_owned(),
channel,
kind: NotificationKind::LoginOtp,
payload: code,
})
.await?;
}
Ok(())
}
pub async fn complete_otp(&self, code: &str) -> Result<TokenResponse> {
let user_id = self.ott.consume(code, OttPurpose::LoginOtp).await?;
let user = self
.users
.find_by_id(&user_id)
.await?
.ok_or(IdentityError::NotFound)?;
self.identity.mint(&user).await
}
}