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