shared/models/
user_security_key.rs

1use crate::{
2    models::{InsertQueryBuilder, UpdateQueryBuilder},
3    prelude::*,
4};
5use base64::Engine;
6use garde::Validate;
7use serde::{Deserialize, Serialize};
8use sqlx::{Row, postgres::PgRow};
9use std::{
10    collections::BTreeMap,
11    sync::{Arc, LazyLock},
12};
13use utoipa::ToSchema;
14
15#[derive(Serialize, Deserialize)]
16pub struct UserSecurityKey {
17    pub uuid: uuid::Uuid,
18
19    pub name: compact_str::CompactString,
20
21    pub passkey: Option<webauthn_rs::prelude::Passkey>,
22    pub registration: Option<webauthn_rs::prelude::PasskeyRegistration>,
23
24    pub last_used: Option<chrono::NaiveDateTime>,
25    pub created: chrono::NaiveDateTime,
26}
27
28impl BaseModel for UserSecurityKey {
29    const NAME: &'static str = "user_security_key";
30
31    #[inline]
32    fn columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
33        let prefix = prefix.unwrap_or_default();
34
35        BTreeMap::from([
36            (
37                "user_security_keys.uuid",
38                compact_str::format_compact!("{prefix}uuid"),
39            ),
40            (
41                "user_security_keys.name",
42                compact_str::format_compact!("{prefix}name"),
43            ),
44            (
45                "user_security_keys.passkey",
46                compact_str::format_compact!("{prefix}passkey"),
47            ),
48            (
49                "user_security_keys.registration",
50                compact_str::format_compact!("{prefix}registration"),
51            ),
52            (
53                "user_security_keys.last_used",
54                compact_str::format_compact!("{prefix}last_used"),
55            ),
56            (
57                "user_security_keys.created",
58                compact_str::format_compact!("{prefix}created"),
59            ),
60        ])
61    }
62
63    #[inline]
64    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
65        let prefix = prefix.unwrap_or_default();
66
67        Ok(Self {
68            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
69            name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
70            passkey: if row
71                .try_get::<serde_json::Value, _>(
72                    compact_str::format_compact!("{prefix}passkey").as_str(),
73                )
74                .is_ok()
75            {
76                serde_json::from_value(
77                    row.try_get(compact_str::format_compact!("{prefix}passkey").as_str())?,
78                )
79                .ok()
80            } else {
81                None
82            },
83            registration: if row
84                .try_get::<serde_json::Value, _>(
85                    compact_str::format_compact!("{prefix}registration").as_str(),
86                )
87                .is_ok()
88            {
89                serde_json::from_value(
90                    row.try_get(compact_str::format_compact!("{prefix}registration").as_str())?,
91                )
92                .ok()
93            } else {
94                None
95            },
96            last_used: row.try_get(compact_str::format_compact!("{prefix}last_used").as_str())?,
97            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
98        })
99    }
100}
101
102impl UserSecurityKey {
103    pub async fn by_user_uuid_uuid(
104        database: &crate::database::Database,
105        user_uuid: uuid::Uuid,
106        uuid: uuid::Uuid,
107    ) -> Result<Option<Self>, crate::database::DatabaseError> {
108        let row = sqlx::query(&format!(
109            r#"
110            SELECT {}
111            FROM user_security_keys
112            WHERE user_security_keys.user_uuid = $1 AND user_security_keys.uuid = $2
113            "#,
114            Self::columns_sql(None)
115        ))
116        .bind(user_uuid)
117        .bind(uuid)
118        .fetch_optional(database.read())
119        .await?;
120
121        row.try_map(|row| Self::map(None, &row))
122    }
123
124    pub async fn by_user_uuid_with_pagination(
125        database: &crate::database::Database,
126        user_uuid: uuid::Uuid,
127        page: i64,
128        per_page: i64,
129        search: Option<&str>,
130    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
131        let offset = (page - 1) * per_page;
132
133        let rows = sqlx::query(&format!(
134            r#"
135            SELECT {}, COUNT(*) OVER() AS total_count
136            FROM user_security_keys
137            WHERE user_security_keys.user_uuid = $1 AND user_security_keys.passkey IS NOT NULL AND ($2 IS NULL OR user_security_keys.name ILIKE '%' || $2 || '%')
138            ORDER BY user_security_keys.created
139            LIMIT $3 OFFSET $4
140            "#,
141            Self::columns_sql(None)
142        ))
143        .bind(user_uuid)
144        .bind(search)
145        .bind(per_page)
146        .bind(offset)
147        .fetch_all(database.read())
148        .await?;
149
150        Ok(super::Pagination {
151            total: rows
152                .first()
153                .map_or(Ok(0), |row| row.try_get("total_count"))?,
154            per_page,
155            page,
156            data: rows
157                .into_iter()
158                .map(|row| Self::map(None, &row))
159                .try_collect_vec()?,
160        })
161    }
162
163    pub async fn delete_unconfigured_by_user_uuid_name(
164        database: &crate::database::Database,
165        user_uuid: uuid::Uuid,
166        name: &str,
167    ) -> Result<(), sqlx::Error> {
168        sqlx::query(
169            r#"
170            DELETE FROM user_security_keys
171            WHERE user_security_keys.user_uuid = $1 AND user_security_keys.name = $2 AND user_security_keys.passkey IS NULL
172            "#,
173        )
174        .bind(user_uuid)
175        .bind(name)
176        .execute(database.write())
177        .await?;
178
179        Ok(())
180    }
181
182    pub async fn delete_unconfigured(
183        database: &crate::database::Database,
184    ) -> Result<u64, sqlx::Error> {
185        Ok(sqlx::query(
186            r#"
187            DELETE FROM user_security_keys
188            WHERE user_security_keys.created < NOW() - INTERVAL '1 day' AND user_security_keys.passkey IS NULL
189            "#,
190        )
191        .execute(database.write())
192        .await?
193        .rows_affected())
194    }
195
196    #[inline]
197    pub fn into_api_object(self) -> ApiUserSecurityKey {
198        ApiUserSecurityKey {
199            uuid: self.uuid,
200            name: self.name,
201            credential_id: self.passkey.as_ref().map_or_else(
202                || "".to_string(),
203                |pk| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pk.cred_id()),
204            ),
205            last_used: self.last_used.map(|dt| dt.and_utc()),
206            created: self.created.and_utc(),
207        }
208    }
209}
210
211#[derive(ToSchema, Deserialize, Validate)]
212pub struct CreateUserSecurityKeyOptions {
213    #[garde(skip)]
214    pub user_uuid: uuid::Uuid,
215
216    #[garde(length(chars, min = 3, max = 31))]
217    #[schema(min_length = 3, max_length = 31)]
218    pub name: compact_str::CompactString,
219
220    #[garde(skip)]
221    #[schema(value_type = serde_json::Value)]
222    pub registration: webauthn_rs::prelude::PasskeyRegistration,
223}
224
225#[async_trait::async_trait]
226impl CreatableModel for UserSecurityKey {
227    type CreateOptions<'a> = CreateUserSecurityKeyOptions;
228    type CreateResult = Self;
229
230    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
231        static CREATE_LISTENERS: LazyLock<CreateListenerList<UserSecurityKey>> =
232            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
233
234        &CREATE_LISTENERS
235    }
236
237    async fn create(
238        state: &crate::State,
239        mut options: Self::CreateOptions<'_>,
240    ) -> Result<Self, crate::database::DatabaseError> {
241        let mut transaction = state.database.write().begin().await?;
242
243        let mut query_builder = InsertQueryBuilder::new("user_security_keys");
244
245        Self::run_create_handlers(&mut options, &mut query_builder, state, &mut transaction)
246            .await?;
247
248        query_builder
249            .set("user_uuid", options.user_uuid)
250            .set("name", &options.name)
251            .set(
252                "credential_id",
253                rand::random_iter().take(16).collect::<Vec<u8>>(),
254            )
255            .set("registration", serde_json::to_value(options.registration)?);
256
257        let row = query_builder
258            .returning(&Self::columns_sql(None))
259            .fetch_one(&mut *transaction)
260            .await?;
261        let security_key = Self::map(None, &row)?;
262
263        transaction.commit().await?;
264
265        Ok(security_key)
266    }
267}
268
269#[derive(ToSchema, Serialize, Deserialize, Validate, Default)]
270pub struct UpdateUserSecurityKeyOptions {
271    #[garde(length(chars, min = 3, max = 31))]
272    #[schema(min_length = 3, max_length = 31)]
273    pub name: Option<compact_str::CompactString>,
274}
275
276#[async_trait::async_trait]
277impl UpdatableModel for UserSecurityKey {
278    type UpdateOptions = UpdateUserSecurityKeyOptions;
279
280    fn get_update_handlers() -> &'static LazyLock<UpdateListenerList<Self>> {
281        static UPDATE_LISTENERS: LazyLock<UpdateListenerList<UserSecurityKey>> =
282            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
283
284        &UPDATE_LISTENERS
285    }
286
287    async fn update(
288        &mut self,
289        state: &crate::State,
290        mut options: Self::UpdateOptions,
291    ) -> Result<(), crate::database::DatabaseError> {
292        options.validate()?;
293
294        let mut transaction = state.database.write().begin().await?;
295
296        let mut query_builder = UpdateQueryBuilder::new("user_security_keys");
297
298        Self::run_update_handlers(
299            self,
300            &mut options,
301            &mut query_builder,
302            state,
303            &mut transaction,
304        )
305        .await?;
306
307        query_builder
308            .set("name", options.name.as_ref())
309            .where_eq("uuid", self.uuid);
310
311        query_builder.execute(&mut *transaction).await?;
312
313        if let Some(name) = options.name {
314            self.name = name;
315        }
316
317        transaction.commit().await?;
318
319        Ok(())
320    }
321}
322
323#[async_trait::async_trait]
324impl DeletableModel for UserSecurityKey {
325    type DeleteOptions = ();
326
327    fn get_delete_handlers() -> &'static LazyLock<DeleteListenerList<Self>> {
328        static DELETE_LISTENERS: LazyLock<DeleteListenerList<UserSecurityKey>> =
329            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
330
331        &DELETE_LISTENERS
332    }
333
334    async fn delete(
335        &self,
336        state: &crate::State,
337        options: Self::DeleteOptions,
338    ) -> Result<(), anyhow::Error> {
339        let mut transaction = state.database.write().begin().await?;
340
341        self.run_delete_handlers(&options, state, &mut transaction)
342            .await?;
343
344        sqlx::query(
345            r#"
346            DELETE FROM user_security_keys
347            WHERE user_security_keys.uuid = $1
348            "#,
349        )
350        .bind(self.uuid)
351        .execute(&mut *transaction)
352        .await?;
353
354        transaction.commit().await?;
355
356        Ok(())
357    }
358}
359
360#[derive(ToSchema, Serialize)]
361#[schema(title = "UserSecurityKey")]
362pub struct ApiUserSecurityKey {
363    pub uuid: uuid::Uuid,
364
365    pub name: compact_str::CompactString,
366
367    pub credential_id: String,
368
369    pub last_used: Option<chrono::DateTime<chrono::Utc>>,
370    pub created: chrono::DateTime<chrono::Utc>,
371}