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 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}