Skip to main content

max / makenotwork

4.0 KB · 130 lines History Blame Raw
1 //! Authentication queries: login tokens, password resets, lockouts.
2
3 use chrono::{DateTime, Utc};
4 use sqlx::PgPool;
5
6 use super::models::*;
7 use super::UserId;
8 use crate::error::Result;
9
10 /// Result of an atomic failed-login increment.
11 pub struct FailedLoginResult {
12 /// New failed_login_attempts count after increment.
13 pub attempts: i32,
14 /// Whether the account was just locked by this increment.
15 pub just_locked: bool,
16 }
17
18 /// Atomically increment failed login attempts and lock the account if the
19 /// threshold is reached. This prevents race conditions where concurrent
20 /// requests could each pass the lockout check before either increments.
21 ///
22 /// The UPDATE uses a single SQL statement with conditional locked_until
23 /// assignment, so PostgreSQL's row-level locking serializes concurrent callers.
24 #[tracing::instrument(skip_all)]
25 pub async fn increment_failed_login(
26 pool: &PgPool,
27 user_id: UserId,
28 max_attempts: i32,
29 lockout_minutes: i64,
30 ) -> Result<FailedLoginResult> {
31 let row: (i32, bool) = sqlx::query_as(
32 r#"
33 UPDATE users
34 SET failed_login_attempts = failed_login_attempts + 1,
35 last_failed_login_at = NOW(),
36 locked_until = CASE
37 -- Set/refresh the lock only when reaching the threshold AND not
38 -- already inside an active lock window. Without the second clause,
39 -- every failed attempt during a lock pushed `locked_until` forward
40 -- another window, letting an attacker keep a victim perpetually
41 -- locked with one wrong password every <LOCKOUT_MINUTES. Now an
42 -- active lock simply runs out; a fresh failure after it expires
43 -- re-locks (the counter isn't reset until a successful login).
44 WHEN failed_login_attempts + 1 >= $2
45 AND (locked_until IS NULL OR locked_until <= NOW())
46 THEN NOW() + ($3 || ' minutes')::interval
47 ELSE locked_until
48 END
49 WHERE id = $1
50 RETURNING failed_login_attempts, (failed_login_attempts = $2) AS just_locked
51 "#,
52 )
53 .bind(user_id)
54 .bind(max_attempts)
55 .bind(lockout_minutes.to_string())
56 .fetch_one(pool)
57 .await?;
58
59 Ok(FailedLoginResult {
60 attempts: row.0,
61 just_locked: row.1,
62 })
63 }
64
65 /// Reset failed login attempts (on successful login)
66 #[tracing::instrument(skip_all)]
67 pub async fn reset_failed_login(pool: &PgPool, user_id: UserId) -> Result<()> {
68 sqlx::query(
69 "UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1",
70 )
71 .bind(user_id)
72 .execute(pool)
73 .await?;
74
75 Ok(())
76 }
77
78 /// Create a one-time login token
79 #[tracing::instrument(skip_all)]
80 pub async fn create_login_token(
81 pool: &PgPool,
82 user_id: UserId,
83 token_hash: &str,
84 expires_at: DateTime<Utc>,
85 ) -> Result<DbLoginToken> {
86 let token = sqlx::query_as::<_, DbLoginToken>(
87 r#"
88 INSERT INTO login_tokens (user_id, token_hash, expires_at)
89 VALUES ($1, $2, $3)
90 RETURNING *
91 "#,
92 )
93 .bind(user_id)
94 .bind(token_hash)
95 .bind(expires_at)
96 .fetch_one(pool)
97 .await?;
98
99 Ok(token)
100 }
101
102 /// Atomically consume a login token: mark it used and return it in one step.
103 ///
104 /// Returns `Some(token)` if the token was valid and successfully consumed,
105 /// or `None` if the token was already used, expired, or does not exist.
106 /// Because this is a single UPDATE with `used_at IS NULL` in the WHERE clause,
107 /// concurrent requests for the same token will never both succeed.
108 #[tracing::instrument(skip_all)]
109 pub async fn consume_login_token(
110 pool: &PgPool,
111 token_hash: &str,
112 ) -> Result<Option<DbLoginToken>> {
113 let token = sqlx::query_as::<_, DbLoginToken>(
114 r#"
115 UPDATE login_tokens
116 SET used_at = NOW()
117 WHERE token_hash = $1
118 AND used_at IS NULL
119 AND expires_at > NOW()
120 RETURNING *
121 "#,
122 )
123 .bind(token_hash)
124 .fetch_optional(pool)
125 .await?;
126
127 Ok(token)
128 }
129
130