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
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/// 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}
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}