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(|| parking_lot::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(sqlx::AssertSqlSafe(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(sqlx::AssertSqlSafe(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        timeout_seconds: i64,
199    ) -> Result<u64, sqlx::Error> {
200        Ok(sqlx::query(
201            r#"
202            DELETE FROM user_security_keys
203            WHERE user_security_keys.created < $1 AND user_security_keys.passkey IS NULL
204            "#,
205        )
206        .bind(chrono::Utc::now().naive_utc() - chrono::Duration::seconds(timeout_seconds))
207        .execute(database.write())
208        .await?
209        .rows_affected())
210    }
211
212    pub async fn count_by_user_uuid(
213        database: &crate::database::Database,
214        user_uuid: uuid::Uuid,
215    ) -> Result<i64, sqlx::Error> {
216        sqlx::query_scalar(
217            r#"
218            SELECT COUNT(*)
219            FROM user_security_keys
220            WHERE user_security_keys.user_uuid = $1
221            "#,
222        )
223        .bind(user_uuid)
224        .fetch_one(database.read())
225        .await
226    }
227
228    /// Keys that finished registration, so can actually be used to sign in. Locks the counted
229    /// rows, so two concurrent deletions cannot both pass a last-key check.
230    pub async fn count_usable_by_user_uuid_for_update(
231        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
232        user_uuid: uuid::Uuid,
233    ) -> Result<i64, sqlx::Error> {
234        sqlx::query_scalar(
235            r#"
236            SELECT COUNT(*)
237            FROM (
238                SELECT user_security_keys.uuid
239                FROM user_security_keys
240                WHERE user_security_keys.user_uuid = $1 AND user_security_keys.passkey IS NOT NULL
241                FOR UPDATE
242            ) AS usable_keys
243            "#,
244        )
245        .bind(user_uuid)
246        .fetch_one(&mut **transaction)
247        .await
248    }
249}
250
251#[async_trait::async_trait]
252impl IntoApiObject for UserSecurityKey {
253    type ApiObject = ApiUserSecurityKey;
254    type ExtraArgs<'a> = ();
255
256    async fn into_api_object<'a>(
257        self,
258        state: &crate::State,
259        _args: Self::ExtraArgs<'a>,
260    ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
261        let api_object = ApiUserSecurityKey::init_hooks(&self, state).await?;
262
263        let api_object = finish_extendible!(
264            ApiUserSecurityKey {
265                uuid: self.uuid,
266                name: self.name,
267                credential_id: self.passkey.as_ref().map_or_else(
268                    || "".to_string(),
269                    |pk| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pk.cred_id()),
270                ),
271                last_used: self.last_used.map(|dt| dt.and_utc()),
272                created: self.created.and_utc(),
273            },
274            api_object,
275            state
276        )?;
277
278        Ok(api_object)
279    }
280}
281
282#[derive(ToSchema, Deserialize, Validate)]
283pub struct CreateUserSecurityKeyOptions {
284    #[garde(skip)]
285    pub user_uuid: uuid::Uuid,
286
287    #[garde(length(chars, min = 3, max = 31))]
288    #[schema(min_length = 3, max_length = 31)]
289    pub name: compact_str::CompactString,
290
291    #[garde(skip)]
292    #[schema(value_type = serde_json::Value)]
293    pub registration: webauthn_rs::prelude::PasskeyRegistration,
294}
295
296#[async_trait::async_trait]
297impl CreatableModel for UserSecurityKey {
298    type CreateOptions<'a> = CreateUserSecurityKeyOptions;
299    type CreateResult = Self;
300
301    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
302        static CREATE_LISTENERS: LazyLock<CreateListenerList<UserSecurityKey>> =
303            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
304
305        &CREATE_LISTENERS
306    }
307
308    async fn create_with_transaction(
309        state: &crate::State,
310        mut options: Self::CreateOptions<'_>,
311        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
312    ) -> Result<Self, crate::database::DatabaseError> {
313        options.validate()?;
314
315        let mut query_builder = InsertQueryBuilder::new("user_security_keys");
316
317        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
318
319        query_builder
320            .set("user_uuid", options.user_uuid)
321            .set("name", &options.name)
322            .set(
323                "credential_id",
324                rand::random_iter().take(16).collect::<Vec<u8>>(),
325            )
326            .set("registration", serde_json::to_value(&options.registration)?);
327
328        let row = query_builder
329            .returning(&Self::columns_sql(None))
330            .fetch_one(&mut **transaction)
331            .await?;
332        let mut security_key = Self::map(None, &row)?;
333
334        Self::run_after_create_handlers(&mut security_key, &options, state, transaction).await?;
335
336        Ok(security_key)
337    }
338}
339
340#[derive(ToSchema, Serialize, Deserialize, Validate, Default)]
341pub struct UpdateUserSecurityKeyOptions {
342    #[garde(length(chars, min = 3, max = 31))]
343    #[schema(min_length = 3, max_length = 31)]
344    pub name: Option<compact_str::CompactString>,
345}
346
347#[async_trait::async_trait]
348impl UpdatableModel for UserSecurityKey {
349    type UpdateOptions = UpdateUserSecurityKeyOptions;
350
351    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
352        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<UserSecurityKey>> =
353            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
354
355        &UPDATE_LISTENERS
356    }
357
358    async fn update_with_transaction(
359        &mut self,
360        state: &crate::State,
361        mut options: Self::UpdateOptions,
362        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
363    ) -> Result<(), crate::database::DatabaseError> {
364        options.validate()?;
365
366        let mut query_builder = UpdateQueryBuilder::new("user_security_keys");
367
368        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
369            .await?;
370
371        query_builder
372            .set("name", options.name.as_ref())
373            .where_eq("uuid", self.uuid);
374
375        query_builder.execute(&mut **transaction).await?;
376
377        if let Some(name) = options.name {
378            self.name = name;
379        }
380
381        self.run_after_update_handlers(state, transaction).await?;
382
383        Ok(())
384    }
385}
386
387#[async_trait::async_trait]
388impl DeletableModel for UserSecurityKey {
389    type DeleteOptions = ();
390
391    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
392        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<UserSecurityKey>> =
393            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
394
395        &DELETE_LISTENERS
396    }
397
398    async fn delete_with_transaction(
399        &self,
400        state: &crate::State,
401        options: Self::DeleteOptions,
402        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
403    ) -> Result<(), anyhow::Error> {
404        self.run_delete_handlers(&options, state, transaction)
405            .await?;
406
407        sqlx::query(
408            r#"
409            DELETE FROM user_security_keys
410            WHERE user_security_keys.uuid = $1
411            "#,
412        )
413        .bind(self.uuid)
414        .execute(&mut **transaction)
415        .await?;
416
417        self.run_after_delete_handlers(&options, state, transaction)
418            .await?;
419
420        Ok(())
421    }
422}
423
424#[schema_extension_derive::extendible]
425#[init_args(UserSecurityKey, crate::State)]
426#[hook_args(crate::State)]
427#[derive(ToSchema, Serialize)]
428#[schema(title = "UserSecurityKey")]
429pub struct ApiUserSecurityKey {
430    pub uuid: uuid::Uuid,
431
432    pub name: compact_str::CompactString,
433
434    pub credential_id: String,
435
436    pub last_used: Option<chrono::DateTime<chrono::Utc>>,
437    pub created: chrono::DateTime<chrono::Utc>,
438}