//! Authentication queries: login tokens, password resets, lockouts. use chrono::{DateTime, Utc}; use sqlx::PgPool; use super::models::*; use super::UserId; use crate::error::Result; /// Result of an atomic failed-login increment. pub struct FailedLoginResult { /// New failed_login_attempts count after increment. pub attempts: i32, /// Whether the account was just locked by this increment. pub just_locked: bool, } /// Atomically increment failed login attempts and lock the account if the /// threshold is reached. This prevents race conditions where concurrent /// requests could each pass the lockout check before either increments. /// /// The UPDATE uses a single SQL statement with conditional locked_until /// assignment, so PostgreSQL's row-level locking serializes concurrent callers. #[tracing::instrument(skip_all)] pub async fn increment_failed_login( pool: &PgPool, user_id: UserId, max_attempts: i32, lockout_minutes: i64, ) -> Result { let row: (i32, bool) = sqlx::query_as( r#" UPDATE users SET failed_login_attempts = failed_login_attempts + 1, last_failed_login_at = NOW(), locked_until = CASE -- Set/refresh the lock only when reaching the threshold AND not -- already inside an active lock window. Without the second clause, -- every failed attempt during a lock pushed `locked_until` forward -- another window, letting an attacker keep a victim perpetually -- locked with one wrong password every = $2 AND (locked_until IS NULL OR locked_until <= NOW()) THEN NOW() + ($3 || ' minutes')::interval ELSE locked_until END WHERE id = $1 RETURNING failed_login_attempts, (failed_login_attempts = $2) AS just_locked "#, ) .bind(user_id) .bind(max_attempts) .bind(lockout_minutes.to_string()) .fetch_one(pool) .await?; Ok(FailedLoginResult { attempts: row.0, just_locked: row.1, }) } /// Reset failed login attempts (on successful login) #[tracing::instrument(skip_all)] pub async fn reset_failed_login(pool: &PgPool, user_id: UserId) -> Result<()> { sqlx::query( "UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1", ) .bind(user_id) .execute(pool) .await?; Ok(()) } /// Create a one-time login token #[tracing::instrument(skip_all)] pub async fn create_login_token( pool: &PgPool, user_id: UserId, token_hash: &str, expires_at: DateTime, ) -> Result { let token = sqlx::query_as::<_, DbLoginToken>( r#" INSERT INTO login_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3) RETURNING * "#, ) .bind(user_id) .bind(token_hash) .bind(expires_at) .fetch_one(pool) .await?; Ok(token) } /// Atomically consume a login token: mark it used and return it in one step. /// /// Returns `Some(token)` if the token was valid and successfully consumed, /// or `None` if the token was already used, expired, or does not exist. /// Because this is a single UPDATE with `used_at IS NULL` in the WHERE clause, /// concurrent requests for the same token will never both succeed. #[tracing::instrument(skip_all)] pub async fn consume_login_token( pool: &PgPool, token_hash: &str, ) -> Result> { let token = sqlx::query_as::<_, DbLoginToken>( r#" UPDATE login_tokens SET used_at = NOW() WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW() RETURNING * "#, ) .bind(token_hash) .fetch_optional(pool) .await?; Ok(token) }