Skip to main content

auth_password/
repositories.rs

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/// Token returned by register/login, varying by strategy.
23#[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}