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 pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
281 self.cookie_name = name.into();
282 self.cookie_name_explicit = true;
283 self
284 }
285
286 pub fn secure(mut self, secure: bool) -> Self {
287 self.secure = secure;
288 self.secure_explicit = true;
289 self
290 }
291
292 pub fn http_only(mut self, on: bool) -> Self {
293 self.http_only = on;
294 self
295 }
296
297 pub fn same_site(mut self, same_site: SameSite) -> Self {
298 self.same_site = same_site;
299 self.same_site_explicit = true;
300 self
301 }
302
303 pub fn path(mut self, path: impl Into<String>) -> Self {
304 self.path = path.into();
305 self
306 }
307
308 pub fn ttl(mut self, ttl: Duration) -> Self {
309 self.ttl = ttl;
310 self.ttl_explicit = true;
311 self
312 }
313
314 pub fn rolling(mut self, on: bool) -> Self {
316 self.rolling = on;
317 self
318 }
319
320 pub fn save_uninitialized(mut self, on: bool) -> Self {
322 self.save_uninitialized = on;
323 self
324 }
325
326 pub fn hook<F, Fut>(mut self, f: F) -> Self
342 where
343 F: Fn(Session, Request) -> Fut + Send + Sync + 'static,
344 Fut: Future<Output = Result<Request>> + Send + 'static,
345 {
346 self.hook = Some(Arc::new(move |sess, req| Box::pin(f(sess, req))));
347 self
348 }
349
350 fn apply_config(&mut self, app: &App) {
351 let Some(doc) = app.config_doc() else {
352 return;
353 };
354 let Some(section) = doc.section("session") else {
355 return;
356 };
357 if !self.cookie_name_explicit {
358 if let Some(n) = section.get("cookie").and_then(|v| v.as_str()) {
359 self.cookie_name = n.to_string();
360 }
361 }
362 if !self.ttl_explicit {
363 if let Some(s) = section.get("ttl").and_then(|v| v.as_str()) {
364 if let Ok(d) = sova_core::extend::parse_duration(s) {
365 self.ttl = d;
366 }
367 }
368 }
369 if !self.same_site_explicit {
370 if let Some(s) = section.get("same_site").and_then(|v| v.as_str()) {
371 self.same_site = match s.to_ascii_lowercase().as_str() {
372 "strict" => SameSite::Strict,
373 "none" => SameSite::None,
374 _ => SameSite::Lax,
375 };
376 }
377 }
378 if let Some(v) = section.get("secure").and_then(|v| v.as_bool()) {
379 self.secure = v;
380 self.secure_explicit = true;
381 }
382 }
383
384 fn apply_env_defaults(&mut self) {
385 if self.secure_explicit {
386 return;
387 }
388 let production = std::env::var("SOVA_ENV")
390 .map(|v| v.eq_ignore_ascii_case("production"))
391 .unwrap_or(false);
392 let session_secure = std::env::var("SESSION_SECURE")
393 .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes"))
394 .unwrap_or(false);
395 if production || session_secure {
396 self.secure = true;
397 }
398 }
399}
400
401impl Plugin for SessionLayer {
402 fn id(&self) -> &'static str {
403 "session"
404 }
405
406 fn meta(&self) -> sova_core::PluginMeta {
407 sova_core::PluginMeta::new("Session")
408 .description("Cookie sessions backed by a SessionStore")
409 .version(env!("CARGO_PKG_VERSION"))
410 }
411
412 fn install(mut self, app: &mut App) {
413 self.apply_config(app);
414 self.apply_env_defaults();
415 if !app.has_plugin("cookies") {
416 app.install(CookieLayer);
417 }
418
419 app.state(SessionStoreHandle(Arc::clone(&self.store)));
420
421 let store_check = Arc::clone(&self.store);
422 app.register_check("session", move |_state| {
423 let store = Arc::clone(&store_check);
424 async move {
425 let probe = "__sova_session_check__";
426 let mut data = HashMap::new();
427 data.insert("_probe".into(), "1".into());
428 store
429 .save(probe, &data, Duration::from_secs(5))
430 .await;
431 let got = store.load(probe).await;
432 store.destroy(probe).await;
433 if got.is_none() {
434 return Err(Error::Internal("session store probe failed".into()));
435 }
436 Ok(())
437 }
438 });
439
440 app.use_middleware(named(
441 "session",
442 with_state(self, move |layer, mut req, next| {
443 async move {
444 let had_cookie = req
445 .get::<Cookies>()
446 .and_then(|c| c.get(&layer.cookie_name).map(|s| s.to_string()));
447 let is_new = had_cookie.is_none();
448 let sid = had_cookie.unwrap_or_else(new_sid);
449
450 let data = if is_new {
451 HashMap::new()
452 } else {
453 layer.store.load(&sid).await.unwrap_or_default()
454 };
455 let session = Session {
456 inner: Arc::new(Mutex::new(SessionInner {
457 id: sid,
458 data,
459 dirty: false,
460 destroyed: false,
461 old_id: None,
462 is_new,
463 })),
464 events: req.try_state::<EventBus>().map(|b| (*b).clone()),
465 };
466 req.set(session.clone());
467
468 let req = if let Some(hook) = &layer.hook {
469 match hook(session.clone(), req).await {
470 Ok(r) => r,
471 Err(err) => return err.into_response(),
472 }
473 } else {
474 req
475 };
476
477 let mut res = next(req).await;
478 let snap = session.snapshot();
479
480 if snap.destroyed {
481 layer.store.destroy(&snap.id).await;
482 if let Some(old) = &snap.old_id {
483 layer.store.destroy(old).await;
484 }
485 let mut builder = Cookie::build((layer.cookie_name.clone(), ""))
486 .http_only(layer.http_only)
487 .same_site(layer.same_site)
488 .path(layer.path.clone())
489 .max_age(cookie::time::Duration::seconds(0));
490 if layer.secure {
491 builder = builder.secure(true);
492 }
493 return res.cookie(builder.build());
494 }
495
496 let should_persist = snap.dirty
497 || (snap.is_new && layer.save_uninitialized)
498 || (layer.rolling && !snap.is_new);
499
500 if should_persist {
501 if let Some(old) = &snap.old_id {
502 layer.store.destroy(old).await;
503 }
504 layer
505 .store
506 .save(&snap.id, &snap.data, layer.ttl)
507 .await;
508 }
509
510 let should_set_cookie = should_persist || (layer.rolling && !snap.is_new);
511 if should_set_cookie || (snap.is_new && snap.dirty) {
512 let mut builder = Cookie::build((layer.cookie_name.clone(), snap.id))
513 .http_only(layer.http_only)
514 .same_site(layer.same_site)
515 .path(layer.path.clone());
516 if layer.secure {
517 builder = builder.secure(true);
518 }
519 if let Ok(secs) = i64::try_from(layer.ttl.as_secs()) {
520 builder = builder.max_age(cookie::time::Duration::seconds(secs));
521 }
522 res = res.cookie(builder.build());
523 }
524
525 res
526 }
527 }),
528 ));
529 app.with(Needs::<CookieLayerPresent>::new());
530 }
531}
532
533fn new_sid() -> String {
534 let mut buf = [0u8; 16];
535 if getrandom::getrandom(&mut buf).is_err() {
536 let t = std::time::SystemTime::now()
537 .duration_since(std::time::UNIX_EPOCH)
538 .map(|d| d.as_nanos())
539 .unwrap_or(0);
540 return format!("{t:032x}");
541 }
542 let mut s = String::with_capacity(32);
543 for b in buf {
544 s.push_str(&format!("{b:02x}"));
545 }
546 s
547}
548
549pub fn memory_sessions() -> SessionLayer {
551 SessionLayer::new(Arc::new(namespace(Arc::new(KvMemory::new()), "sess")))
552}
553
554pub trait SessionExt {
556 fn session(&self) -> Session;
557
558 fn flash(&self, key: impl Into<String>, value: impl Into<String>) {
559 self.session().flash(key, value);
560 }
561
562 fn flash_status(&self, msg: impl Into<String>) {
563 self.session().flash_status(msg);
564 }
565
566 fn flash_errors(&self, errors: &serde_json::Value) {
567 self.session().flash_errors(errors);
568 }
569
570 fn flash_old(&self, old: &serde_json::Value) {
571 self.session().flash_old(old);
572 }
573
574 fn logout_other_sessions(
578 &self,
579 ) -> impl std::future::Future<Output = Result<u64>> + Send;
580
581 fn logout_all_sessions(
583 &mut self,
584 ) -> impl std::future::Future<Output = Result<u64>> + Send;
585}
586
587impl SessionExt for sova_core::Request {
588 fn session(&self) -> Session {
589 self.get::<Session>().cloned().unwrap_or_else(Session::empty)
590 }
591
592 async fn logout_other_sessions(&self) -> Result<u64> {
593 let sess = self.session();
594 let uid = sess
595 .user_id()
596 .ok_or_else(|| Error::BadRequest("no bound session user".into()))?;
597 let handle = self.try_state::<SessionStoreHandle>().ok_or_else(|| {
598 Error::Internal("SessionStore missing (is SessionLayer installed?)".into())
599 })?;
600 Ok(handle
601 .0
602 .destroy_user(&uid, Some(&sess.id()))
603 .await)
604 }
605
606 async fn logout_all_sessions(&mut self) -> Result<u64> {
607 let sess = self.session();
608 let uid = sess
609 .user_id()
610 .ok_or_else(|| Error::BadRequest("no bound session user".into()))?;
611 let handle = self.try_state::<SessionStoreHandle>().ok_or_else(|| {
612 Error::Internal("SessionStore missing (is SessionLayer installed?)".into())
613 })?;
614 let n = handle.0.destroy_user(&uid, None).await;
615 sess.destroy();
616 let _ = self.take::<sova_core::RateLimitIdentity>();
617 if let Some(bus) = self.try_state::<EventBus>() {
618 bus.dispatch(SessionLogoutAll {
619 user_id: uid,
620 count: n,
621 });
622 }
623 Ok(n)
624 }
625}
626
627#[cfg(test)]
628mod flash_tests {
629 use super::*;
630 use serde_json::json;
631
632 #[test]
633 fn flash_take_is_one_shot() {
634 let s = Session::empty();
635 s.flash_status("Saved");
636 assert_eq!(s.take(FLASH_STATUS), "Saved");
637 assert_eq!(s.take(FLASH_STATUS), "");
638
639 s.flash_old(&json!({ "email": "a@b.c" }));
640 let old = s.take_json(FLASH_OLD);
641 assert_eq!(old["email"], "a@b.c");
642 assert_eq!(s.take_json(FLASH_OLD), json!({}));
643 }
644}