use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use crate::errors::AppError;
use crate::repositories::{
normalize_email, LockoutStatus, LoginAttemptConfig, LoginAttemptRepository,
};
pub struct PostgresLoginAttemptRepository {
pool: PgPool,
}
impl PostgresLoginAttemptRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl LoginAttemptRepository for PostgresLoginAttemptRepository {
async fn record_attempt(
&self,
user_id: Option<Uuid>,
email: &str,
ip_address: Option<&str>,
successful: bool,
) -> Result<(), AppError> {
sqlx::query(
r#"
INSERT INTO login_attempts (id, user_id, email, ip_address, successful, attempted_at)
VALUES ($1, $2, $3, $4, $5, NOW())
"#,
)
.bind(Uuid::new_v4())
.bind(user_id)
.bind(normalize_email(email))
.bind(ip_address)
.bind(successful)
.execute(&self.pool)
.await
.map_err(|e| AppError::Internal(e.into()))?;
Ok(())
}
async fn get_lockout_status(
&self,
email: &str,
config: &LoginAttemptConfig,
) -> Result<LockoutStatus, AppError> {
let now = Utc::now();
let window_start = now - Duration::minutes(config.window_minutes as i64);
let row: Option<(i64, Option<DateTime<Utc>>)> = sqlx::query_as(
r#"
SELECT COUNT(*) as failed_count, MAX(attempted_at) as last_failed
FROM login_attempts
WHERE lower(email) = $1
AND successful = FALSE
AND attempted_at > $2
"#,
)
.bind(normalize_email(email))
.bind(window_start)
.fetch_optional(&self.pool)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let (failed_attempts, last_failed) = row.unwrap_or((0, None));
let failed_attempts = failed_attempts as u32;
if failed_attempts >= config.max_attempts {
if let Some(last_failed) = last_failed {
let lockout_expires_at =
last_failed + Duration::minutes(config.lockout_minutes as i64);
if lockout_expires_at > now {
let remaining = (lockout_expires_at - now).num_seconds();
return Ok(LockoutStatus {
is_locked: true,
failed_attempts,
lockout_expires_at: Some(lockout_expires_at),
lockout_remaining_secs: Some(remaining),
});
}
}
}
Ok(LockoutStatus {
is_locked: false,
failed_attempts,
lockout_expires_at: None,
lockout_remaining_secs: None,
})
}
async fn clear_failed_attempts(&self, email: &str) -> Result<(), AppError> {
sqlx::query(
r#"
DELETE FROM login_attempts
WHERE lower(email) = $1 AND successful = FALSE
"#,
)
.bind(normalize_email(email))
.execute(&self.pool)
.await
.map_err(|e| AppError::Internal(e.into()))?;
Ok(())
}
async fn cleanup_old_records(&self, older_than: DateTime<Utc>) -> Result<u64, AppError> {
let result = sqlx::query(
r#"
DELETE FROM login_attempts
WHERE attempted_at < $1
"#,
)
.bind(older_than)
.execute(&self.pool)
.await
.map_err(|e| AppError::Internal(e.into()))?;
Ok(result.rows_affected())
}
async fn record_failed_attempt_atomic(
&self,
user_id: Option<Uuid>,
email: &str,
ip_address: Option<&str>,
config: &LoginAttemptConfig,
) -> Result<LockoutStatus, AppError> {
let now = Utc::now();
let window_start = now - Duration::minutes(config.window_minutes as i64);
let email_normalized = normalize_email(email);
let row: (i64, Option<DateTime<Utc>>) = sqlx::query_as(
r#"
WITH inserted AS (
INSERT INTO login_attempts (id, user_id, email, ip_address, successful, attempted_at)
VALUES ($1, $2, $3, $4, FALSE, NOW())
)
SELECT COUNT(*) as failed_count, MAX(attempted_at) as last_failed
FROM login_attempts
WHERE lower(email) = $3
AND successful = FALSE
AND attempted_at > $5
"#,
)
.bind(Uuid::new_v4())
.bind(user_id)
.bind(&email_normalized)
.bind(ip_address)
.bind(window_start)
.fetch_one(&self.pool)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let (failed_count, last_failed) = row;
let failed_attempts = failed_count as u32;
if failed_attempts >= config.max_attempts {
if let Some(last_failed) = last_failed {
let lockout_expires_at =
last_failed + Duration::minutes(config.lockout_minutes as i64);
if lockout_expires_at > now {
let remaining = (lockout_expires_at - now).num_seconds();
return Ok(LockoutStatus {
is_locked: true,
failed_attempts,
lockout_expires_at: Some(lockout_expires_at),
lockout_remaining_secs: Some(remaining),
});
}
}
}
Ok(LockoutStatus {
is_locked: false,
failed_attempts,
lockout_expires_at: None,
lockout_remaining_secs: None,
})
}
}