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 row = sqlx::query_as::<_, (i32, DateTime<Utc>)>(
442            r#"
443            select failed_count, window_started_at
444            from auth_password.login_failures
445            where provider = $1
446              and identifier = $2
447            for update
448            "#,
449        )
450        .bind(provider)
451        .bind(normalized_identifier)
452        .fetch_optional(&mut *tx)
453        .await
454        .map_err(map_sql_error)?;
455
456        if let Some((failed_count, window_started_at)) = row {
457            let update = failed_login_update(failed_count, window_started_at, now);
458            sqlx::query(
459                r#"
460                update auth_password.login_failures
461                set failed_count = $3,
462                    window_started_at = $4,
463                    last_failed_at = $5,
464                    locked_until = $6,
465                    last_failed_ip = $7,
466                    last_failed_user_agent = $8
467                where provider = $1
468                  and identifier = $2
469                "#,
470            )
471            .bind(provider)
472            .bind(normalized_identifier)
473            .bind(update.failed_count)
474            .bind(update.window_started_at)
475            .bind(now)
476            .bind(update.locked_until)
477            .bind(client.ip.as_deref())
478            .bind(client.user_agent.as_deref())
479            .execute(&mut *tx)
480            .await
481            .map_err(map_sql_error)?;
482        } else {
483            sqlx::query(
484                r#"
485                insert into auth_password.login_failures
486                    (
487                        provider,
488                        identifier,
489                        failed_count,
490                        window_started_at,
491                        last_failed_at,
492                        locked_until,
493                        last_failed_ip,
494                        last_failed_user_agent
495                    )
496                values ($1, $2, 1, $3, $3, null, $4, $5)
497                "#,
498            )
499            .bind(provider)
500            .bind(normalized_identifier)
501            .bind(now)
502            .bind(client.ip.as_deref())
503            .bind(client.user_agent.as_deref())
504            .execute(&mut *tx)
505            .await
506            .map_err(map_sql_error)?;
507        }
508
509        tx.commit().await.map_err(map_sql_error)?;
510        Ok(())
511    }
512
513    pub async fn clear_login_failures_for_provider(
514        &self,
515        provider: &str,
516        normalized_identifier: &str,
517    ) -> AppResult<()> {
518        sqlx::query(
519            r#"
520            delete from auth_password.login_failures
521            where provider = $1
522              and identifier = $2
523            "#,
524        )
525        .bind(provider)
526        .bind(normalized_identifier)
527        .execute(&self.pool)
528        .await
529        .map_err(map_sql_error)?;
530        Ok(())
531    }
532
533    async fn ensure_login_not_locked(
534        &self,
535        normalized_identifier: &str,
536        now: DateTime<Utc>,
537    ) -> AppResult<()> {
538        self.ensure_login_not_locked_for_provider(PASSWORD_PROVIDER, normalized_identifier, now)
539            .await
540    }
541
542    async fn record_failed_login(
543        &self,
544        normalized_identifier: &str,
545        now: DateTime<Utc>,
546        client: &ClientRequestMetadata,
547    ) -> AppResult<()> {
548        self.record_failed_login_for_provider(PASSWORD_PROVIDER, normalized_identifier, now, client)
549            .await
550    }
551
552    async fn clear_login_failures(&self, normalized_identifier: &str) -> AppResult<()> {
553        self.clear_login_failures_for_provider(PASSWORD_PROVIDER, normalized_identifier)
554            .await
555    }
556}
557
558fn invalid_credentials() -> AppError {
559    AppError::new(ErrorCode::Unauthorized, "Invalid identifier or password")
560}
561
562fn rate_limited(locked_until: DateTime<Utc>) -> AppError {
563    AppError::new(
564        ErrorCode::RateLimited,
565        format!(
566            "Too many password login attempts; try again after {}",
567            locked_until.to_rfc3339()
568        ),
569    )
570}
571
572fn map_sql_error(source: sqlx::Error) -> AppError {
573    AppError::new(ErrorCode::Internal, "Internal server error").with_source(source)
574}
575
576#[derive(Debug, Clone, Copy, PartialEq, Eq)]
577struct FailedLoginUpdate {
578    failed_count: i32,
579    window_started_at: DateTime<Utc>,
580    locked_until: Option<DateTime<Utc>>,
581}
582
583fn failed_login_update(
584    failed_count: i32,
585    window_started_at: DateTime<Utc>,
586    now: DateTime<Utc>,
587) -> FailedLoginUpdate {
588    if now - window_started_at > LOGIN_FAILURE_WINDOW {
589        return FailedLoginUpdate {
590            failed_count: 1,
591            window_started_at: now,
592            locked_until: None,
593        };
594    }
595
596    let failed_count = failed_count + 1;
597    FailedLoginUpdate {
598        failed_count,
599        window_started_at,
600        locked_until: (failed_count >= MAX_FAILED_LOGINS).then_some(now + LOGIN_LOCKOUT_DURATION),
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use chrono::Duration;
608
609    #[test]
610    fn failed_login_update_locks_on_fifth_failure_in_window() {
611        let now = Utc::now();
612        let update = failed_login_update(4, now - Duration::minutes(1), now);
613
614        assert_eq!(update.failed_count, 5);
615        assert_eq!(update.window_started_at, now - Duration::minutes(1));
616        assert_eq!(update.locked_until, Some(now + LOGIN_LOCKOUT_DURATION));
617    }
618
619    #[test]
620    fn failed_login_update_resets_expired_window() {
621        let now = Utc::now();
622        let update = failed_login_update(4, now - LOGIN_FAILURE_WINDOW - Duration::seconds(1), now);
623
624        assert_eq!(update.failed_count, 1);
625        assert_eq!(update.window_started_at, now);
626        assert_eq!(update.locked_until, None);
627    }
628}