arcly_http_identity/
passwordless.rs1use std::sync::Arc;
5
6use arcly_http_core::resilience::distributed_rate_limit::{RateDecision, RateLimitBackend};
7
8use crate::error::{IdentityError, Result};
9use crate::identity::IdentityService;
10use crate::model::TokenResponse;
11use crate::notify::{Channel, Notification, NotificationKind, Notifier};
12use crate::ott::{OneTimeTokenService, OttPurpose};
13use crate::store::UserStore;
14
15struct SendLimit {
19 backend: Arc<dyn RateLimitBackend>,
20 max: u32,
21 window_secs: u32,
22}
23
24impl SendLimit {
25 async fn check(&self, destination: &str) -> Result<()> {
26 let key = format!("pwless_send::{}", destination.to_ascii_lowercase());
27 match self.backend.hit(&key, self.max, self.window_secs).await {
28 RateDecision::Allow { .. } => Ok(()),
29 RateDecision::Deny { .. } => Err(IdentityError::RateLimited),
30 RateDecision::Unavailable => Err(IdentityError::Unavailable),
31 }
32 }
33}
34
35pub struct PasswordlessService {
38 users: Arc<dyn UserStore>,
39 ott: Arc<OneTimeTokenService>,
40 notifier: Arc<dyn Notifier>,
41 identity: IdentityService,
42 link_base: String,
43 magic_ttl_secs: u64,
44 otp_ttl_secs: u64,
45 otp_digits: u32,
46 limit: Option<SendLimit>,
47}
48
49impl PasswordlessService {
50 pub fn new(
51 users: Arc<dyn UserStore>,
52 ott: Arc<OneTimeTokenService>,
53 notifier: Arc<dyn Notifier>,
54 identity: IdentityService,
55 link_base: impl Into<String>,
56 ) -> Self {
57 Self {
58 users,
59 ott,
60 notifier,
61 identity,
62 link_base: link_base.into(),
63 magic_ttl_secs: 900,
64 otp_ttl_secs: 300,
65 otp_digits: 6,
66 limit: None,
67 }
68 }
69
70 pub fn with_send_limit(
74 mut self,
75 backend: Arc<dyn RateLimitBackend>,
76 max: u32,
77 window_secs: u32,
78 ) -> Self {
79 self.limit = Some(SendLimit {
80 backend,
81 max,
82 window_secs,
83 });
84 self
85 }
86
87 pub async fn start_magic_link(&self, tenant: Option<&str>, email: &str) -> Result<()> {
91 if let Some(limit) = &self.limit {
94 limit.check(email).await?;
95 }
96 if let Some(user) = self.users.find_by_email(tenant, email).await? {
97 let token = self
98 .ott
99 .issue(&user.id, OttPurpose::MagicLink, self.magic_ttl_secs)
100 .await?;
101 let link = format!("{}?token={}", self.link_base, token);
102 self.notifier
103 .send(Notification {
104 to: email.to_owned(),
105 channel: Channel::Email,
106 kind: NotificationKind::MagicLink,
107 payload: link,
108 })
109 .await?;
110 }
111 Ok(())
112 }
113
114 pub async fn complete_magic_link(&self, token: &str) -> Result<TokenResponse> {
116 let user_id = self.ott.consume(token, OttPurpose::MagicLink).await?;
117 let user = self
118 .users
119 .find_by_id(&user_id)
120 .await?
121 .ok_or(IdentityError::NotFound)?;
122 self.identity.mint(&user).await
123 }
124
125 pub async fn start_otp(
127 &self,
128 tenant: Option<&str>,
129 destination: &str,
130 channel: Channel,
131 ) -> Result<()> {
132 if let Some(limit) = &self.limit {
133 limit.check(destination).await?;
134 }
135 if let Some(user) = self.users.find_by_email(tenant, destination).await? {
138 let code = self
139 .ott
140 .issue_numeric(
141 &user.id,
142 OttPurpose::LoginOtp,
143 self.otp_digits,
144 self.otp_ttl_secs,
145 )
146 .await?;
147 self.notifier
148 .send(Notification {
149 to: destination.to_owned(),
150 channel,
151 kind: NotificationKind::LoginOtp,
152 payload: code,
153 })
154 .await?;
155 }
156 Ok(())
157 }
158
159 pub async fn complete_otp(&self, code: &str) -> Result<TokenResponse> {
161 let user_id = self.ott.consume(code, OttPurpose::LoginOtp).await?;
162 let user = self
163 .users
164 .find_by_id(&user_id)
165 .await?
166 .ok_or(IdentityError::NotFound)?;
167 self.identity.mint(&user).await
168 }
169}