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
11pub const 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 pub link_anonymous_user_id: Option<AuthUserId>,
38}
39
40impl PasswordAuthRepository {
41 #[must_use]
42 pub fn new(pool: DbPool) -> Self {
43 Self::new_with_session_policy(pool, std::sync::Arc::new(AllowSessionPolicy))
44 }
45
46 #[must_use]
47 pub fn new_with_session_policy(
48 pool: DbPool,
49 session_policy: std::sync::Arc<dyn AuthSessionPolicy>,
50 ) -> Self {
51 Self {
52 pool,
53 session_policy,
54 }
55 }
56
57 pub async fn register(
58 &self,
59 identifier: &str,
60 password: &str,
61 user_id: String,
62 identity_id: String,
63 session_id: String,
64 now: DateTime<Utc>,
65 expires_at: DateTime<Utc>,
66 config: &AuthPasswordConfig,
67 ) -> AppResult<AuthToken> {
68 self.register_with_options(
69 identifier,
70 password,
71 user_id,
72 identity_id,
73 session_id,
74 now,
75 expires_at,
76 config,
77 PasswordSessionOptions::default(),
78 )
79 .await
80 }
81
82 pub async fn register_with_options(
83 &self,
84 identifier: &str,
85 password: &str,
86 user_id: String,
87 identity_id: String,
88 session_id: String,
89 now: DateTime<Utc>,
90 expires_at: DateTime<Utc>,
91 config: &AuthPasswordConfig,
92 options: PasswordSessionOptions,
93 ) -> AppResult<AuthToken> {
94 let normalized_identifier = normalize_identifier(identifier)?;
95 validate_password(password)?;
96 let password_hash = hash_password(password, config)?;
97
98 match config.token_strategy {
99 TokenStrategy::Session => {
100 let token = new_session_token();
101 let mut tx = self.pool.begin().await.map_err(map_sql_error)?;
102 let identity = match options.link_anonymous_user_id.as_ref() {
103 Some(link_user_id) => {
104 public::link_identity_to_anonymous_user_in_tx(
105 &mut tx,
106 link_user_id,
107 identity_id,
108 PASSWORD_PROVIDER,
109 &normalized_identifier,
110 now,
111 )
112 .await?
113 }
114 None => {
115 public::create_user_identity_in_tx(
116 &mut tx,
117 AuthUserId(user_id),
118 identity_id,
119 PASSWORD_PROVIDER,
120 &normalized_identifier,
121 now,
122 )
123 .await?
124 }
125 };
126
127 sqlx::query(
128 r#"
129 insert into auth_password.credentials (identity_id, password_hash, created_at, updated_at)
130 values ($1, $2, $3, $3)
131 "#,
132 )
133 .bind(&identity.id)
134 .bind(password_hash)
135 .bind(now)
136 .execute(&mut *tx)
137 .await
138 .map_err(map_sql_error)?;
139
140 let session = public::create_session_in_tx_with_policy(
141 &mut tx,
142 &identity.user_id,
143 session_id,
144 token,
145 now,
146 expires_at,
147 SessionCreateOptions {
148 device_id: options.device_id,
149 client: options.client,
150 },
151 self.session_policy.as_ref(),
152 )
153 .await?;
154
155 tx.commit().await.map_err(map_sql_error)?;
156 Ok(AuthToken::Session(session))
157 }
158 TokenStrategy::Jwt => {
159 let jwt_config = config.jwt_config()?.ok_or_else(|| {
160 AppError::new(ErrorCode::Internal, "JWT configuration is required")
161 })?;
162 let mut tx = self.pool.begin().await.map_err(map_sql_error)?;
163 let identity = match options.link_anonymous_user_id.as_ref() {
164 Some(link_user_id) => {
165 public::link_identity_to_anonymous_user_in_tx(
166 &mut tx,
167 link_user_id,
168 identity_id,
169 PASSWORD_PROVIDER,
170 &normalized_identifier,
171 now,
172 )
173 .await?
174 }
175 None => {
176 public::create_user_identity_in_tx(
177 &mut tx,
178 AuthUserId(user_id),
179 identity_id,
180 PASSWORD_PROVIDER,
181 &normalized_identifier,
182 now,
183 )
184 .await?
185 }
186 };
187
188 sqlx::query(
189 r#"
190 insert into auth_password.credentials (identity_id, password_hash, created_at, updated_at)
191 values ($1, $2, $3, $3)
192 "#,
193 )
194 .bind(&identity.id)
195 .bind(password_hash)
196 .bind(now)
197 .execute(&mut *tx)
198 .await
199 .map_err(map_sql_error)?;
200
201 tx.commit().await.map_err(map_sql_error)?;
202
203 let user_id_str = identity.user_id.0.clone();
204 let token = jwt::create_token(&user_id_str, &jwt_config, now);
205
206 Ok(AuthToken::Jwt {
207 user_id: user_id_str,
208 token,
209 expires_at,
210 })
211 }
212 }
213 }
214
215 pub async fn login(
216 &self,
217 identifier: &str,
218 password: &str,
219 session_id: String,
220 now: DateTime<Utc>,
221 expires_at: DateTime<Utc>,
222 config: &AuthPasswordConfig,
223 ) -> AppResult<AuthToken> {
224 self.login_with_options(
225 identifier,
226 password,
227 session_id,
228 now,
229 expires_at,
230 config,
231 PasswordSessionOptions::default(),
232 )
233 .await
234 }
235
236 pub async fn login_with_options(
237 &self,
238 identifier: &str,
239 password: &str,
240 session_id: String,
241 now: DateTime<Utc>,
242 expires_at: DateTime<Utc>,
243 config: &AuthPasswordConfig,
244 options: PasswordSessionOptions,
245 ) -> AppResult<AuthToken> {
246 let normalized_identifier = normalize_identifier(identifier)?;
247 validate_password(password)?;
248 self.ensure_login_not_locked(&normalized_identifier, now)
249 .await?;
250
251 let Some(identity) =
252 public::find_active_identity(&self.pool, PASSWORD_PROVIDER, &normalized_identifier)
253 .await?
254 else {
255 self.record_failed_login(&normalized_identifier, now, &options.client)
256 .await?;
257 return Err(invalid_credentials());
258 };
259
260 let Some(password_hash) = sqlx::query_scalar::<_, String>(
261 r#"
262 select password_hash
263 from auth_password.credentials
264 where identity_id = $1
265 "#,
266 )
267 .bind(&identity.id)
268 .fetch_optional(&self.pool)
269 .await
270 .map_err(map_sql_error)?
271 else {
272 self.record_failed_login(&normalized_identifier, now, &options.client)
273 .await?;
274 return Err(invalid_credentials());
275 };
276
277 if !verify_password(&password_hash, password)? {
278 self.record_failed_login(&normalized_identifier, now, &options.client)
279 .await?;
280 return Err(invalid_credentials());
281 }
282
283 match config.token_strategy {
284 TokenStrategy::Session => {
285 let session = public::create_session_with_policy(
286 &self.pool,
287 &identity.user_id,
288 session_id,
289 new_session_token(),
290 now,
291 expires_at,
292 SessionCreateOptions {
293 device_id: options.device_id,
294 client: options.client,
295 },
296 self.session_policy.as_ref(),
297 )
298 .await?;
299 self.clear_login_failures(&normalized_identifier).await?;
300 Ok(AuthToken::Session(session))
301 }
302 TokenStrategy::Jwt => {
303 let jwt_config = config.jwt_config()?.ok_or_else(|| {
304 AppError::new(ErrorCode::Internal, "JWT configuration is required")
305 })?;
306 let user_id_str = identity.user_id.0.clone();
307 let token = jwt::create_token(&user_id_str, &jwt_config, now);
308 self.clear_login_failures(&normalized_identifier).await?;
309 Ok(AuthToken::Jwt {
310 user_id: user_id_str,
311 token,
312 expires_at,
313 })
314 }
315 }
316 }
317
318 pub async fn reset_password(
319 &self,
320 user_id: &AuthUserId,
321 password: &str,
322 now: DateTime<Utc>,
323 config: &AuthPasswordConfig,
324 ) -> AppResult<bool> {
325 validate_password(password)?;
326 let password_hash = hash_password(password, config)?;
327 let result = sqlx::query(
328 r#"
329 update auth_password.credentials credentials
330 set password_hash = $2, updated_at = $3
331 from auth.identities identities
332 where credentials.identity_id = identities.id
333 and identities.user_id = $1
334 and identities.provider = $4
335 "#,
336 )
337 .bind(&user_id.0)
338 .bind(password_hash)
339 .bind(now)
340 .bind(PASSWORD_PROVIDER)
341 .execute(&self.pool)
342 .await
343 .map_err(map_sql_error)?;
344
345 Ok(result.rows_affected() > 0)
346 }
347
348 pub async fn set_identity_password(
349 &self,
350 identity_id: &str,
351 password: &str,
352 now: DateTime<Utc>,
353 config: &AuthPasswordConfig,
354 ) -> AppResult<()> {
355 validate_password(password)?;
356 let password_hash = hash_password(password, config)?;
357
358 sqlx::query(
359 r#"
360 insert into auth_password.credentials (
361 identity_id,
362 password_hash,
363 created_at,
364 updated_at
365 )
366 values ($1, $2, $3, $3)
367 on conflict (identity_id) do update
368 set password_hash = excluded.password_hash,
369 updated_at = excluded.updated_at
370 "#,
371 )
372 .bind(identity_id)
373 .bind(password_hash)
374 .bind(now)
375 .execute(&self.pool)
376 .await
377 .map_err(map_sql_error)?;
378
379 Ok(())
380 }
381
382 pub async fn verify_identity_password(
383 &self,
384 identity_id: &str,
385 password: &str,
386 ) -> AppResult<bool> {
387 let Some(password_hash) = sqlx::query_scalar::<_, String>(
388 r#"
389 select password_hash
390 from auth_password.credentials
391 where identity_id = $1
392 "#,
393 )
394 .bind(identity_id)
395 .fetch_optional(&self.pool)
396 .await
397 .map_err(map_sql_error)?
398 else {
399 return Ok(false);
400 };
401
402 verify_password(&password_hash, password)
403 }
404
405 pub async fn ensure_login_not_locked_for_provider(
406 &self,
407 provider: &str,
408 normalized_identifier: &str,
409 now: DateTime<Utc>,
410 ) -> AppResult<()> {
411 let locked_until = sqlx::query_scalar::<_, DateTime<Utc>>(
412 r#"
413 select locked_until
414 from auth_password.login_failures
415 where provider = $1
416 and identifier = $2
417 and locked_until > $3
418 "#,
419 )
420 .bind(provider)
421 .bind(normalized_identifier)
422 .bind(now)
423 .fetch_optional(&self.pool)
424 .await
425 .map_err(map_sql_error)?;
426
427 match locked_until {
428 Some(locked_until) => Err(rate_limited(locked_until)),
429 None => Ok(()),
430 }
431 }
432
433 pub async fn record_failed_login_for_provider(
434 &self,
435 provider: &str,
436 normalized_identifier: &str,
437 now: DateTime<Utc>,
438 client: &ClientRequestMetadata,
439 ) -> AppResult<()> {
440 let mut tx = self.pool.begin().await.map_err(map_sql_error)?;
441 let inserted = sqlx::query(
442 r#"
443 insert into auth_password.login_failures (
444 provider, identifier, failed_count, window_started_at,
445 last_failed_at, locked_until, last_failed_ip, last_failed_user_agent
446 )
447 values ($1, $2, 1, $3, $3, null, $4, $5)
448 on conflict (provider, identifier) do nothing
449 "#,
450 )
451 .bind(provider)
452 .bind(normalized_identifier)
453 .bind(now)
454 .bind(client.ip.as_deref())
455 .bind(client.user_agent.as_deref())
456 .execute(&mut *tx)
457 .await
458 .map_err(map_sql_error)?;
459 if inserted.rows_affected() == 1 {
460 tx.commit().await.map_err(map_sql_error)?;
461 return Ok(());
462 }
463
464 let (failed_count, window_started_at) = sqlx::query_as::<_, (i32, DateTime<Utc>)>(
465 r#"
466 select failed_count, window_started_at
467 from auth_password.login_failures
468 where provider = $1
469 and identifier = $2
470 for update
471 "#,
472 )
473 .bind(provider)
474 .bind(normalized_identifier)
475 .fetch_one(&mut *tx)
476 .await
477 .map_err(map_sql_error)?;
478
479 let update = failed_login_update(failed_count, window_started_at, now);
480 sqlx::query(
481 r#"
482 update auth_password.login_failures
483 set failed_count = $3,
484 window_started_at = $4,
485 last_failed_at = $5,
486 locked_until = $6,
487 last_failed_ip = $7,
488 last_failed_user_agent = $8
489 where provider = $1
490 and identifier = $2
491 "#,
492 )
493 .bind(provider)
494 .bind(normalized_identifier)
495 .bind(update.failed_count)
496 .bind(update.window_started_at)
497 .bind(now)
498 .bind(update.locked_until)
499 .bind(client.ip.as_deref())
500 .bind(client.user_agent.as_deref())
501 .execute(&mut *tx)
502 .await
503 .map_err(map_sql_error)?;
504
505 tx.commit().await.map_err(map_sql_error)?;
506 Ok(())
507 }
508
509 pub async fn clear_login_failures_for_provider(
510 &self,
511 provider: &str,
512 normalized_identifier: &str,
513 ) -> AppResult<()> {
514 sqlx::query(
515 r#"
516 delete from auth_password.login_failures
517 where provider = $1
518 and identifier = $2
519 "#,
520 )
521 .bind(provider)
522 .bind(normalized_identifier)
523 .execute(&self.pool)
524 .await
525 .map_err(map_sql_error)?;
526 Ok(())
527 }
528
529 async fn ensure_login_not_locked(
530 &self,
531 normalized_identifier: &str,
532 now: DateTime<Utc>,
533 ) -> AppResult<()> {
534 self.ensure_login_not_locked_for_provider(PASSWORD_PROVIDER, normalized_identifier, now)
535 .await
536 }
537
538 async fn record_failed_login(
539 &self,
540 normalized_identifier: &str,
541 now: DateTime<Utc>,
542 client: &ClientRequestMetadata,
543 ) -> AppResult<()> {
544 self.record_failed_login_for_provider(PASSWORD_PROVIDER, normalized_identifier, now, client)
545 .await
546 }
547
548 async fn clear_login_failures(&self, normalized_identifier: &str) -> AppResult<()> {
549 self.clear_login_failures_for_provider(PASSWORD_PROVIDER, normalized_identifier)
550 .await
551 }
552}
553
554fn invalid_credentials() -> AppError {
555 AppError::new(ErrorCode::Unauthorized, "Invalid identifier or password")
556}
557
558fn rate_limited(locked_until: DateTime<Utc>) -> AppError {
559 AppError::new(
560 ErrorCode::RateLimited,
561 format!(
562 "Too many password login attempts; try again after {}",
563 locked_until.to_rfc3339()
564 ),
565 )
566}
567
568fn map_sql_error(source: sqlx::Error) -> AppError {
569 AppError::new(ErrorCode::Internal, "Internal server error").with_source(source)
570}
571
572#[derive(Debug, Clone, Copy, PartialEq, Eq)]
573struct FailedLoginUpdate {
574 failed_count: i32,
575 window_started_at: DateTime<Utc>,
576 locked_until: Option<DateTime<Utc>>,
577}
578
579fn failed_login_update(
580 failed_count: i32,
581 window_started_at: DateTime<Utc>,
582 now: DateTime<Utc>,
583) -> FailedLoginUpdate {
584 if now - window_started_at > LOGIN_FAILURE_WINDOW {
585 return FailedLoginUpdate {
586 failed_count: 1,
587 window_started_at: now,
588 locked_until: None,
589 };
590 }
591
592 let failed_count = failed_count + 1;
593 FailedLoginUpdate {
594 failed_count,
595 window_started_at,
596 locked_until: (failed_count >= MAX_FAILED_LOGINS).then_some(now + LOGIN_LOCKOUT_DURATION),
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use chrono::Duration;
604
605 #[test]
606 fn failed_login_update_locks_on_fifth_failure_in_window() {
607 let now = Utc::now();
608 let update = failed_login_update(4, now - Duration::minutes(1), now);
609
610 assert_eq!(update.failed_count, 5);
611 assert_eq!(update.window_started_at, now - Duration::minutes(1));
612 assert_eq!(update.locked_until, Some(now + LOGIN_LOCKOUT_DURATION));
613 }
614
615 #[test]
616 fn failed_login_update_resets_expired_window() {
617 let now = Utc::now();
618 let update = failed_login_update(4, now - LOGIN_FAILURE_WINDOW - Duration::seconds(1), now);
619
620 assert_eq!(update.failed_count, 1);
621 assert_eq!(update.window_started_at, now);
622 assert_eq!(update.locked_until, None);
623 }
624}