Skip to main content

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