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 http_only: bool,
224 same_site: SameSite,
225 same_site_explicit: bool,
226 path: String,
227 ttl: Duration,
228 ttl_explicit: bool,
229 rolling: bool,
230 save_uninitialized: bool,
231 hook: Option<HookFn>,
232}
233
234impl SessionLayer {
235 pub fn new(kv: Arc<dyn KvStore>) -> Self {
237 Self::from_store(Arc::new(KvSessionStore::new(kv)))
238 }
239
240 pub fn from_store(store: Arc<dyn SessionStore>) -> Self {
242 Self {
243 store,
244 cookie_name: "sova_sid".into(),
245 cookie_name_explicit: false,
246 secure: false,
247 http_only: true,
248 same_site: SameSite::Lax,
249 same_site_explicit: false,
250 path: "/".into(),
251 ttl: Duration::from_secs(86400),
252 ttl_explicit: false,
253 rolling: false,
254 save_uninitialized: false,
255 hook: None,
256 }
257 }
258
259 pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
260 self.cookie_name = name.into();
261 self.cookie_name_explicit = true;
262 self
263 }
264
265 pub fn secure(mut self, secure: bool) -> Self {
266 self.secure = secure;
267 self
268 }
269
270 pub fn http_only(mut self, on: bool) -> Self {
271 self.http_only = on;
272 self
273 }
274
275 pub fn same_site(mut self, same_site: SameSite) -> Self {
276 self.same_site = same_site;
277 self.same_site_explicit = true;
278 self
279 }
280
281 pub fn path(mut self, path: impl Into<String>) -> Self {
282 self.path = path.into();
283 self
284 }
285
286 pub fn ttl(mut self, ttl: Duration) -> Self {
287 self.ttl = ttl;
288 self.ttl_explicit = true;
289 self
290 }
291
292 pub fn rolling(mut self, on: bool) -> Self {
294 self.rolling = on;
295 self
296 }
297
298 pub fn save_uninitialized(mut self, on: bool) -> Self {
300 self.save_uninitialized = on;
301 self
302 }
303
304 pub fn hook<F, Fut>(mut self, f: F) -> Self
320 where
321 F: Fn(Session, Request) -> Fut + Send + Sync + 'static,
322 Fut: Future<Output = Result<Request>> + Send + 'static,
323 {
324 self.hook = Some(Arc::new(move |sess, req| Box::pin(f(sess, req))));
325 self
326 }
327
328 fn apply_config(&mut self, app: &App) {
329 let Some(doc) = app.config_doc() else {
330 return;
331 };
332 let Some(section) = doc.section("session") else {
333 return;
334 };
335 if !self.cookie_name_explicit {
336 if let Some(n) = section.get("cookie").and_then(|v| v.as_str()) {
337 self.cookie_name = n.to_string();
338 }
339 }
340 if !self.ttl_explicit {
341 if let Some(s) = section.get("ttl").and_then(|v| v.as_str()) {
342 if let Ok(d) = sova_core::extend::parse_duration(s) {
343 self.ttl = d;
344 }
345 }
346 }
347 if !self.same_site_explicit {
348 if let Some(s) = section.get("same_site").and_then(|v| v.as_str()) {
349 self.same_site = match s.to_ascii_lowercase().as_str() {
350 "strict" => SameSite::Strict,
351 "none" => SameSite::None,
352 _ => SameSite::Lax,
353 };
354 }
355 }
356 if let Some(v) = section.get("secure").and_then(|v| v.as_bool()) {
357 self.secure = v;
358 }
359 }
360}
361
362impl Plugin for SessionLayer {
363 fn id(&self) -> &'static str {
364 "session"
365 }
366
367 fn meta(&self) -> sova_core::PluginMeta {
368 sova_core::PluginMeta::new("Session")
369 .description("Cookie sessions backed by a SessionStore")
370 .version(env!("CARGO_PKG_VERSION"))
371 }
372
373 fn install(mut self, app: &mut App) {
374 self.apply_config(app);
375 if !app.has_plugin("cookies") {
376 app.install(CookieLayer);
377 }
378
379 app.state(SessionStoreHandle(Arc::clone(&self.store)));
380
381 let store_check = Arc::clone(&self.store);
382 app.register_check("session", move |_state| {
383 let store = Arc::clone(&store_check);
384 async move {
385 let probe = "__sova_session_check__";
386 let mut data = HashMap::new();
387 data.insert("_probe".into(), "1".into());
388 store
389 .save(probe, &data, Duration::from_secs(5))
390 .await;
391 let got = store.load(probe).await;
392 store.destroy(probe).await;
393 if got.is_none() {
394 return Err(Error::Internal("session store probe failed".into()));
395 }
396 Ok(())
397 }
398 });
399
400 app.use_middleware(named(
401 "session",
402 with_state(self, move |layer, mut req, next| {
403 async move {
404 let had_cookie = req
405 .get::<Cookies>()
406 .and_then(|c| c.get(&layer.cookie_name).map(|s| s.to_string()));
407 let is_new = had_cookie.is_none();
408 let sid = had_cookie.unwrap_or_else(new_sid);
409
410 let data = if is_new {
411 HashMap::new()
412 } else {
413 layer.store.load(&sid).await.unwrap_or_default()
414 };
415 let session = Session {
416 inner: Arc::new(Mutex::new(SessionInner {
417 id: sid,
418 data,
419 dirty: false,
420 destroyed: false,
421 old_id: None,
422 is_new,
423 })),
424 };
425 req.set(session.clone());
426
427 let req = if let Some(hook) = &layer.hook {
428 match hook(session.clone(), req).await {
429 Ok(r) => r,
430 Err(err) => return err.into_response(),
431 }
432 } else {
433 req
434 };
435
436 let mut res = next(req).await;
437 let snap = session.snapshot();
438
439 if snap.destroyed {
440 layer.store.destroy(&snap.id).await;
441 if let Some(old) = &snap.old_id {
442 layer.store.destroy(old).await;
443 }
444 let mut builder = Cookie::build((layer.cookie_name.clone(), ""))
445 .http_only(layer.http_only)
446 .same_site(layer.same_site)
447 .path(layer.path.clone())
448 .max_age(cookie::time::Duration::seconds(0));
449 if layer.secure {
450 builder = builder.secure(true);
451 }
452 return res.cookie(builder.build());
453 }
454
455 let should_persist = snap.dirty
456 || (snap.is_new && layer.save_uninitialized)
457 || (layer.rolling && !snap.is_new);
458
459 if should_persist {
460 if let Some(old) = &snap.old_id {
461 layer.store.destroy(old).await;
462 }
463 layer
464 .store
465 .save(&snap.id, &snap.data, layer.ttl)
466 .await;
467 }
468
469 let should_set_cookie = should_persist || (layer.rolling && !snap.is_new);
470 if should_set_cookie || (snap.is_new && snap.dirty) {
471 let mut builder = Cookie::build((layer.cookie_name.clone(), snap.id))
472 .http_only(layer.http_only)
473 .same_site(layer.same_site)
474 .path(layer.path.clone());
475 if layer.secure {
476 builder = builder.secure(true);
477 }
478 if let Ok(secs) = i64::try_from(layer.ttl.as_secs()) {
479 builder = builder.max_age(cookie::time::Duration::seconds(secs));
480 }
481 res = res.cookie(builder.build());
482 }
483
484 res
485 }
486 }),
487 ));
488 app.with(Needs::<CookieLayerPresent>::new());
489 }
490}
491
492fn new_sid() -> String {
493 let mut buf = [0u8; 16];
494 if getrandom::getrandom(&mut buf).is_err() {
495 let t = std::time::SystemTime::now()
496 .duration_since(std::time::UNIX_EPOCH)
497 .map(|d| d.as_nanos())
498 .unwrap_or(0);
499 return format!("{t:032x}");
500 }
501 let mut s = String::with_capacity(32);
502 for b in buf {
503 s.push_str(&format!("{b:02x}"));
504 }
505 s
506}
507
508pub fn memory_sessions() -> SessionLayer {
510 SessionLayer::new(Arc::new(namespace(Arc::new(KvMemory::new()), "sess")))
511}
512
513pub trait SessionExt {
515 fn session(&self) -> Session;
516
517 fn flash(&self, key: impl Into<String>, value: impl Into<String>) {
518 self.session().flash(key, value);
519 }
520
521 fn flash_status(&self, msg: impl Into<String>) {
522 self.session().flash_status(msg);
523 }
524
525 fn flash_errors(&self, errors: &serde_json::Value) {
526 self.session().flash_errors(errors);
527 }
528
529 fn flash_old(&self, old: &serde_json::Value) {
530 self.session().flash_old(old);
531 }
532
533 fn logout_other_sessions(
537 &self,
538 ) -> impl std::future::Future<Output = Result<u64>> + Send;
539
540 fn logout_all_sessions(
542 &mut self,
543 ) -> impl std::future::Future<Output = Result<u64>> + Send;
544}
545
546impl SessionExt for sova_core::Request {
547 fn session(&self) -> Session {
548 self.get::<Session>().cloned().unwrap_or_else(Session::empty)
549 }
550
551 async fn logout_other_sessions(&self) -> Result<u64> {
552 let sess = self.session();
553 let uid = sess
554 .user_id()
555 .ok_or_else(|| Error::BadRequest("no bound session user".into()))?;
556 let handle = self.try_state::<SessionStoreHandle>().ok_or_else(|| {
557 Error::Internal("SessionStore missing (is SessionLayer installed?)".into())
558 })?;
559 Ok(handle
560 .0
561 .destroy_user(&uid, Some(&sess.id()))
562 .await)
563 }
564
565 async fn logout_all_sessions(&mut self) -> Result<u64> {
566 let sess = self.session();
567 let uid = sess
568 .user_id()
569 .ok_or_else(|| Error::BadRequest("no bound session user".into()))?;
570 let handle = self.try_state::<SessionStoreHandle>().ok_or_else(|| {
571 Error::Internal("SessionStore missing (is SessionLayer installed?)".into())
572 })?;
573 let n = handle.0.destroy_user(&uid, None).await;
574 sess.destroy();
575 let _ = self.take::<sova_core::RateLimitIdentity>();
576 Ok(n)
577 }
578}
579
580#[cfg(test)]
581mod flash_tests {
582 use super::*;
583 use serde_json::json;
584
585 #[test]
586 fn flash_take_is_one_shot() {
587 let s = Session::empty();
588 s.flash_status("Saved");
589 assert_eq!(s.take(FLASH_STATUS), "Saved");
590 assert_eq!(s.take(FLASH_STATUS), "");
591
592 s.flash_old(&json!({ "email": "a@b.c" }));
593 let old = s.take_json(FLASH_OLD);
594 assert_eq!(old["email"], "a@b.c");
595 assert_eq!(s.take_json(FLASH_OLD), json!({}));
596 }
597}