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
81pub async fn get_unused_user_email_code_with_user_id(
82    conn: &mut PgConnection,
83    user_id: Uuid,
84    purpose: UserEmailCodePurpose,
85) -> ModelResult<Option<UserEmailCode>> {
86    let now = Utc::now();
87    let record = sqlx::query_as!(
88        UserEmailCode,
89        r#"
90SELECT id,
91  user_id,
92  code,
93  purpose AS "purpose: UserEmailCodePurpose",
94  attempt_count,
95  expires_at,
96  used_at,
97  created_at,
98  updated_at,
99  deleted_at
100FROM user_email_codes
101WHERE user_id = $1
102  AND purpose = $2
103  AND deleted_at IS NULL
104  AND used_at IS NULL
105  AND expires_at > $3
106        "#,
107        user_id,
108        purpose as UserEmailCodePurpose,
109        now
110    )
111    .fetch_optional(conn)
112    .await?;
113
114    Ok(record)
115}
116
117pub async fn is_reset_user_email_code_valid(
118    conn: &mut PgConnection,
119    user_id: Uuid,
120    purpose: UserEmailCodePurpose,
121    code: &DbSecret,
122) -> ModelResult<bool> {
123    let now = Utc::now();
124    let record = sqlx::query!(
125        r#"
126SELECT id
127FROM user_email_codes
128WHERE user_id = $1
129  AND purpose = $2
130  AND code = $3
131  AND deleted_at IS NULL
132  AND used_at IS NULL
133  AND expires_at > $4
134       "#,
135        user_id,
136        purpose as UserEmailCodePurpose,
137        code.expose_secret(),
138        now
139    )
140    .fetch_optional(conn)
141    .await?;
142
143    Ok(record.is_some())
144}
145
146pub async fn mark_user_email_code_used(
147    conn: &mut PgConnection,
148    user_id: Uuid,
149    purpose: UserEmailCodePurpose,
150    code: &DbSecret,
151) -> ModelResult<bool> {
152    let result = sqlx::query!(
153        r#"
154UPDATE user_email_codes
155SET used_at = NOW(),
156  deleted_at = NOW()
157WHERE user_id = $1
158  AND purpose = $2
159  AND code = $3
160  AND deleted_at IS NULL
161        "#,
162        user_id,
163        purpose as UserEmailCodePurpose,
164        code.expose_secret(),
165    )
166    .execute(conn)
167    .await?;
168
169    Ok(result.rows_affected() > 0)
170}
171
172/// Counts a wrong guess against the user's live code and retires it once `max_attempts` is reached.
173///
174/// Keyed on the user and purpose rather than on what was typed: there is only ever one live code, so
175/// any refused guess is a guess against it.
176pub async fn record_failed_attempt(
177    conn: &mut PgConnection,
178    user_id: Uuid,
179    purpose: UserEmailCodePurpose,
180    max_attempts: i32,
181) -> ModelResult<()> {
182    sqlx::query!(
183        r#"
184UPDATE user_email_codes
185SET attempt_count = attempt_count + 1,
186  deleted_at = CASE
187    WHEN attempt_count + 1 >= $3 THEN NOW()
188    ELSE deleted_at
189  END
190WHERE user_id = $1
191  AND purpose = $2
192  AND deleted_at IS NULL
193  AND used_at IS NULL
194        "#,
195        user_id,
196        purpose as UserEmailCodePurpose,
197        max_attempts,
198    )
199    .execute(conn)
200    .await?;
201
202    Ok(())
203}