//! Authentication queries: login tokens, password resets, lockouts. use chrono::{DateTime, Utc}; use sqlx::PgPool; use super::models::DbLoginToken; use super::{LoginTokenId, UserId}; use crate::error::Result; /// Result of an atomic failed-login increment. pub(crate) 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(crate) async fn increment_failed_login( pool: &PgPool, user_id: UserId, max_attempts: i32, lockout_minutes: i64, ) -> Result { // Capture the pre-update row under a row lock so `just_locked` can be // derived from the *same* predicate the CASE uses to set the lock. Reading // the old values in a CTE avoids the trap where a bare `RETURNING` column is // the post-update value: the previous code computed `just_locked` as // `(failed_login_attempts = $2)` against the new count, which only matched // the exact-threshold attempt and read false on a *re-lock* after an expired // window (counter already >= threshold), so the lockout notification was // silently skipped on every re-lock. Now `just_locked` is exactly "the lock // was (re)set on this call". let row = sqlx::query!( r#" WITH prev AS ( SELECT failed_login_attempts AS old_attempts, locked_until AS old_lock FROM users WHERE id = $1 FOR UPDATE ) UPDATE users u SET failed_login_attempts = u.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 (u.locked_until IS NULL OR u.locked_until <= NOW()) THEN NOW() + ($3 || ' minutes')::interval ELSE u.locked_until END FROM prev WHERE u.id = $1 RETURNING u.failed_login_attempts, (prev.old_attempts + 1 >= $2 AND (prev.old_lock IS NULL OR prev.old_lock <= NOW())) AS "just_locked!" "#, user_id as UserId, max_attempts, lockout_minutes.to_string(), ) .fetch_one(pool) .await?; Ok(FailedLoginResult { attempts: row.failed_login_attempts, just_locked: row.just_locked, }) } /// Reset failed login attempts (on successful login) #[tracing::instrument(skip_all)] pub(crate) 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", user_id as UserId, ) .execute(pool) .await?; Ok(()) } /// Create a one-time login token #[tracing::instrument(skip_all)] pub(crate) async fn create_login_token( pool: &PgPool, user_id: UserId, token_hash: &str, expires_at: DateTime, ) -> Result { // runtime-checked: binds a chrono DateTime param; a bind param's type can't be overridden in the macro when sqlx time+chrono features are unified. 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(crate) 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 id AS "id: LoginTokenId", user_id AS "user_id: UserId", token_hash, expires_at AS "expires_at: chrono::DateTime", used_at AS "used_at: chrono::DateTime", created_at AS "created_at: chrono::DateTime" "#, token_hash, ) .fetch_optional(pool) .await?; Ok(token) } /// Create a single-use password reset token (mirrors [`create_login_token`]). #[tracing::instrument(skip_all)] pub(crate) async fn create_password_reset_token( pool: &PgPool, user_id: UserId, token_hash: &str, expires_at: DateTime, ) -> Result<()> { // runtime-checked: binds a chrono DateTime param; a bind param's type can't be overridden in the macro when sqlx time+chrono features are unified. sqlx::query( r" INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3) ", ) .bind(user_id) .bind(token_hash) .bind(expires_at) .execute(pool) .await?; Ok(()) } /// Check whether a password reset token is currently valid (unused, unexpired), /// returning the user it belongs to, without consuming it. Used to decide /// whether to render the reset form. The token is only spent on submit via /// [`consume_password_reset_token`]. #[tracing::instrument(skip_all)] pub(crate) async fn peek_password_reset_token( pool: &PgPool, token_hash: &str, ) -> Result> { let user_id = sqlx::query_scalar!( r#" SELECT user_id AS "user_id: UserId" FROM password_reset_tokens WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW() "#, token_hash, ) .fetch_optional(pool) .await?; Ok(user_id) } /// Atomically consume a password reset token, returning the user it belongs to. /// /// Returns `None` if the token was already used, expired, or does not exist. /// The single UPDATE with `used_at IS NULL` in the WHERE clause guarantees a /// replay (or a concurrent double-submit) can never succeed twice. #[tracing::instrument(skip_all)] pub(crate) async fn consume_password_reset_token( pool: &PgPool, token_hash: &str, ) -> Result> { let user_id = sqlx::query_scalar!( r#" UPDATE password_reset_tokens SET used_at = NOW() WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW() RETURNING user_id AS "user_id: UserId" "#, token_hash, ) .fetch_optional(pool) .await?; Ok(user_id) } /// Delete consumed or expired password reset tokens (housekeeping so the table /// doesn't accumulate dead rows). Keeps recently-used rows briefly for audit. #[tracing::instrument(skip_all)] pub(crate) async fn prune_password_reset_tokens(pool: &PgPool) -> Result { let result = sqlx::query!( r#" DELETE FROM password_reset_tokens WHERE expires_at < NOW() - interval '7 days' OR used_at < NOW() - interval '7 days' "#, ) .execute(pool) .await?; Ok(result.rows_affected()) } /// Invalidate every outstanding reset token for a user. Called after a /// successful reset so any other links mailed to the same account (e.g. a /// double request) are dead, matching the old hash-binding's "completing a /// reset kills all outstanding links" behavior. #[tracing::instrument(skip_all)] pub(crate) async fn invalidate_password_reset_tokens(pool: &PgPool, user_id: UserId) -> Result<()> { sqlx::query!( r#" UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL "#, user_id as UserId, ) .execute(pool) .await?; Ok(()) }