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