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        super::PaginatedQueryBuilder::new(
255            Self::columns_sql(None),
256            format!(
257                r#"
258                FROM user_sessions
259                WHERE user_sessions.user_uuid = $1 AND {search}
260                "#,
261                search =
262                    super::search_sql(2, &["user_sessions.user_agent"], &["user_sessions.uuid"]),
263            ),
264            "user_sessions.created DESC, user_sessions.uuid",
265        )
266        .bind(user_uuid)
267        .bind(search)
268        .fetch(database, page, per_page)
269        .await
270    }
271
272    pub async fn delete_by_user_uuid_except(
273        database: &crate::database::Database,
274        user_uuid: uuid::Uuid,
275        except: Option<uuid::Uuid>,
276    ) -> Result<u64, sqlx::Error> {
277        let rows = sqlx::query(
278            r#"
279            DELETE FROM user_sessions
280            WHERE user_sessions.user_uuid = $1 AND ($2 IS NULL OR user_sessions.uuid != $2)
281            RETURNING user_sessions.uuid
282            "#,
283        )
284        .bind(user_uuid)
285        .bind(except)
286        .fetch_all(database.write())
287        .await?;
288
289        for row in &rows {
290            Self::invalidate_cached(database, row.try_get("uuid")?).await;
291        }
292
293        Ok(rows.len() as u64)
294    }
295
296    pub async fn delete_unused(
297        database: &crate::database::Database,
298        duration_seconds: i64,
299    ) -> Result<u64, sqlx::Error> {
300        Ok(sqlx::query(
301            r#"
302            DELETE FROM user_sessions
303            WHERE user_sessions.last_used < $1
304            "#,
305        )
306        .bind(chrono::Utc::now().naive_utc() - chrono::Duration::seconds(duration_seconds))
307        .execute(database.write())
308        .await?
309        .rows_affected())
310    }
311
312    pub async fn update_last_used(
313        &self,
314        database: &Arc<crate::database::Database>,
315        ip: impl Into<sqlx::types::ipnetwork::IpNetwork>,
316        user_agent: &str,
317    ) {
318        let uuid = self.uuid;
319        let now = chrono::Utc::now().naive_utc();
320        let user_agent = crate::utils::slice_up_to(user_agent, 255).to_string();
321        let ip = ip.into();
322
323        database
324            .batch_action("update_user_session", uuid, {
325                let database = database.clone();
326
327                async move {
328                    sqlx::query!(
329                        "UPDATE user_sessions
330                        SET ip = $2, user_agent = $3, last_used = $4
331                        WHERE user_sessions.uuid = $1",
332                        uuid,
333                        ip,
334                        user_agent,
335                        now
336                    )
337                    .execute(database.write())
338                    .await?;
339
340                    Ok(())
341                }
342            })
343            .await;
344    }
345
346    pub async fn get_cookie<'a>(
347        state: &crate::State,
348        request_host: Option<&str>,
349        key: impl Into<Cow<'a, str>>,
350    ) -> Result<Cookie<'a>, anyhow::Error> {
351        let settings = state.settings.get().await?;
352
353        Ok(Cookie::build((settings.app.session_cookie.clone(), key))
354            .http_only(true)
355            .same_site(tower_cookies::cookie::SameSite::Lax)
356            .secure(
357                settings
358                    .app
359                    .url_for_host(request_host)
360                    .starts_with("https://"),
361            )
362            .path("/")
363            .expires(
364                tower_cookies::cookie::time::OffsetDateTime::now_utc()
365                    + tower_cookies::cookie::time::Duration::seconds(
366                        settings.app.session_duration_seconds as i64,
367                    ),
368            )
369            .build())
370    }
371}
372
373#[async_trait::async_trait]
374impl IntoApiObject for UserSession {
375    type ApiObject = ApiUserSession;
376    type ExtraArgs<'a> = &'a GetAuthMethod;
377
378    async fn into_api_object<'a>(
379        self,
380        state: &crate::State,
381        auth: Self::ExtraArgs<'a>,
382    ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
383        let api_object = ApiUserSession::init_hooks(&self, state).await?;
384
385        let api_object = finish_extendible!(
386            ApiUserSession {
387                uuid: self.uuid,
388                ip: self.ip.ip().to_compact_string(),
389                user_agent: self.user_agent,
390                is_using: match &***auth {
391                    AuthMethod::Session(session) => session.uuid == self.uuid,
392                    _ => false,
393                },
394                last_used: self.last_used.and_utc(),
395                created: self.created.and_utc(),
396            },
397            api_object,
398            state
399        )?;
400
401        Ok(api_object)
402    }
403}
404
405#[derive(ToSchema, Deserialize, Validate)]
406pub struct CreateUserSessionOptions {
407    #[garde(skip)]
408    pub user_uuid: uuid::Uuid,
409    #[garde(skip)]
410    #[schema(value_type = String)]
411    pub ip: sqlx::types::ipnetwork::IpNetwork,
412    #[garde(length(chars, min = 1, max = 1024))]
413    #[schema(min_length = 1, max_length = 1024)]
414    pub user_agent: compact_str::CompactString,
415}
416
417#[async_trait::async_trait]
418impl CreatableModel for UserSession {
419    type CreateOptions<'a> = CreateUserSessionOptions;
420    type CreateResult = String;
421
422    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
423        static CREATE_LISTENERS: LazyLock<CreateListenerList<UserSession>> =
424            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
425
426        &CREATE_LISTENERS
427    }
428
429    async fn create_with_transaction(
430        state: &crate::State,
431        mut options: Self::CreateOptions<'_>,
432        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
433    ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
434        options.validate()?;
435
436        let mut query_builder = InsertQueryBuilder::new("user_sessions");
437
438        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
439
440        let key_id = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 16);
441
442        let mut hash = sha2::Sha256::new();
443        hash.update(chrono::Utc::now().timestamp().to_le_bytes());
444        hash.update(options.user_uuid.to_bytes_le());
445        hash.update(rand::rng().random::<[u8; 32]>());
446        let hash = hex::encode(hash.finalize());
447
448        query_builder
449            .set("user_uuid", options.user_uuid)
450            .set("key_id", key_id.clone())
451            .set("key", crate::crypt::token_digest(&hash))
452            .set("ip", options.ip)
453            .set("user_agent", &options.user_agent);
454
455        query_builder.execute(&mut **transaction).await?;
456
457        let mut result = format!("{key_id}:{hash}");
458
459        Self::run_after_create_handlers(&mut result, &options, state, transaction).await?;
460
461        match User::by_uuid_with_transaction(transaction, options.user_uuid).await {
462            Ok(user) => {
463                let settings = state.settings.get().await?;
464
465                state
466                    .mail
467                    .send_template(
468                        state,
469                        "session_created",
470                        &user,
471                        minijinja::context! {
472                            user => minijinja::value::Serde(&user),
473                            ip => minijinja::value::Serde(options.ip.ip().to_compact_string()),
474                            user_agent => minijinja::value::Serde(&options.user_agent),
475                            sessions_link => format!("{}/account/sessions", settings.app.url),
476                        },
477                    )
478                    .await;
479            }
480            Err(err) => {
481                tracing::warn!(
482                    user = %options.user_uuid,
483                    "failed to fetch user for session created email: {:#?}",
484                    err
485                );
486            }
487        }
488
489        Ok(result)
490    }
491}
492
493#[async_trait::async_trait]
494impl DeletableModel for UserSession {
495    type DeleteOptions = ();
496
497    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
498        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<UserSession>> =
499            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
500
501        &DELETE_LISTENERS
502    }
503
504    async fn delete_with_transaction(
505        &self,
506        state: &crate::State,
507        options: Self::DeleteOptions,
508        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
509    ) -> Result<(), anyhow::Error> {
510        self.run_delete_handlers(&options, state, transaction)
511            .await?;
512
513        sqlx::query(
514            r#"
515            DELETE FROM user_sessions
516            WHERE user_sessions.uuid = $1
517            "#,
518        )
519        .bind(self.uuid)
520        .execute(&mut **transaction)
521        .await?;
522
523        self.run_after_delete_handlers(&options, state, transaction)
524            .await?;
525
526        Ok(())
527    }
528}
529
530#[schema_extension_derive::extendible]
531#[init_args(UserSession, crate::State)]
532#[hook_args(crate::State)]
533#[derive(ToSchema, Serialize, Deserialize)]
534#[schema(title = "UserSession")]
535pub struct ApiUserSession {
536    pub uuid: uuid::Uuid,
537
538    pub ip: compact_str::CompactString,
539    pub user_agent: compact_str::CompactString,
540
541    pub is_using: bool,
542
543    pub last_used: chrono::DateTime<chrono::Utc>,
544    pub created: chrono::DateTime<chrono::Utc>,
545}