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