1mod store;
11mod events;
12#[cfg(feature = "sql")]
13mod sql;
14#[cfg(feature = "redis")]
15mod redis_store;
16
17use cookie::Cookie;
18pub use cookie::SameSite;
19use sova_cookies::{CookieLayer, CookieLayerPresent, Cookies, ResponseCookieExt};
20use sova_core::extend::{named, BoxFuture, Needs};
21use sova_core::{with_state, App, Error, EventBus, Plugin, Request, Result};
22use sova_store::{namespace, KvStore, MemoryStore as KvMemory};
23use std::collections::HashMap;
24use std::fmt;
25use std::future::Future;
26use std::sync::{Arc, Mutex};
27use std::time::Duration;
28
29pub use events::{SessionLogoutAll, SessionRegenerated};
30pub use store::{KvSessionStore, SessionStore, SessionStoreHandle, SESSION_USER_KEY};
31#[cfg(feature = "sql")]
32pub use sql::SqlSessionStore;
33#[cfg(feature = "redis")]
34pub use redis_store::RedisSessionStore;
35
36pub const FLASH_STATUS: &str = "flash_status";
38pub const FLASH_ERRORS: &str = "flash_errors";
40pub const FLASH_OLD: &str = "flash_old";
42
43#[derive(Debug)]
44struct SessionInner {
45 id: String,
46 data: HashMap<String, String>,
47 dirty: bool,
48 destroyed: bool,
50 old_id: Option<String>,
52 is_new: bool,
54}
55
56#[derive(Clone)]
58pub struct Session {
59 inner: Arc<Mutex<SessionInner>>,
60 events: Option<EventBus>,
61}
62
63impl fmt::Debug for Session {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 f.debug_struct("Session")
66 .field("inner", &self.inner)
67 .finish_non_exhaustive()
68 }
69}
70
71impl Session {
72 pub fn id(&self) -> String {
73 self.inner.lock().unwrap().id.clone()
74 }
75
76 pub fn get(&self, key: &str) -> Option<String> {
77 self.inner.lock().unwrap().data.get(key).cloned()
78 }
79
80 pub fn get_or(&self, key: &str, default: impl Into<String>) -> String {
81 self.get(key).unwrap_or_else(|| default.into())
82 }
83
84 pub fn set(&self, key: impl Into<String>, value: impl Into<String>) {
85 let mut g = self.inner.lock().unwrap();
86 g.data.insert(key.into(), value.into());
87 g.dirty = true;
88 }
89
90 pub fn flash(&self, key: impl Into<String>, value: impl Into<String>) {
92 self.set(key, value);
93 }
94
95 pub fn flash_status(&self, msg: impl Into<String>) {
97 self.flash(FLASH_STATUS, msg);
98 }
99
100 pub fn flash_json(&self, key: &str, value: &serde_json::Value) {
102 self.flash(
103 key,
104 serde_json::to_string(value).unwrap_or_else(|_| "{}".into()),
105 );
106 }
107
108 pub fn flash_errors(&self, errors: &serde_json::Value) {
110 self.flash_json(FLASH_ERRORS, errors);
111 }
112
113 pub fn flash_old(&self, old: &serde_json::Value) {
115 self.flash_json(FLASH_OLD, old);
116 }
117
118 pub fn take(&self, key: &str) -> String {
120 let mut g = self.inner.lock().unwrap();
121 let val = g.data.remove(key).unwrap_or_default();
122 if !val.is_empty() {
123 g.dirty = true;
124 }
125 val
126 }
127
128 pub fn take_json(&self, key: &str) -> serde_json::Value {
130 let raw = self.take(key);
131 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
132 }
133
134 pub fn remove(&self, key: &str) {
135 let mut g = self.inner.lock().unwrap();
136 if g.data.remove(key).is_some() {
137 g.dirty = true;
138 }
139 }
140
141 pub fn clear(&self) {
142 let mut g = self.inner.lock().unwrap();
143 if !g.data.is_empty() {
144 g.data.clear();
145 g.dirty = true;
146 }
147 }
148
149 pub fn replace(&self, data: HashMap<String, String>) {
151 let mut g = self.inner.lock().unwrap();
152 g.data = data;
153 g.dirty = true;
154 }
155
156 pub fn data(&self) -> HashMap<String, String> {
158 self.inner.lock().unwrap().data.clone()
159 }
160
161 pub fn bind_user(&self, user_id: impl Into<String>) {
163 self.set(SESSION_USER_KEY, user_id);
164 }
165
166 pub fn user_id(&self) -> Option<String> {
168 self.get(SESSION_USER_KEY).filter(|s| !s.is_empty())
169 }
170
171 pub fn destroy(&self) {
173 let mut g = self.inner.lock().unwrap();
174 g.data.clear();
175 g.dirty = false;
176 g.destroyed = true;
177 }
178
179 pub fn regenerate(&self) {
181 let had_user = self.user_id().is_some();
182 {
183 let mut g = self.inner.lock().unwrap();
184 let old = std::mem::replace(&mut g.id, new_sid());
185 g.old_id = Some(old);
186 g.dirty = true;
187 g.is_new = true;
188 g.destroyed = false;
189 }
190 if let Some(bus) = &self.events {
191 bus.dispatch(SessionRegenerated { had_user });
192 }
193 }
194
195 fn snapshot(&self) -> SessionSnapshot {
196 let g = self.inner.lock().unwrap();
197 SessionSnapshot {
198 id: g.id.clone(),
199 data: g.data.clone(),
200 dirty: g.dirty,
201 destroyed: g.destroyed,
202 old_id: g.old_id.clone(),
203 is_new: g.is_new,
204 }
205 }
206
207 fn empty() -> Self {
209 Self {
210 inner: Arc::new(Mutex::new(SessionInner {
211 id: String::new(),
212 data: HashMap::new(),
213 dirty: false,
214 destroyed: false,
215 old_id: None,
216 is_new: true,
217 })),
218 events: None,
219 }
220 }
221}
222
223struct SessionSnapshot {
224 id: String,
225 data: HashMap<String, String>,
226 dirty: bool,
227 destroyed: bool,
228 old_id: Option<String>,
229 is_new: bool,
230}
231
232type HookFn = Arc<dyn Fn(Session, Request) -> BoxFuture<Result<Request>> + Send + Sync>;
233
234pub struct SessionLayer {
238 store: Arc<dyn SessionStore>,
239 cookie_name: String,
240 cookie_name_explicit: bool,
241 secure: bool,
242 secure_explicit: bool,
243 http_only: bool,
244 same_site: SameSite,
245 same_site_explicit: bool,
246 path: String,
247 ttl: Duration,
248 ttl_explicit: bool,
249 rolling: bool,
250 save_uninitialized: bool,
251 hook: Option<HookFn>,
252}
253
254impl SessionLayer {
255 pub fn new(kv: Arc<dyn KvStore>) -> Self {
257 Self::from_store(Arc::new(KvSessionStore::new(kv)))
258 }
259
260 pub fn from_store(store: Arc<dyn SessionStore>) -> Self {
262 Self {
263 store,
264 cookie_name: "sova_sid".into(),
265 cookie_name_explicit: false,
266 secure: false,
267 secure_explicit: false,
268 http_only: true,
269 same_site: SameSite::Lax,
270 same_site_explicit: false,
271 path: "/".into(),
272 ttl: Duration::from_secs(86400),
273 ttl_explicit: false,
274 rolling: false,
275 save_uninitialized: false,
276 hook: None,
277 }
278 }
279
280 #[cfg(feature = "sql")]
282 pub fn sql(app: &App) -> Self {
283 let pool = app.try_state::<sova_db::DbPool>().unwrap_or_else(|| {
284 panic!("SessionLayer::sql requires Db plugin installed before session")
285 });
286 Self::from_store(Arc::new(SqlSessionStore::from_db_pool(pool.as_ref())))
287 }
288
289 #[cfg(feature = "redis")]
291 pub fn redis(app: &App) -> Self {
292 let pool = app.try_state::<sova_redis::RedisPool>().unwrap_or_else(|| {
293 panic!("SessionLayer::redis requires Redis plugin installed before session")
294 });
295 Self::from_store(Arc::new(RedisSessionStore::from_redis_pool(pool.as_ref())))
296 }
297
298 pub fn from_app_store(app: &App) -> Self {
300 let store = app.try_state::<sova_store::AppStore>().unwrap_or_else(|| {
301 panic!("SessionLayer::from_app_store requires SharedStore / AppStore installed")
302 });
303 Self::new(store.namespaced("sess"))
304 }
305
306 pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
307 self.cookie_name = name.into();
308 self.cookie_name_explicit = true;
309 self
310 }
311
312 pub fn secure(mut self, secure: bool) -> Self {
313 self.secure = secure;
314 self.secure_explicit = true;
315 self
316 }
317
318 pub fn http_only(mut self, on: bool) -> Self {
319 self.http_only = on;
320 self
321 }
322
323 pub fn same_site(mut self, same_site: SameSite) -> Self {
324 self.same_site = same_site;
325 self.same_site_explicit = true;
326 self
327 }
328
329 pub fn path(mut self, path: impl Into<String>) -> Self {
330 self.path = path.into();
331 self
332 }
333
334 pub fn ttl(mut self, ttl: Duration) -> Self {
335 self.ttl = ttl;
336 self.ttl_explicit = true;
337 self
338 }
339
340 pub fn rolling(mut self, on: bool) -> Self {
342 self.rolling = on;
343 self
344 }
345
346 pub fn save_uninitialized(mut self, on: bool) -> Self {
348 self.save_uninitialized = on;
349 self
350 }
351
352 pub fn hook<F, Fut>(mut self, f: F) -> Self
368 where
369 F: Fn(Session, Request) -> Fut + Send + Sync + 'static,
370 Fut: Future<Output = Result<Request>> + Send + 'static,
371 {
372 self.hook = Some(Arc::new(move |sess, req| Box::pin(f(sess, req))));
373 self
374 }
375
376 fn apply_config(&mut self, app: &App) {
377 let Some(doc) = app.config_doc() else {
378 return;
379 };
380 let Some(section) = doc.section("session") else {
381 return;
382 };
383 if !self.cookie_name_explicit {
384 if let Some(n) = section.get("cookie").and_then(|v| v.as_str()) {
385 self.cookie_name = n.to_string();
386 }
387 }
388 if !self.ttl_explicit {
389 if let Some(s) = section.get("ttl").and_then(|v| v.as_str()) {
390 if let Ok(d) = sova_core::extend::parse_duration(s) {
391 self.ttl = d;
392 }
393 }
394 }
395 if !self.same_site_explicit {
396 if let Some(s) = section.get("same_site").and_then(|v| v.as_str()) {
397 self.same_site = match s.to_ascii_lowercase().as_str() {
398 "strict" => SameSite::Strict,
399 "none" => SameSite::None,
400 _ => SameSite::Lax,
401 };
402 }
403 }
404 if let Some(v) = section.get("secure").and_then(|v| v.as_bool()) {
405 self.secure = v;
406 self.secure_explicit = true;
407 }
408 }
409
410 fn apply_env_defaults(&mut self) {
411 if self.secure_explicit {
412 return;
413 }
414 let production = std::env::var("SOVA_ENV")
416 .map(|v| v.eq_ignore_ascii_case("production"))
417 .unwrap_or(false);
418 let session_secure = std::env::var("SESSION_SECURE")
419 .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes"))
420 .unwrap_or(false);
421 if production || session_secure {
422 self.secure = true;
423 }
424 }
425}
426
427impl Plugin for SessionLayer {
428 fn id(&self) -> &'static str {
429 "session"
430 }
431
432 fn meta(&self) -> sova_core::PluginMeta {
433 sova_core::PluginMeta::new("Session")
434 .description("Cookie sessions backed by a SessionStore")
435 .version(env!("CARGO_PKG_VERSION"))
436 }
437
438 fn install(mut self, app: &mut App) {
439 self.apply_config(app);
440 self.apply_env_defaults();
441 if !app.has_plugin("cookies") {
442 app.install(CookieLayer);
443 }
444
445 app.state(SessionStoreHandle(Arc::clone(&self.store)));
446
447 let store_check = Arc::clone(&self.store);
448 app.register_check("session", move |_state| {
449 let store = Arc::clone(&store_check);
450 async move {
451 let probe = "__sova_session_check__";
452 let mut data = HashMap::new();
453 data.insert("_probe".into(), "1".into());
454 store
455 .save(probe, &data, Duration::from_secs(5))
456 .await;
457 let got = store.load(probe).await;
458 store.destroy(probe).await;
459 if got.is_none() {
460 return Err(Error::Internal("session store probe failed".into()));
461 }
462 Ok(())
463 }
464 });
465
466 app.use_middleware(named(
467 "session",
468 with_state(self, move |layer, mut req, next| {
469 async move {
470 let had_cookie = req
471 .get::<Cookies>()
472 .and_then(|c| c.get(&layer.cookie_name).map(|s| s.to_string()));
473 let is_new = had_cookie.is_none();
474 let sid = had_cookie.unwrap_or_else(new_sid);
475
476 let data = if is_new {
477 HashMap::new()
478 } else {
479 layer.store.load(&sid).await.unwrap_or_default()
480 };
481 let session = Session {
482 inner: Arc::new(Mutex::new(SessionInner {
483 id: sid,
484 data,
485 dirty: false,
486 destroyed: false,
487 old_id: None,
488 is_new,
489 })),
490 events: req.try_state::<EventBus>().map(|b| (*b).clone()),
491 };
492 req.set(session.clone());
493
494 let req = if let Some(hook) = &layer.hook {
495 match hook(session.clone(), req).await {
496 Ok(r) => r,
497 Err(err) => return err.into_response(),
498 }
499 } else {
500 req
501 };
502
503 let mut res = next(req).await;
504 let snap = session.snapshot();
505
506 if snap.destroyed {
507 layer.store.destroy(&snap.id).await;
508 if let Some(old) = &snap.old_id {
509 layer.store.destroy(old).await;
510 }
511 let mut builder = Cookie::build((layer.cookie_name.clone(), ""))
512 .http_only(layer.http_only)
513 .same_site(layer.same_site)
514 .path(layer.path.clone())
515 .max_age(cookie::time::Duration::seconds(0));
516 if layer.secure {
517 builder = builder.secure(true);
518 }
519 return res.cookie(builder.build());
520 }
521
522 let should_persist = snap.dirty
523 || (snap.is_new && layer.save_uninitialized)
524 || (layer.rolling && !snap.is_new);
525
526 if should_persist {
527 if let Some(old) = &snap.old_id {
528 layer.store.destroy(old).await;
529 }
530 layer
531 .store
532 .save(&snap.id, &snap.data, layer.ttl)
533 .await;
534 }
535
536 let should_set_cookie = should_persist || (layer.rolling && !snap.is_new);
537 if should_set_cookie || (snap.is_new && snap.dirty) {
538 let mut builder = Cookie::build((layer.cookie_name.clone(), snap.id))
539 .http_only(layer.http_only)
540 .same_site(layer.same_site)
541 .path(layer.path.clone());
542 if layer.secure {
543 builder = builder.secure(true);
544 }
545 if let Ok(secs) = i64::try_from(layer.ttl.as_secs()) {
546 builder = builder.max_age(cookie::time::Duration::seconds(secs));
547 }
548 res = res.cookie(builder.build());
549 }
550
551 res
552 }
553 }),
554 ));
555 app.with(Needs::<CookieLayerPresent>::new());
556 }
557}
558
559fn new_sid() -> String {
560 let mut buf = [0u8; 16];
561 if getrandom::getrandom(&mut buf).is_err() {
562 let t = std::time::SystemTime::now()
563 .duration_since(std::time::UNIX_EPOCH)
564 .map(|d| d.as_nanos())
565 .unwrap_or(0);
566 return format!("{t:032x}");
567 }
568 let mut s = String::with_capacity(32);
569 for b in buf {
570 s.push_str(&format!("{b:02x}"));
571 }
572 s
573}
574
575pub fn memory_sessions() -> SessionLayer {
577 SessionLayer::new(Arc::new(namespace(Arc::new(KvMemory::new()), "sess")))
578}
579
580pub trait SessionExt {
582 fn session(&self) -> Session;
583
584 fn flash(&self, key: impl Into<String>, value: impl Into<String>) {
585 self.session().flash(key, value);
586 }
587
588 fn flash_status(&self, msg: impl Into<String>) {
589 self.session().flash_status(msg);
590 }
591
592 fn flash_errors(&self, errors: &serde_json::Value) {
593 self.session().flash_errors(errors);
594 }
595
596 fn flash_old(&self, old: &serde_json::Value) {
597 self.session().flash_old(old);
598 }
599
600 fn logout_other_sessions(
604 &self,
605 ) -> impl std::future::Future<Output = Result<u64>> + Send;
606
607 fn logout_all_sessions(
609 &mut self,
610 ) -> impl std::future::Future<Output = Result<u64>> + Send;
611}
612
613impl SessionExt for sova_core::Request {
614 fn session(&self) -> Session {
615 self.get::<Session>().cloned().unwrap_or_else(Session::empty)
616 }
617
618 async fn logout_other_sessions(&self) -> Result<u64> {
619 let sess = self.session();
620 let uid = sess
621 .user_id()
622 .ok_or_else(|| Error::BadRequest("no bound session user".into()))?;
623 let handle = self.try_state::<SessionStoreHandle>().ok_or_else(|| {
624 Error::Internal("SessionStore missing (is SessionLayer installed?)".into())
625 })?;
626 Ok(handle
627 .0
628 .destroy_user(&uid, Some(&sess.id()))
629 .await)
630 }
631
632 async fn logout_all_sessions(&mut self) -> Result<u64> {
633 let sess = self.session();
634 let uid = sess
635 .user_id()
636 .ok_or_else(|| Error::BadRequest("no bound session user".into()))?;
637 let handle = self.try_state::<SessionStoreHandle>().ok_or_else(|| {
638 Error::Internal("SessionStore missing (is SessionLayer installed?)".into())
639 })?;
640 let n = handle.0.destroy_user(&uid, None).await;
641 sess.destroy();
642 let _ = self.take::<sova_core::RateLimitIdentity>();
643 if let Some(bus) = self.try_state::<EventBus>() {
644 bus.dispatch(SessionLogoutAll {
645 user_id: uid,
646 count: n,
647 });
648 }
649 Ok(n)
650 }
651}
652
653#[cfg(test)]
654mod flash_tests {
655 use super::*;
656 use serde_json::json;
657
658 #[test]
659 fn flash_take_is_one_shot() {
660 let s = Session::empty();
661 s.flash_status("Saved");
662 assert_eq!(s.take(FLASH_STATUS), "Saved");
663 assert_eq!(s.take(FLASH_STATUS), "");
664
665 s.flash_old(&json!({ "email": "a@b.c" }));
666 let old = s.take_json(FLASH_OLD);
667 assert_eq!(old["email"], "a@b.c");
668 assert_eq!(s.take_json(FLASH_OLD), json!({}));
669 }
670}