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