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#[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}