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