Skip to main content

headless_lms_models/
user_email_codes.rs

1use rand::RngExt;
2use secrecy::ExposeSecret;
3
4use crate::prelude::*;
5
6/// What a code authorises. Every read is scoped by it, so a code mailed for one action cannot be
7/// spent on another.
8#[derive(Debug, PartialEq, Eq, Clone, Copy, Type)]
9#[sqlx(type_name = "user_email_code_purpose", rename_all = "snake_case")]
10pub enum UserEmailCodePurpose {
11    AdminLogin,
12    AccountDeletion,
13    EmailOwnershipVerification,
14}
15
16#[derive(sqlx::FromRow, Debug, Clone)]
17pub struct UserEmailCode {
18    pub id: Uuid,
19    pub user_id: Uuid,
20    pub code: DbSecret,
21    pub purpose: UserEmailCodePurpose,
22    pub attempt_count: i32,
23    pub expires_at: DateTime<Utc>,
24    pub used_at: Option<DateTime<Utc>>,
25    pub created_at: DateTime<Utc>,
26    pub updated_at: DateTime<Utc>,
27    pub deleted_at: Option<DateTime<Utc>>,
28}
29
30/// A fresh code for mailing to a user.
31///
32/// Zero padded from a range that starts at zero: formatting `random_range(100_000..1_000_000)`
33/// instead would silently exclude the 100 000 codes with a leading zero.
34pub fn generate_code() -> DbSecret {
35    DbSecret::new(format!("{:06}", rand::rng().random_range(0..1_000_000u32)))
36}
37
38/// Retires the user's outstanding code for `purpose` and inserts a new one.
39///
40/// The unique index allows only one live code per user and purpose, so the retirement is what makes
41/// a resend possible at all.
42pub async fn insert_user_email_code(
43    conn: &mut PgConnection,
44    user_id: Uuid,
45    purpose: UserEmailCodePurpose,
46    code: &DbSecret,
47) -> ModelResult<()> {
48    let mut tx = conn.begin().await?;
49
50    sqlx::query!(
51        r#"
52UPDATE user_email_codes
53SET deleted_at = NOW()
54WHERE user_id = $1
55  AND purpose = $2
56  AND deleted_at IS NULL
57    "#,
58        user_id,
59        purpose as UserEmailCodePurpose,
60    )
61    .execute(&mut *tx)
62    .await?;
63
64    sqlx::query!(
65        r#"
66INSERT INTO user_email_codes (code, user_id, purpose)
67VALUES ($1, $2, $3)
68        "#,
69        code.expose_secret(),
70        user_id,
71        purpose as UserEmailCodePurpose,
72    )
73    .execute(&mut *tx)
74    .await?;
75
76    tx.commit().await?;
77
78    Ok(())
79}
80
81/// The user's outstanding code for `purpose`, whether or not it has expired.
82///
83/// Use this to tell a timed out code apart from one that was never requested, spent or retired;
84/// [`get_unused_user_email_code_with_user_id`] returns nothing for all of those.
85pub async fn get_outstanding_user_email_code(
86    conn: &mut PgConnection,
87    user_id: Uuid,
88    purpose: UserEmailCodePurpose,
89) -> ModelResult<Option<UserEmailCode>> {
90    let record = sqlx::query_as!(
91        UserEmailCode,
92        r#"
93SELECT id,
94  user_id,
95  code,
96  purpose AS "purpose: UserEmailCodePurpose",
97  attempt_count,
98  expires_at,
99  used_at,
100  created_at,
101  updated_at,
102  deleted_at
103FROM user_email_codes
104WHERE user_id = $1
105  AND purpose = $2
106  AND deleted_at IS NULL
107  AND used_at IS NULL
108        "#,
109        user_id,
110        purpose as UserEmailCodePurpose,
111    )
112    .fetch_optional(conn)
113    .await?;
114
115    Ok(record)
116}
117
118/// The user's code for `purpose` if one is still usable right now.
119pub async fn get_unused_user_email_code_with_user_id(
120    conn: &mut PgConnection,
121    user_id: Uuid,
122    purpose: UserEmailCodePurpose,
123) -> ModelResult<Option<UserEmailCode>> {
124    let code = get_outstanding_user_email_code(conn, user_id, purpose).await?;
125    Ok(code.filter(|code| code.expires_at > Utc::now()))
126}
127
128pub async fn is_reset_user_email_code_valid(
129    conn: &mut PgConnection,
130    user_id: Uuid,
131    purpose: UserEmailCodePurpose,
132    code: &DbSecret,
133) -> ModelResult<bool> {
134    let now = Utc::now();
135    let record = sqlx::query!(
136        r#"
137SELECT id
138FROM user_email_codes
139WHERE user_id = $1
140  AND purpose = $2
141  AND code = $3
142  AND deleted_at IS NULL
143  AND used_at IS NULL
144  AND expires_at > $4
145       "#,
146        user_id,
147        purpose as UserEmailCodePurpose,
148        code.expose_secret(),
149        now
150    )
151    .fetch_optional(conn)
152    .await?;
153
154    Ok(record.is_some())
155}
156
157pub async fn mark_user_email_code_used(
158    conn: &mut PgConnection,
159    user_id: Uuid,
160    purpose: UserEmailCodePurpose,
161    code: &DbSecret,
162) -> ModelResult<bool> {
163    let result = sqlx::query!(
164        r#"
165UPDATE user_email_codes
166SET used_at = NOW(),
167  deleted_at = NOW()
168WHERE user_id = $1
169  AND purpose = $2
170  AND code = $3
171  AND deleted_at IS NULL
172        "#,
173        user_id,
174        purpose as UserEmailCodePurpose,
175        code.expose_secret(),
176    )
177    .execute(conn)
178    .await?;
179
180    Ok(result.rows_affected() > 0)
181}
182
183/// Counts a wrong guess against the user's live code and retires it once `max_attempts` is reached.
184///
185/// Keyed on the user and purpose rather than on what was typed: there is only ever one live code, so
186/// any refused guess is a guess against it.
187///
188/// Returns whether this guess retired the code, which is a caller's cue to send the user for a new
189/// one rather than another attempt. Also false when there was no live code to count against.
190pub async fn record_failed_attempt(
191    conn: &mut PgConnection,
192    user_id: Uuid,
193    purpose: UserEmailCodePurpose,
194    max_attempts: i32,
195) -> ModelResult<bool> {
196    let record = sqlx::query!(
197        r#"
198UPDATE user_email_codes
199SET attempt_count = attempt_count + 1,
200  deleted_at = CASE
201    WHEN attempt_count + 1 >= $3 THEN NOW()
202    ELSE deleted_at
203  END
204WHERE user_id = $1
205  AND purpose = $2
206  AND deleted_at IS NULL
207  AND used_at IS NULL
208RETURNING deleted_at IS NOT NULL AS "retired!"
209        "#,
210        user_id,
211        purpose as UserEmailCodePurpose,
212        max_attempts,
213    )
214    .fetch_optional(conn)
215    .await?;
216
217    Ok(record.is_some_and(|record| record.retired))
218}