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