Skip to main content

shared/models/
user_password_reset.rs

1use crate::prelude::*;
2use rand::distr::SampleString;
3use serde::{Deserialize, Serialize};
4use sqlx::{Row, postgres::PgRow};
5use std::{collections::BTreeMap, sync::LazyLock};
6
7#[derive(Serialize, Deserialize)]
8pub struct UserPasswordReset {
9    pub uuid: uuid::Uuid,
10    pub user: super::user::User,
11
12    pub token: String,
13
14    pub created: chrono::NaiveDateTime,
15
16    extension_data: super::ModelExtensionData,
17}
18
19impl BaseModel for UserPasswordReset {
20    const NAME: &'static str = "user_password_reset";
21
22    fn get_extension_list() -> &'static super::ModelExtensionList {
23        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
24            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
25
26        &EXTENSIONS
27    }
28
29    fn get_extension_data(&self) -> &super::ModelExtensionData {
30        &self.extension_data
31    }
32
33    #[inline]
34    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
35        let prefix = prefix.unwrap_or_default();
36
37        let mut columns = BTreeMap::from([
38            (
39                "user_password_resets.uuid",
40                compact_str::format_compact!("{prefix}uuid"),
41            ),
42            (
43                "user_password_resets.token",
44                compact_str::format_compact!("{prefix}token"),
45            ),
46            (
47                "user_password_resets.created",
48                compact_str::format_compact!("{prefix}created"),
49            ),
50        ]);
51
52        columns.extend(super::user::User::base_columns(Some("user_")));
53
54        columns
55    }
56
57    #[inline]
58    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
59        let prefix = prefix.unwrap_or_default();
60
61        Ok(Self {
62            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
63            user: super::user::User::map(Some("user_"), row)?,
64            token: row.try_get(compact_str::format_compact!("{prefix}token").as_str())?,
65            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
66            extension_data: Self::map_extensions(prefix, row)?,
67        })
68    }
69}
70
71impl UserPasswordReset {
72    pub async fn create(
73        database: &crate::database::Database,
74        user_uuid: uuid::Uuid,
75    ) -> Result<String, anyhow::Error> {
76        let existing = sqlx::query(
77            r#"
78            SELECT COUNT(*)
79            FROM user_password_resets
80            WHERE user_password_resets.user_uuid = $1 AND user_password_resets.created > NOW() - INTERVAL '20 minutes'
81            "#,
82        )
83        .bind(user_uuid)
84        .fetch_optional(database.read())
85        .await?;
86
87        if let Some(row) = existing
88            && row.get::<i64, _>(0) > 0
89        {
90            return Err(anyhow::anyhow!(
91                "a password reset was already requested recently"
92            ));
93        }
94
95        let token = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 96);
96
97        sqlx::query(
98            r#"
99            INSERT INTO user_password_resets (user_uuid, token_start, token, created)
100            VALUES ($1, $2, crypt($3, gen_salt('bf', 12)), NOW())
101            "#,
102        )
103        .bind(user_uuid)
104        .bind(&token[0..16])
105        .bind(&token)
106        .execute(database.write())
107        .await?;
108
109        Ok(token)
110    }
111
112    pub async fn create_with_transaction(
113        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
114        user_uuid: uuid::Uuid,
115    ) -> Result<String, anyhow::Error> {
116        let existing = sqlx::query(
117            r#"
118            SELECT COUNT(*)
119            FROM user_password_resets
120            WHERE user_password_resets.user_uuid = $1 AND user_password_resets.created > NOW() - INTERVAL '20 minutes'
121            "#,
122        )
123        .bind(user_uuid)
124        .fetch_optional(&mut **transaction)
125        .await?;
126
127        if let Some(row) = existing
128            && row.get::<i64, _>(0) > 0
129        {
130            return Err(anyhow::anyhow!(
131                "a password reset was already requested recently"
132            ));
133        }
134
135        let token = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 96);
136
137        sqlx::query(
138            r#"
139            INSERT INTO user_password_resets (user_uuid, token_start, token, created)
140            VALUES ($1, $2, crypt($3, gen_salt('bf', 12)), NOW())
141            "#,
142        )
143        .bind(user_uuid)
144        .bind(&token[0..16])
145        .bind(&token)
146        .execute(&mut **transaction)
147        .await?;
148
149        Ok(token)
150    }
151
152    pub async fn delete_by_token(
153        database: &crate::database::Database,
154        token: &str,
155    ) -> Result<Option<Self>, crate::database::DatabaseError> {
156        let Some(token_start) = token.get(0..16) else {
157            return Ok(None);
158        };
159
160        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
161            r#"
162            WITH user_password_resets AS MATERIALIZED (
163                SELECT * FROM user_password_resets
164                WHERE user_password_resets.token_start = $1
165                AND user_password_resets.created > NOW() - INTERVAL '20 minutes'
166            )
167            SELECT {}, {} FROM user_password_resets
168            JOIN users ON users.uuid = user_password_resets.user_uuid
169            LEFT JOIN roles ON roles.uuid = users.role_uuid
170            WHERE user_password_resets.token = crypt($2, user_password_resets.token)
171            "#,
172            Self::columns_sql(None),
173            super::user::User::columns_sql(Some("user_"))
174        )))
175        .bind(token_start)
176        .bind(token)
177        .fetch_optional(database.read())
178        .await?;
179
180        let row = match row {
181            Some(row) => row,
182            None => return Ok(None),
183        };
184
185        sqlx::query(
186            r#"
187            DELETE FROM user_password_resets
188            WHERE user_password_resets.uuid = $1
189            "#,
190        )
191        .bind(row.get::<uuid::Uuid, _>("uuid"))
192        .execute(database.write())
193        .await?;
194
195        Ok(Some(Self::map(None, &row)?))
196    }
197
198    pub async fn delete_by_user_uuid(
199        database: &crate::database::Database,
200        user_uuid: uuid::Uuid,
201    ) -> Result<(), crate::database::DatabaseError> {
202        sqlx::query(
203            r#"
204            DELETE FROM user_password_resets
205            WHERE user_password_resets.user_uuid = $1
206            "#,
207        )
208        .bind(user_uuid)
209        .execute(database.write())
210        .await?;
211
212        Ok(())
213    }
214
215    pub async fn delete_expired(database: &crate::database::Database) -> Result<u64, sqlx::Error> {
216        Ok(sqlx::query(
217            r#"
218            DELETE FROM user_password_resets
219            WHERE user_password_resets.created < NOW() - INTERVAL '20 minutes'
220            "#,
221        )
222        .execute(database.write())
223        .await?
224        .rows_affected())
225    }
226}