Skip to main content

shared/models/
user_session.rs

1use crate::{
2    models::{
3        InsertQueryBuilder,
4        user::{AuthMethod, GetAuthMethod, User},
5    },
6    prelude::*,
7};
8use compact_str::ToCompactString;
9use garde::Validate;
10use rand::{RngExt, distr::SampleString};
11use serde::{Deserialize, Serialize};
12use sha2::Digest;
13use sqlx::{Row, postgres::PgRow};
14use std::{
15    borrow::Cow,
16    collections::BTreeMap,
17    sync::{Arc, LazyLock},
18};
19use tower_cookies::Cookie;
20use utoipa::ToSchema;
21
22#[derive(Serialize, Deserialize, Clone)]
23pub struct UserSession {
24    pub uuid: uuid::Uuid,
25    pub user_uuid: uuid::Uuid,
26
27    pub ip: sqlx::types::ipnetwork::IpNetwork,
28    pub user_agent: compact_str::CompactString,
29
30    pub last_used: chrono::NaiveDateTime,
31    pub created: chrono::NaiveDateTime,
32
33    extension_data: super::ModelExtensionData,
34}
35
36impl BaseModel for UserSession {
37    const NAME: &'static str = "user_session";
38
39    fn get_extension_list() -> &'static super::ModelExtensionList {
40        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
41            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
42
43        &EXTENSIONS
44    }
45
46    fn get_extension_data(&self) -> &super::ModelExtensionData {
47        &self.extension_data
48    }
49
50    #[inline]
51    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
52        let prefix = prefix.unwrap_or_default();
53
54        BTreeMap::from([
55            (
56                "user_sessions.uuid",
57                compact_str::format_compact!("{prefix}uuid"),
58            ),
59            (
60                "user_sessions.user_uuid",
61                compact_str::format_compact!("{prefix}user_uuid"),
62            ),
63            (
64                "user_sessions.ip",
65                compact_str::format_compact!("{prefix}ip"),
66            ),
67            (
68                "user_sessions.user_agent",
69                compact_str::format_compact!("{prefix}user_agent"),
70            ),
71            (
72                "user_sessions.last_used",
73                compact_str::format_compact!("{prefix}last_used"),
74            ),
75            (
76                "user_sessions.created",
77                compact_str::format_compact!("{prefix}created"),
78            ),
79        ])
80    }
81
82    #[inline]
83    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
84        let prefix = prefix.unwrap_or_default();
85
86        Ok(Self {
87            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
88            user_uuid: row.try_get(compact_str::format_compact!("{prefix}user_uuid").as_str())?,
89            ip: row.try_get(compact_str::format_compact!("{prefix}ip").as_str())?,
90            user_agent: row.try_get(compact_str::format_compact!("{prefix}user_agent").as_str())?,
91            last_used: row.try_get(compact_str::format_compact!("{prefix}last_used").as_str())?,
92            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
93            extension_data: Self::map_extensions(prefix, row)?,
94        })
95    }
96
97    fn cache_invalidation_keys(&self) -> Vec<compact_str::CompactString> {
98        vec![compact_str::format_compact!(
99            "{}::{}",
100            Self::NAME,
101            self.uuid
102        )]
103    }
104}
105
106#[async_trait::async_trait]
107impl ByUuid for UserSession {
108    async fn by_uuid(
109        database: &crate::database::Database,
110        uuid: uuid::Uuid,
111    ) -> Result<Self, crate::database::DatabaseError> {
112        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
113            r#"
114            SELECT {}
115            FROM user_sessions
116            WHERE user_sessions.uuid = $1
117            "#,
118            Self::columns_sql(None)
119        )))
120        .bind(uuid)
121        .fetch_one(database.read())
122        .await?;
123
124        Self::map(None, &row)
125    }
126
127    async fn by_uuid_with_transaction(
128        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
129        uuid: uuid::Uuid,
130    ) -> Result<Self, crate::database::DatabaseError> {
131        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
132            r#"
133            SELECT {}
134            FROM user_sessions
135            WHERE user_sessions.uuid = $1
136            "#,
137            Self::columns_sql(None)
138        )))
139        .bind(uuid)
140        .fetch_one(&mut **transaction)
141        .await?;
142
143        Self::map(None, &row)
144    }
145}
146
147#[async_trait::async_trait]
148impl ResolvableModel for UserSession {
149    type Fingerprint = ();
150
151    fn uuid(&self) -> uuid::Uuid {
152        self.uuid
153    }
154
155    fn fingerprint(&self) -> Self::Fingerprint {}
156
157    async fn resolve(
158        database: &crate::database::Database,
159        identifier: &str,
160    ) -> Result<Option<Self>, anyhow::Error> {
161        let Some((key_id, key)) = identifier.split_once(':') else {
162            return Ok(None);
163        };
164
165        let digest = crate::crypt::token_digest(key);
166
167        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
168            r#"
169            SELECT {}
170            FROM user_sessions
171            WHERE user_sessions.key_id = $1 AND user_sessions.key = $2
172            "#,
173            Self::columns_sql(None)
174        )))
175        .bind(key_id)
176        .bind(&digest)
177        .fetch_optional(database.read())
178        .await?;
179
180        if let Some(row) = row {
181            return Ok(Some(Self::map(None, &row)?));
182        }
183
184        let Some(row) = sqlx::query(sqlx::AssertSqlSafe(format!(
185            r#"
186            WITH user_sessions AS MATERIALIZED (
187                SELECT * FROM user_sessions WHERE key_id = $1
188            )
189            SELECT {}, user_sessions.key AS key_hash
190            FROM user_sessions
191            WHERE user_sessions.key = crypt($2, user_sessions.key)
192            "#,
193            Self::columns_sql(None)
194        )))
195        .bind(key_id)
196        .bind(key)
197        .fetch_optional(database.read())
198        .await?
199        else {
200            return Ok(None);
201        };
202
203        sqlx::query(
204            r#"
205            UPDATE user_sessions
206            SET key = $2
207            WHERE user_sessions.uuid = $1 AND user_sessions.key = $3
208            "#,
209        )
210        .bind(row.try_get::<uuid::Uuid, _>("uuid")?)
211        .bind(&digest)
212        .bind(row.try_get::<String, _>("key_hash")?)
213        .execute(database.write())
214        .await?;
215
216        Ok(Some(Self::map(None, &row)?))
217    }
218}
219
220impl UserSession {
221    /// The total len of the session cookie,
222    ///
223    /// key_id (16) + separator (1) + key (64)
224    pub const COOKIE_LEN: usize = 16 + 1 + 64;
225
226    pub async fn by_user_uuid_uuid(
227        database: &crate::database::Database,
228        user_uuid: uuid::Uuid,
229        uuid: uuid::Uuid,
230    ) -> Result<Option<Self>, crate::database::DatabaseError> {
231        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
232            r#"
233            SELECT {}
234            FROM user_sessions
235            WHERE user_sessions.user_uuid = $1 AND user_sessions.uuid = $2
236            "#,
237            Self::columns_sql(None)
238        )))
239        .bind(user_uuid)
240        .bind(uuid)
241        .fetch_optional(database.read())
242        .await?;
243
244        row.try_map(|row| Self::map(None, &row))
245    }
246
247    pub async fn by_user_uuid_with_pagination(
248        database: &crate::database::Database,
249        user_uuid: uuid::Uuid,
250        page: i64,
251        per_page: i64,
252        search: Option<&str>,
253    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
254        let offset = (page - 1) * per_page;
255
256        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
257            r#"
258            SELECT {}, COUNT(*) OVER() AS total_count
259            FROM user_sessions
260            WHERE user_sessions.user_uuid = $1 AND ($2 IS NULL OR user_sessions.user_agent ILIKE '%' || $2 || '%')
261            ORDER BY user_sessions.created DESC
262            LIMIT $3 OFFSET $4
263            "#,
264            Self::columns_sql(None)
265        )))
266        .bind(user_uuid)
267        .bind(search)
268        .bind(per_page)
269        .bind(offset)
270        .fetch_all(database.read())
271        .await?;
272
273        Ok(super::Pagination {
274            total: rows
275                .first()
276                .map_or(Ok(0), |row| row.try_get("total_count"))?,
277            per_page,
278            page,
279            data: rows
280                .into_iter()
281                .map(|row| Self::map(None, &row))
282                .try_collect_vec()?,
283        })
284    }
285
286    pub async fn delete_by_user_uuid_except(
287        database: &crate::database::Database,
288        user_uuid: uuid::Uuid,
289        except: Option<uuid::Uuid>,
290    ) -> Result<u64, sqlx::Error> {
291        let rows = sqlx::query(
292            r#"
293            DELETE FROM user_sessions
294            WHERE user_sessions.user_uuid = $1 AND ($2 IS NULL OR user_sessions.uuid != $2)
295            RETURNING user_sessions.uuid
296            "#,
297        )
298        .bind(user_uuid)
299        .bind(except)
300        .fetch_all(database.write())
301        .await?;
302
303        for row in &rows {
304            Self::invalidate_cached(database, row.try_get("uuid")?).await;
305        }
306
307        Ok(rows.len() as u64)
308    }
309
310    pub async fn delete_unused(
311        database: &crate::database::Database,
312        duration_seconds: i64,
313    ) -> Result<u64, sqlx::Error> {
314        Ok(sqlx::query(
315            r#"
316            DELETE FROM user_sessions
317            WHERE user_sessions.last_used < $1
318            "#,
319        )
320        .bind(chrono::Utc::now().naive_utc() - chrono::Duration::seconds(duration_seconds))
321        .execute(database.write())
322        .await?
323        .rows_affected())
324    }
325
326    pub async fn update_last_used(
327        &self,
328        database: &Arc<crate::database::Database>,
329        ip: impl Into<sqlx::types::ipnetwork::IpNetwork>,
330        user_agent: &str,
331    ) {
332        let uuid = self.uuid;
333        let now = chrono::Utc::now().naive_utc();
334        let user_agent = crate::utils::slice_up_to(user_agent, 255).to_string();
335        let ip = ip.into();
336
337        database
338            .batch_action("update_user_session", uuid, {
339                let database = database.clone();
340
341                async move {
342                    sqlx::query!(
343                        "UPDATE user_sessions
344                        SET ip = $2, user_agent = $3, last_used = $4
345                        WHERE user_sessions.uuid = $1",
346                        uuid,
347                        ip,
348                        user_agent,
349                        now
350                    )
351                    .execute(database.write())
352                    .await?;
353
354                    Ok(())
355                }
356            })
357            .await;
358    }
359
360    pub async fn get_cookie<'a>(
361        state: &crate::State,
362        key: impl Into<Cow<'a, str>>,
363    ) -> Result<Cookie<'a>, anyhow::Error> {
364        let settings = state.settings.get().await?;
365
366        Ok(Cookie::build((settings.app.session_cookie.clone(), key))
367            .http_only(true)
368            .same_site(tower_cookies::cookie::SameSite::Lax)
369            .secure(settings.app.url.starts_with("https://"))
370            .path("/")
371            .expires(
372                tower_cookies::cookie::time::OffsetDateTime::now_utc()
373                    + tower_cookies::cookie::time::Duration::seconds(
374                        settings.app.session_duration_seconds as i64,
375                    ),
376            )
377            .build())
378    }
379}
380
381#[async_trait::async_trait]
382impl IntoApiObject for UserSession {
383    type ApiObject = ApiUserSession;
384    type ExtraArgs<'a> = &'a GetAuthMethod;
385
386    async fn into_api_object<'a>(
387        self,
388        state: &crate::State,
389        auth: Self::ExtraArgs<'a>,
390    ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
391        let api_object = ApiUserSession::init_hooks(&self, state).await?;
392
393        let api_object = finish_extendible!(
394            ApiUserSession {
395                uuid: self.uuid,
396                ip: self.ip.ip().to_compact_string(),
397                user_agent: self.user_agent,
398                is_using: match &***auth {
399                    AuthMethod::Session(session) => session.uuid == self.uuid,
400                    _ => false,
401                },
402                last_used: self.last_used.and_utc(),
403                created: self.created.and_utc(),
404            },
405            api_object,
406            state
407        )?;
408
409        Ok(api_object)
410    }
411}
412
413#[derive(ToSchema, Deserialize, Validate)]
414pub struct CreateUserSessionOptions {
415    #[garde(skip)]
416    pub user_uuid: uuid::Uuid,
417    #[garde(skip)]
418    #[schema(value_type = String)]
419    pub ip: sqlx::types::ipnetwork::IpNetwork,
420    #[garde(length(chars, min = 1, max = 1024))]
421    #[schema(min_length = 1, max_length = 1024)]
422    pub user_agent: compact_str::CompactString,
423}
424
425#[async_trait::async_trait]
426impl CreatableModel for UserSession {
427    type CreateOptions<'a> = CreateUserSessionOptions;
428    type CreateResult = String;
429
430    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
431        static CREATE_LISTENERS: LazyLock<CreateListenerList<UserSession>> =
432            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
433
434        &CREATE_LISTENERS
435    }
436
437    async fn create_with_transaction(
438        state: &crate::State,
439        mut options: Self::CreateOptions<'_>,
440        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
441    ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
442        options.validate()?;
443
444        let mut query_builder = InsertQueryBuilder::new("user_sessions");
445
446        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
447
448        let key_id = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 16);
449
450        let mut hash = sha2::Sha256::new();
451        hash.update(chrono::Utc::now().timestamp().to_le_bytes());
452        hash.update(options.user_uuid.to_bytes_le());
453        hash.update(rand::rng().random::<[u8; 32]>());
454        let hash = hex::encode(hash.finalize());
455
456        query_builder
457            .set("user_uuid", options.user_uuid)
458            .set("key_id", key_id.clone())
459            .set("key", crate::crypt::token_digest(&hash))
460            .set("ip", options.ip)
461            .set("user_agent", &options.user_agent);
462
463        query_builder.execute(&mut **transaction).await?;
464
465        let mut result = format!("{key_id}:{hash}");
466
467        Self::run_after_create_handlers(&mut result, &options, state, transaction).await?;
468
469        match User::by_uuid_with_transaction(transaction, options.user_uuid).await {
470            Ok(user) => {
471                let settings = state.settings.get().await?;
472
473                state
474                    .mail
475                    .send_template(
476                        state,
477                        "session_created",
478                        &user,
479                        minijinja::context! {
480                            user => user,
481                            ip => options.ip.ip().to_compact_string(),
482                            user_agent => options.user_agent,
483                            sessions_link => format!("{}/account/sessions", settings.app.url),
484                        },
485                    )
486                    .await;
487            }
488            Err(err) => {
489                tracing::warn!(
490                    user = %options.user_uuid,
491                    "failed to fetch user for session created email: {:#?}",
492                    err
493                );
494            }
495        }
496
497        Ok(result)
498    }
499}
500
501#[async_trait::async_trait]
502impl DeletableModel for UserSession {
503    type DeleteOptions = ();
504
505    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
506        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<UserSession>> =
507            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
508
509        &DELETE_LISTENERS
510    }
511
512    async fn delete_with_transaction(
513        &self,
514        state: &crate::State,
515        options: Self::DeleteOptions,
516        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
517    ) -> Result<(), anyhow::Error> {
518        self.run_delete_handlers(&options, state, transaction)
519            .await?;
520
521        sqlx::query(
522            r#"
523            DELETE FROM user_sessions
524            WHERE user_sessions.uuid = $1
525            "#,
526        )
527        .bind(self.uuid)
528        .execute(&mut **transaction)
529        .await?;
530
531        self.run_after_delete_handlers(&options, state, transaction)
532            .await?;
533
534        Ok(())
535    }
536}
537
538#[schema_extension_derive::extendible]
539#[init_args(UserSession, crate::State)]
540#[hook_args(crate::State)]
541#[derive(ToSchema, Serialize, Deserialize)]
542#[schema(title = "UserSession")]
543pub struct ApiUserSession {
544    pub uuid: uuid::Uuid,
545
546    pub ip: compact_str::CompactString,
547    pub user_agent: compact_str::CompactString,
548
549    pub is_using: bool,
550
551    pub last_used: chrono::DateTime<chrono::Utc>,
552    pub created: chrono::DateTime<chrono::Utc>,
553}