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