1use crate::config::{AuthPasswordConfig, TokenStrategy};
2use crate::jwt;
3use crate::password::{
4 hash_password, new_session_token, normalize_identifier, validate_password, verify_password,
5};
6use auth::public::{self, AuthSession, AuthUserId, SessionCreateOptions};
7use auth::session_policy::{AllowSessionPolicy, AuthSessionPolicy};
8use chrono::{DateTime, Duration, Utc};
9use platform_core::{AppError, AppResult, ClientRequestMetadata, DbPool, ErrorCode};
10
11const PASSWORD_PROVIDER: &str = "password";
12const MAX_FAILED_LOGINS: i32 = 5;
13const LOGIN_FAILURE_WINDOW: Duration = Duration::minutes(15);
14const LOGIN_LOCKOUT_DURATION: Duration = Duration::minutes(15);
15
16#[derive(Debug, Clone)]
17pub struct PasswordAuthRepository {
18 pool: DbPool,
19 session_policy: std::sync::Arc<dyn AuthSessionPolicy>,
20}
21
22#[derive(Debug)]
24pub enum AuthToken {
25 Session(AuthSession),
26 Jwt {
27 user_id: String,
28 token: String,
29 expires_at: DateTime<Utc>,
30 },
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct PasswordSessionOptions {
35 pub device_id: Option<String>,
36 pub client: ClientRequestMetadata,
37}
38
39impl PasswordAuthRepository {
40 #[must_use]
41 pub fn new(pool: DbPool) -> Self {
42 Self::new_with_session_policy(pool, std::sync::Arc::new(AllowSessionPolicy))
43 }
44
45 #[must_use]
46 pub fn new_with_session_policy(
47 pool: DbPool,
48 session_policy: std::sync::Arc<dyn AuthSessionPolicy>,
49 ) -> Self {
50 Self {
51 pool,
52 session_policy,
53 }
54 }
55
56 pub async fn register(
57 &self,
58 identifier: &str,
59 password: &str,
60 user_id: String,
61 identity_id: String,
62 session_id: String,
63 now: DateTime<Utc>,
64 expires_at: DateTime<Utc>,
65 config: &AuthPasswordConfig,
66 ) -> AppResult<AuthToken> {
67 self.register_with_options(
68 identifier,
69 password,
70 user_id,
71 identity_id,
72 session_id,
73 now,
74 expires_at,
75 config,
76 PasswordSessionOptions::default(),
77 )
78 .await
79 }
80
81 pub async fn register_with_options(
82 &self,
83 identifier: &str,
84 password: &str,
85 user_id: String,
86 identity_id: String,
87 session_id: String,
88 now: DateTime<Utc>,
89 expires_at: DateTime<Utc>,
90 config: &AuthPasswordConfig,
91 options: PasswordSessionOptions,
92 ) -> AppResult<AuthToken> {
93 let normalized_identifier = normalize_identifier(identifier)?;
94 validate_password(password)?;
95 let password_hash = hash_password(password, config)?;
96
97 match config.token_strategy {
98 TokenStrategy::Session => {
99 let token = new_session_token();
100 let mut tx = self.pool.begin().await.map_err(map_sql_error)?;
101 let identity = public::create_user_identity_in_tx(
102 &mut tx,
103 AuthUserId(user_id),
104 identity_id,
105 PASSWORD_PROVIDER,
106 &normalized_identifier,
107 now,
108 )
109 .await?;
110
111 sqlx::query(
112 r#"
113 insert into auth_password.credentials (identity_id, password_hash, created_at, updated_at)
114 values ($1, $2, $3, $3)
115 "#,
116 )
117 .bind(&identity.id)
118 .bind(password_hash)
119 .bind(now)
120 .execute(&mut *tx)
121 .await
122 .map_err(map_sql_error)?;
123
124 let session = public::create_session_in_tx_with_policy(
125 &mut tx,
126 &identity.user_id,
127 session_id,
128 token,
129 now,
130 expires_at,
131 SessionCreateOptions {
132 device_id: options.device_id,
133 client: options.client,
134 },
135 self.session_policy.as_ref(),
136 )
137 .await?;
138
139 tx.commit().await.map_err(map_sql_error)?;
140 Ok(AuthToken::Session(session))
141 }
142 TokenStrategy::Jwt => {
143 let jwt_config = config.jwt_config()?.ok_or_else(|| {
144 AppError::new(ErrorCode::Internal, "JWT configuration is required")
145 })?;
146 let mut tx = self.pool.begin().await.map_err(map_sql_error)?;
147 let identity = public::create_user_identity_in_tx(
148 &mut tx,
149 AuthUserId(user_id),
150 identity_id,
151 PASSWORD_PROVIDER,
152 &normalized_identifier,
153 now,
154 )
155 .await?;
156
157 sqlx::query(
158 r#"
159 insert into auth_password.credentials (identity_id, password_hash, created_at, updated_at)
160 values ($1, $2, $3, $3)
161 "#,
162 )
163 .bind(&identity.id)
164 .bind(password_hash)
165 .bind(now)
166 .execute(&mut *tx)
167 .await
168 .map_err(map_sql_error)?;
169
170 tx.commit().await.map_err(map_sql_error)?;
171
172 let user_id_str = identity.user_id.0.clone();
173 let token = jwt::create_token(&user_id_str, &jwt_config, now);
174
175 Ok(AuthToken::Jwt {
176 user_id: user_id_str,
177 token,
178 expires_at,
179 })
180 }
181 }
182 }
183
184 pub async fn login(
185 &self,
186 identifier: &str,
187 password: &str,
188 session_id: String,
189 now: DateTime<Utc>,
190 expires_at: DateTime<Utc>,
191 config: &AuthPasswordConfig,
192 ) -> AppResult<AuthToken> {
193 self.login_with_options(
194 identifier,
195 password,
196 session_id,
197 now,
198 expires_at,
199 config,
200 PasswordSessionOptions::default(),
201 )
202 .await
203 }
204
205 pub async fn login_with_options(
206 &self,
207 identifier: &str,
208 password: &str,
209 session_id: String,
210 now: DateTime<Utc>,
211 expires_at: DateTime<Utc>,
212 config: &AuthPasswordConfig,
213 options: PasswordSessionOptions,
214 ) -> AppResult<AuthToken> {
215 let normalized_identifier = normalize_identifier(identifier)?;
216 validate_password(password)?;
217 self.ensure_login_not_locked(&normalized_identifier, now)
218 .await?;
219
220 let Some(identity) =
221 public::find_active_identity(&self.pool, PASSWORD_PROVIDER, &normalized_identifier)
222 .await?
223 else {
224 self.record_failed_login(&normalized_identifier, now, &options.client)
225 .await?;
226 return Err(invalid_credentials());
227 };
228
229 let Some(password_hash) = sqlx::query_scalar::<_, String>(
230 r#"
231 select password_hash
232 from auth_password.credentials
233 where identity_id = $1
234 "#,
235 )
236 .bind(&identity.id)
237 .fetch_optional(&self.pool)
238 .await
239 .map_err(map_sql_error)?
240 else {
241 self.record_failed_login(&normalized_identifier, now, &options.client)
242 .await?;
243 return Err(invalid_credentials());
244 };
245
246 if !verify_password(&password_hash, password)? {
247 self.record_failed_login(&normalized_identifier, now, &options.client)
248 .await?;
249 return Err(invalid_credentials());
250 }
251
252 match config.token_strategy {
253 TokenStrategy::Session => {
254 let session = public::create_session_with_policy(
255 &self.pool,
256 &identity.user_id,
257 session_id,
258 new_session_token(),
259 now,
260 expires_at,
261 SessionCreateOptions {
262 device_id: options.device_id,
263 client: options.client,
264 },
265 self.session_policy.as_ref(),
266 )
267 .await?;
268 self.clear_login_failures(&normalized_identifier).await?;
269 Ok(AuthToken::Session(session))
270 }
271 TokenStrategy::Jwt => {
272 let jwt_config = config.jwt_config()?.ok_or_else(|| {
273 AppError::new(ErrorCode::Internal, "JWT configuration is required")
274 })?;
275 let user_id_str = identity.user_id.0.clone();
276 let token = jwt::create_token(&user_id_str, &jwt_config, now);
277 self.clear_login_failures(&normalized_identifier).await?;
278 Ok(AuthToken::Jwt {
279 user_id: user_id_str,
280 token,
281 expires_at,
282 })
283 }
284 }
285 }
286
287 async fn ensure_login_not_locked(
288 &self,
289 normalized_identifier: &str,
290 now: DateTime<Utc>,
291 ) -> AppResult<()> {
292 let locked_until = sqlx::query_scalar::<_, DateTime<Utc>>(
293 r#"
294 select locked_until
295 from auth_password.login_failures
296 where identifier = $1 and locked_until > $2
297 "#,
298 )
299 .bind(normalized_identifier)
300 .bind(now)
301 .fetch_optional(&self.pool)
302 .await
303 .map_err(map_sql_error)?;
304
305 match locked_until {
306 Some(locked_until) => Err(rate_limited(locked_until)),
307 None => Ok(()),
308 }
309 }
310
311 async fn record_failed_login(
312 &self,
313 normalized_identifier: &str,
314 now: DateTime<Utc>,
315 client: &ClientRequestMetadata,
316 ) -> AppResult<()> {
317 let mut tx = self.pool.begin().await.map_err(map_sql_error)?;
318 let row = sqlx::query_as::<_, (i32, DateTime<Utc>)>(
319 r#"
320 select failed_count, window_started_at
321 from auth_password.login_failures
322 where identifier = $1
323 for update
324 "#,
325 )
326 .bind(normalized_identifier)
327 .fetch_optional(&mut *tx)
328 .await
329 .map_err(map_sql_error)?;
330
331 if let Some((failed_count, window_started_at)) = row {
332 let update = failed_login_update(failed_count, window_started_at, now);
333 sqlx::query(
334 r#"
335 update auth_password.login_failures
336 set failed_count = $2,
337 window_started_at = $3,
338 last_failed_at = $4,
339 locked_until = $5,
340 last_failed_ip = $6,
341 last_failed_user_agent = $7
342 where identifier = $1
343 "#,
344 )
345 .bind(normalized_identifier)
346 .bind(update.failed_count)
347 .bind(update.window_started_at)
348 .bind(now)
349 .bind(update.locked_until)
350 .bind(client.ip.as_deref())
351 .bind(client.user_agent.as_deref())
352 .execute(&mut *tx)
353 .await
354 .map_err(map_sql_error)?;
355 } else {
356 sqlx::query(
357 r#"
358 insert into auth_password.login_failures
359 (
360 identifier,
361 failed_count,
362 window_started_at,
363 last_failed_at,
364 locked_until,
365 last_failed_ip,
366 last_failed_user_agent
367 )
368 values ($1, 1, $2, $2, null, $3, $4)
369 "#,
370 )
371 .bind(normalized_identifier)
372 .bind(now)
373 .bind(client.ip.as_deref())
374 .bind(client.user_agent.as_deref())
375 .execute(&mut *tx)
376 .await
377 .map_err(map_sql_error)?;
378 }
379
380 tx.commit().await.map_err(map_sql_error)?;
381 Ok(())
382 }
383
384 async fn clear_login_failures(&self, normalized_identifier: &str) -> AppResult<()> {
385 sqlx::query(
386 r#"
387 delete from auth_password.login_failures
388 where identifier = $1
389 "#,
390 )
391 .bind(normalized_identifier)
392 .execute(&self.pool)
393 .await
394 .map_err(map_sql_error)?;
395 Ok(())
396 }
397}
398
399fn invalid_credentials() -> AppError {
400 AppError::new(ErrorCode::Unauthorized, "Invalid identifier or password")
401}
402
403fn rate_limited(locked_until: DateTime<Utc>) -> AppError {
404 AppError::new(
405 ErrorCode::RateLimited,
406 format!(
407 "Too many password login attempts; try again after {}",
408 locked_until.to_rfc3339()
409 ),
410 )
411}
412
413fn map_sql_error(source: sqlx::Error) -> AppError {
414 AppError::new(ErrorCode::Internal, "Internal server error").with_source(source)
415}
416
417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418struct FailedLoginUpdate {
419 failed_count: i32,
420 window_started_at: DateTime<Utc>,
421 locked_until: Option<DateTime<Utc>>,
422}
423
424fn failed_login_update(
425 failed_count: i32,
426 window_started_at: DateTime<Utc>,
427 now: DateTime<Utc>,
428) -> FailedLoginUpdate {
429 if now - window_started_at > LOGIN_FAILURE_WINDOW {
430 return FailedLoginUpdate {
431 failed_count: 1,
432 window_started_at: now,
433 locked_until: None,
434 };
435 }
436
437 let failed_count = failed_count + 1;
438 FailedLoginUpdate {
439 failed_count,
440 window_started_at,
441 locked_until: (failed_count >= MAX_FAILED_LOGINS).then_some(now + LOGIN_LOCKOUT_DURATION),
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use chrono::Duration;
449
450 #[test]
451 fn failed_login_update_locks_on_fifth_failure_in_window() {
452 let now = Utc::now();
453 let update = failed_login_update(4, now - Duration::minutes(1), now);
454
455 assert_eq!(update.failed_count, 5);
456 assert_eq!(update.window_started_at, now - Duration::minutes(1));
457 assert_eq!(update.locked_until, Some(now + LOGIN_LOCKOUT_DURATION));
458 }
459
460 #[test]
461 fn failed_login_update_resets_expired_window() {
462 let now = Utc::now();
463 let update = failed_login_update(4, now - LOGIN_FAILURE_WINDOW - Duration::seconds(1), now);
464
465 assert_eq!(update.failed_count, 1);
466 assert_eq!(update.window_started_at, now);
467 assert_eq!(update.locked_until, None);
468 }
469}