authx_plugins/email_password/
service.rs1use chrono::Utc;
2use rand::Rng;
3use tracing::instrument;
4use uuid::Uuid;
5
6use authx_core::{
7 brute_force::{LockoutConfig, LoginAttemptTracker},
8 crypto::{hash_password, sha256_hex, verify_password},
9 error::{AuthError, Result},
10 events::{AuthEvent, EventBus},
11 models::{CreateCredential, CreateSession, CreateUser, CredentialKind, Session, User},
12 validation::{validate_email, validate_password},
13};
14use authx_storage::ports::{CredentialRepository, SessionRepository, UserRepository};
15
16pub struct SignUpRequest {
17 pub email: String,
18 pub password: String,
19 pub ip: String,
20}
21
22pub struct SignInRequest {
23 pub email: String,
24 pub password: String,
25 pub ip: String,
26}
27
28#[derive(Debug)]
29pub struct AuthResponse {
30 pub user: User,
31 pub session: Session,
32 pub token: String,
35}
36
37pub struct EmailPasswordService<S> {
38 storage: S,
39 events: EventBus,
40 min_password_len: usize,
41 session_ttl_secs: i64,
42 lockout: Option<LoginAttemptTracker>,
43}
44
45impl<S> EmailPasswordService<S>
46where
47 S: UserRepository + SessionRepository + CredentialRepository + Clone + Send + Sync + 'static,
48{
49 pub fn new(
50 storage: S,
51 events: EventBus,
52 min_password_len: usize,
53 session_ttl_secs: i64,
54 ) -> Self {
55 Self {
56 storage,
57 events,
58 min_password_len,
59 session_ttl_secs,
60 lockout: None,
61 }
62 }
63
64 pub fn with_lockout(mut self, cfg: LockoutConfig) -> Self {
66 self.lockout = Some(LoginAttemptTracker::new(cfg));
67 self
68 }
69
70 #[instrument(skip(self, req), fields(email = %req.email))]
71 pub async fn sign_up(&self, req: SignUpRequest) -> Result<User> {
72 validate_email(&req.email)?;
73 validate_password(&req.password, self.min_password_len)?;
74
75 if UserRepository::find_by_email(&self.storage, &req.email)
76 .await?
77 .is_some()
78 {
79 return Err(AuthError::EmailTaken);
80 }
81
82 let hash = hash_password(&req.password)?;
83
84 let user = UserRepository::create(
85 &self.storage,
86 CreateUser {
87 email: req.email,
88 username: None,
89 metadata: None,
90 },
91 )
92 .await?;
93
94 CredentialRepository::create(
95 &self.storage,
96 CreateCredential {
97 user_id: user.id,
98 kind: CredentialKind::Password,
99 credential_hash: hash,
100 metadata: None,
101 },
102 )
103 .await?;
104
105 self.events
106 .emit(AuthEvent::UserCreated { user: user.clone() });
107 tracing::info!(user_id = %user.id, "user registered");
108 Ok(user)
109 }
110
111 #[instrument(skip(self, req), fields(email = %req.email))]
112 pub async fn sign_in(&self, req: SignInRequest) -> Result<AuthResponse> {
113 if let Some(tracker) = &self.lockout {
115 if tracker.is_locked(&req.email) {
116 tracing::warn!(email = %req.email, "sign-in blocked: account locked");
117 return Err(AuthError::AccountLocked);
118 }
119 }
120
121 let user = UserRepository::find_by_email(&self.storage, &req.email)
122 .await?
123 .ok_or(AuthError::InvalidCredentials)?;
124
125 let hash = CredentialRepository::find_password_hash(&self.storage, user.id)
126 .await?
127 .ok_or(AuthError::InvalidCredentials)?;
128
129 if !verify_password(&hash, &req.password)? {
130 tracing::warn!(email = %req.email, "wrong password");
131 if let Some(tracker) = &self.lockout {
132 tracker.record_failure(&req.email);
133 }
134 return Err(AuthError::InvalidCredentials);
135 }
136
137 if let Some(tracker) = &self.lockout {
139 tracker.record_success(&req.email);
140 }
141
142 let raw_token = generate_token();
143 let token_hash = sha256_hex(raw_token.as_bytes());
144
145 let session = SessionRepository::create(
146 &self.storage,
147 CreateSession {
148 user_id: user.id,
149 token_hash,
150 device_info: serde_json::Value::Null,
151 ip_address: req.ip,
152 org_id: None,
153 expires_at: Utc::now() + chrono::Duration::seconds(self.session_ttl_secs),
154 },
155 )
156 .await?;
157
158 self.events.emit(AuthEvent::SignIn {
159 user: user.clone(),
160 session: session.clone(),
161 });
162 tracing::info!(user_id = %user.id, session_id = %session.id, "signed in");
163
164 Ok(AuthResponse {
165 user,
166 session,
167 token: raw_token,
168 })
169 }
170
171 #[instrument(skip(self))]
172 pub async fn sign_out(&self, session_id: Uuid) -> Result<()> {
173 SessionRepository::invalidate(&self.storage, session_id).await?;
174 tracing::info!(session_id = %session_id, "session invalidated");
175 Ok(())
176 }
177
178 #[instrument(skip(self))]
179 pub async fn sign_out_all(&self, user_id: Uuid) -> Result<()> {
180 SessionRepository::invalidate_all_for_user(&self.storage, user_id).await?;
181 tracing::info!(user_id = %user_id, "all sessions invalidated");
182 Ok(())
183 }
184
185 #[instrument(skip(self))]
186 pub async fn list_sessions(&self, user_id: Uuid) -> Result<Vec<Session>> {
187 SessionRepository::find_by_user(&self.storage, user_id).await
188 }
189}
190
191fn generate_token() -> String {
192 let bytes: [u8; 32] = rand::thread_rng().gen();
193 hex::encode(bytes)
194}