Skip to main content

max / makenotwork

3.3 KB · 121 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 WHEN failed_login_attempts + 1 >= $2 THEN NOW() + ($3 || ' minutes')::interval
38 ELSE locked_until
39 END
40 WHERE id = $1
41 RETURNING failed_login_attempts, (failed_login_attempts = $2) AS just_locked
42 "#,
43 )
44 .bind(user_id)
45 .bind(max_attempts)
46 .bind(lockout_minutes.to_string())
47 .fetch_one(pool)
48 .await?;
49
50 Ok(FailedLoginResult {
51 attempts: row.0,
52 just_locked: row.1,
53 })
54 }
55
56 /// Reset failed login attempts (on successful login)
57 #[tracing::instrument(skip_all)]
58 pub async fn reset_failed_login(pool: &PgPool, user_id: UserId) -> Result<()> {
59 sqlx::query(
60 "UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1",
61 )
62 .bind(user_id)
63 .execute(pool)
64 .await?;
65
66 Ok(())
67 }
68
69 /// Create a one-time login token
70 #[tracing::instrument(skip_all)]
71 pub async fn create_login_token(
72 pool: &PgPool,
73 user_id: UserId,
74 token_hash: &str,
75 expires_at: DateTime<Utc>,
76 ) -> Result<DbLoginToken> {
77 let token = sqlx::query_as::<_, DbLoginToken>(
78 r#"
79 INSERT INTO login_tokens (user_id, token_hash, expires_at)
80 VALUES ($1, $2, $3)
81 RETURNING *
82 "#,
83 )
84 .bind(user_id)
85 .bind(token_hash)
86 .bind(expires_at)
87 .fetch_one(pool)
88 .await?;
89
90 Ok(token)
91 }
92
93 /// Atomically consume a login token: mark it used and return it in one step.
94 ///
95 /// Returns `Some(token)` if the token was valid and successfully consumed,
96 /// or `None` if the token was already used, expired, or does not exist.
97 /// Because this is a single UPDATE with `used_at IS NULL` in the WHERE clause,
98 /// concurrent requests for the same token will never both succeed.
99 #[tracing::instrument(skip_all)]
100 pub async fn consume_login_token(
101 pool: &PgPool,
102 token_hash: &str,
103 ) -> Result<Option<DbLoginToken>> {
104 let token = sqlx::query_as::<_, DbLoginToken>(
105 r#"
106 UPDATE login_tokens
107 SET used_at = NOW()
108 WHERE token_hash = $1
109 AND used_at IS NULL
110 AND expires_at > NOW()
111 RETURNING *
112 "#,
113 )
114 .bind(token_hash)
115 .fetch_optional(pool)
116 .await?;
117
118 Ok(token)
119 }
120
121