1use crate::key::AppKey;
18use crate::session::{Session, is_valid_id};
19use crate::store::{DEFAULT_LIFETIME, SessionStore, SharedStore};
20use crate::{base64, constant_time_eq};
21use hmac::{Hmac, Mac};
22use rustlavel_core::{Config, Json, Result};
23use rustlavel_http::handler::BoxFuture;
24use rustlavel_http::middleware::{Middleware, Next};
25use rustlavel_http::{Cookie, Method, Request, Response, SameSite};
26use sha2::Sha256;
27use std::collections::BTreeMap;
28use std::sync::{Arc, Mutex, MutexGuard};
29use std::time::Duration;
30
31type HmacSha256 = Hmac<Sha256>;
32
33pub const DEFAULT_COOKIE: &str = "rustlavel_session";
35
36#[derive(Clone)]
43pub struct SessionHandle(Arc<Mutex<Session>>);
44
45impl SessionHandle {
46 pub fn new(session: Session) -> Self {
47 SessionHandle(Arc::new(Mutex::new(session)))
48 }
49
50 pub fn lock(&self) -> MutexGuard<'_, Session> {
56 self.0.lock().expect("session lock poisoned")
57 }
58
59 pub fn with<R>(&self, action: impl FnOnce(&mut Session) -> R) -> R {
61 action(&mut self.lock())
62 }
63
64 pub fn id(&self) -> String {
65 self.lock().id().to_string()
66 }
67
68 pub fn get(&self, key: &str) -> Option<Json> {
69 self.lock().get(key).cloned()
70 }
71
72 pub fn get_string(&self, key: &str) -> Option<String> {
73 self.lock().get_string(key)
74 }
75
76 pub fn has(&self, key: &str) -> bool {
77 self.lock().has(key)
78 }
79
80 pub fn put(&self, key: impl Into<String>, value: impl Into<Json>) {
81 self.lock().put(key, value);
82 }
83
84 pub fn forget(&self, key: &str) -> Option<Json> {
85 self.lock().forget(key)
86 }
87
88 pub fn flush(&self) {
89 self.lock().flush();
90 }
91
92 pub fn all(&self) -> BTreeMap<String, Json> {
93 self.lock().all().clone()
94 }
95
96 pub fn flash(&self, key: impl Into<String>, value: impl Into<Json>) {
97 self.lock().flash(key, value);
98 }
99
100 pub fn keep(&self, key: &str) {
101 self.lock().keep(key);
102 }
103
104 pub fn reflash(&self) {
105 self.lock().reflash();
106 }
107
108 pub fn regenerate(&self) -> String {
110 self.lock().regenerate().to_string()
111 }
112
113 pub fn invalidate(&self) -> String {
115 self.lock().invalidate().to_string()
116 }
117
118 pub fn token(&self) -> String {
120 self.lock().token()
121 }
122}
123
124impl std::fmt::Debug for SessionHandle {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 f.debug_struct("SessionHandle").field("id", &self.id()).finish()
127 }
128}
129
130pub trait SessionExt {
132 fn try_session(&self) -> Option<&SessionHandle>;
134
135 fn session(&self) -> &SessionHandle;
137}
138
139impl SessionExt for Request {
140 fn try_session(&self) -> Option<&SessionHandle> {
141 self.extension::<SessionHandle>()
142 }
143
144 fn session(&self) -> &SessionHandle {
145 self.try_session().expect(
146 "no session on this request. Add the `session` middleware to the router \
147 (`router.middleware(SessionManager::from_config(&config, store)?)`) before any route \
148 that uses `req.session()`.",
149 )
150 }
151}
152
153#[derive(Clone)]
157pub struct SessionManager {
158 inner: Arc<Inner>,
159}
160
161struct Inner {
162 store: SharedStore,
163 cookie_key: [u8; 32],
164 cookie: String,
165 lifetime: Duration,
166 path: String,
167 secure: bool,
168 same_site: SameSite,
169}
170
171impl rustlavel_http::Flash for SessionHandle {
177 fn flash(&self, key: &str, value: Json) {
178 self.lock().flash(key, value);
179 }
180
181 fn take(&self, key: &str) -> Option<Json> {
182 self.lock().forget(key)
183 }
184
185 fn peek(&self, key: &str) -> Option<Json> {
186 self.lock().get(key).cloned()
187 }
188}
189
190impl SessionManager {
191 pub fn new(key: &AppKey, store: impl SessionStore) -> Self {
193 SessionManager { inner: Arc::new(SessionManager::defaults(key, store)) }
194 }
195
196 pub fn from_config(config: &Config, store: impl SessionStore) -> Result<Self> {
202 let mut inner = SessionManager::defaults(&AppKey::from_config(config)?, store);
203 inner.cookie = config.string("session.cookie", DEFAULT_COOKIE);
204 inner.lifetime = SessionManager::lifetime_from_config(config);
205 inner.path = config.string("session.path", "/");
206 inner.secure = config.bool("session.secure", config.is_production());
208
209 Ok(SessionManager { inner: Arc::new(inner) })
210 }
211
212 fn defaults(key: &AppKey, store: impl SessionStore) -> Inner {
213 let lifetime = store.lifetime();
214 Inner {
215 store: Arc::new(store),
216 cookie_key: key.derive("session-cookie"),
217 cookie: DEFAULT_COOKIE.to_string(),
218 lifetime,
219 path: "/".to_string(),
220 secure: false,
225 same_site: SameSite::Lax,
226 }
227 }
228
229 pub fn lifetime_from_config(config: &Config) -> Duration {
231 match config.int("session.lifetime", -1) {
232 minutes if minutes > 0 => Duration::from_secs(minutes as u64 * 60),
233 _ => DEFAULT_LIFETIME,
234 }
235 }
236
237 pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
238 Arc::make_mut(&mut self.inner).cookie = name.into();
239 self
240 }
241
242 pub fn lifetime(mut self, lifetime: Duration) -> Self {
243 Arc::make_mut(&mut self.inner).lifetime = lifetime;
244 self
245 }
246
247 pub fn path(mut self, path: impl Into<String>) -> Self {
248 Arc::make_mut(&mut self.inner).path = path.into();
249 self
250 }
251
252 pub fn secure(mut self, secure: bool) -> Self {
254 Arc::make_mut(&mut self.inner).secure = secure;
255 self
256 }
257
258 pub fn same_site(mut self, same_site: SameSite) -> Self {
259 Arc::make_mut(&mut self.inner).same_site = same_site;
260 self
261 }
262
263 pub fn store(&self) -> &SharedStore {
264 &self.inner.store
265 }
266
267 fn sign(&self, id: &str) -> String {
274 let mut mac =
275 HmacSha256::new_from_slice(&self.inner.cookie_key).expect("HMAC accepts any key length");
276 mac.update(id.as_bytes());
277 format!("{id}.{}", base64::encode_url(&mac.finalize().into_bytes()))
278 }
279
280 fn unsign(&self, value: &str) -> Option<String> {
282 let (id, signature) = value.rsplit_once('.')?;
283 if !is_valid_id(id) {
284 return None;
285 }
286
287 let expected = self.sign(id);
288 let expected = expected.rsplit_once('.')?.1;
289 constant_time_eq(expected.as_bytes(), signature.as_bytes()).then(|| id.to_string())
290 }
291
292 fn cookie_for(&self, id: &str) -> Cookie {
293 Cookie::new(self.inner.cookie.clone(), self.sign(id))
294 .path(self.inner.path.clone())
295 .max_age(self.inner.lifetime)
296 .http_only(true)
297 .secure(self.inner.secure)
298 .same_site(self.inner.same_site)
299 }
300}
301
302impl Clone for Inner {
303 fn clone(&self) -> Self {
304 Inner {
305 store: Arc::clone(&self.store),
306 cookie_key: self.cookie_key,
307 cookie: self.cookie.clone(),
308 lifetime: self.lifetime,
309 path: self.path.clone(),
310 secure: self.secure,
311 same_site: self.same_site,
312 }
313 }
314}
315
316impl Middleware for SessionManager {
317 fn handle(&self, mut request: Request, next: Next) -> BoxFuture<Response> {
318 let manager = self.clone();
319
320 Box::pin(async move {
321 let presented = request
322 .cookie(&manager.inner.cookie)
323 .and_then(|value| manager.unsign(&value));
324
325 let (session, existed) = match &presented {
326 Some(id) => match manager.inner.store.read(id).await {
327 Ok(Some(session)) => (session, true),
328 Ok(None) => (Session::new(), false),
329 Err(error) => {
330 rustlavel_core::error!("could not read the session: {error}");
333 (Session::new(), false)
334 }
335 },
336 None => (Session::new(), false),
337 };
338
339 let loaded_id = session.id().to_string();
340 let handle = SessionHandle::new(session);
341
342 let method = request.method();
344 let wants_json = request.wants_json();
345 let target = request.target().to_string();
346
347 request.extend(handle.clone());
348 let flash: std::sync::Arc<dyn rustlavel_http::Flash> = std::sync::Arc::new(handle.clone());
352 request.extend(flash);
353
354 let response = next.run(request).await;
355
356 if matches!(method, Method::Get)
372 && !wants_json
373 && (200..300).contains(&response.status.code())
374 && handle.lock().is_dirty()
375 {
376 handle.put(rustlavel_http::flash::PREVIOUS_URL_KEY, target.clone());
377 }
378
379 let (session, dirty) = {
382 let mut session = handle.lock();
383 session.age_flash();
384 (session.clone(), session.is_dirty())
385 };
386
387 if !dirty && session.id() == loaded_id {
410 return response;
411 }
412
413 if existed && session.id() != loaded_id
416 && let Err(error) = manager.inner.store.destroy(&loaded_id).await
417 {
418 rustlavel_core::error!("could not destroy the previous session: {error}");
419 }
420
421 if let Err(error) = manager.inner.store.write(&session).await {
422 rustlavel_core::error!("could not write the session: {error}");
423 return response;
424 }
425
426 response.with_cookie(manager.cookie_for(session.id()))
427 })
428 }
429}
430
431impl std::fmt::Debug for SessionManager {
432 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433 f.debug_struct("SessionManager")
434 .field("cookie", &self.inner.cookie)
435 .field("lifetime", &self.inner.lifetime)
436 .field("secure", &self.inner.secure)
437 .finish()
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use crate::store::MemoryStore;
445 use rustlavel_http::{Router, TestClient};
446
447 fn key() -> AppKey {
448 AppKey::from_bytes([3u8; 32])
449 }
450
451 fn manager() -> SessionManager {
452 SessionManager::new(&key(), MemoryStore::new())
453 }
454
455
456 #[tokio::test]
466 async fn a_request_that_changes_nothing_sets_no_cookie() {
467 let mut router = Router::new();
468 router.middleware(manager());
469 router.get("/touches", |req: Request| async move {
470 req.session().put("something", Json::from("changed"));
471 "wrote"
472 });
473 router.get("/reads-nothing", |_req: Request| async { "untouched" });
474
475 let client = TestClient::new(router);
476
477 let first = client.get("/touches").await;
479 let cookie = cookie_of(&first);
480
481 let second = client
483 .send(Request::new(Method::Get, "/reads-nothing").with_header("cookie", cookie))
484 .await;
485 assert!(
486 second.header("set-cookie").is_none(),
487 "a response that changed nothing re-sent the session cookie, which is what let an \
488 asset request hand back an id a concurrent sign-in had already replaced"
489 );
490 }
491
492 #[tokio::test]
494 async fn a_request_that_changes_the_session_still_sets_a_cookie() {
495 let mut router = Router::new();
496 router.middleware(manager());
497 router.get("/touches", |req: Request| async move {
498 req.session().put("n", Json::from(1.0));
499 "wrote"
500 });
501
502 let client = TestClient::new(router);
503 let response = client.get("/touches").await;
504 assert!(response.header("set-cookie").is_some(), "a changed session was not persisted");
505 }
506
507 fn cookie_of(response: &rustlavel_http::TestResponse) -> String {
509 let header = response
510 .header("set-cookie")
511 .unwrap_or_else(|| panic!("no set-cookie header on the response"));
512 header.split(';').next().unwrap().to_string()
513 }
514
515 fn counting_router(manager: SessionManager) -> Router {
516 let mut router = Router::new();
517 router.middleware(manager);
518 router.get("/count", |request: Request| async move {
519 let visits = request.session().get("visits").and_then(|v| v.as_i64()).unwrap_or(0) + 1;
520 request.session().put("visits", visits);
521 format!("{visits}")
522 });
523 router.get("/quiet", |_request: Request| async { "nothing to see" });
524 router.get("/flash", |request: Request| async move {
525 request.session().flash("status", "Saved");
526 "flashed"
527 });
528 router.get("/status", |request: Request| async move {
529 request.session().get_string("status").unwrap_or_else(|| "none".into())
530 });
531 router.get("/rotate", |request: Request| async move {
532 request.session().put("kept", "yes");
533 request.session().regenerate()
534 });
535 router
536 }
537
538 #[tokio::test]
539 async fn a_session_survives_between_requests_through_the_cookie() {
540 let client = TestClient::new(counting_router(manager()));
541
542 let first = client.get("/count").await.assert_ok();
543 assert_eq!(first.body(), "1");
544 let cookie = cookie_of(&first);
545 assert!(cookie.starts_with(DEFAULT_COOKIE));
546
547 let second = client
548 .send(Request::new(Method::Get, "/count").with_header("cookie", cookie.clone()))
549 .await;
550 assert_eq!(second.body(), "2");
551
552 let third = client
553 .send(Request::new(Method::Get, "/count").with_header("cookie", cookie))
554 .await;
555 assert_eq!(third.body(), "3");
556 }
557
558 #[tokio::test]
559 async fn the_session_cookie_is_http_only_and_scoped() {
560 let response = TestClient::new(counting_router(manager())).get("/count").await;
561 let header = response.header("set-cookie").unwrap();
562
563 assert!(header.contains("HttpOnly"), "header was {header}");
564 assert!(header.contains("SameSite=Lax"));
565 assert!(header.contains("Path=/"));
566 assert!(header.contains("Max-Age=7200"));
567 assert!(!header.contains("Secure"), "a plain-http development cookie is not Secure");
568 }
569
570 #[tokio::test]
571 async fn a_request_that_never_touches_the_session_gets_no_cookie() {
572 let response = TestClient::new(counting_router(manager())).get("/quiet").await.assert_ok();
573
574 assert_eq!(
575 response.header("set-cookie"),
576 None,
577 "nothing was stored, so there is nothing to hand the client"
578 );
579 }
580
581 #[tokio::test]
582 async fn a_forged_or_unsigned_cookie_starts_a_fresh_session() {
583 let client = TestClient::new(counting_router(manager()));
584
585 let real = client.get("/count").await;
586 let cookie = cookie_of(&real);
587
588 let (name_and_id, _) = cookie.rsplit_once('.').unwrap();
590 let forged = format!("{name_and_id}.{}", base64::encode_url(&[0u8; 32]));
591 let response = client
592 .send(Request::new(Method::Get, "/count").with_header("cookie", forged))
593 .await;
594 assert_eq!(response.body(), "1", "a forged cookie must not open the real session");
595
596 let response = client
598 .send(Request::new(Method::Get, "/count").with_header(
599 "cookie",
600 format!("{DEFAULT_COOKIE}={}", Session::new_id()),
601 ))
602 .await;
603 assert_eq!(response.body(), "1");
604 }
605
606 #[test]
607 fn a_cookie_signed_with_another_key_is_refused() {
608 let mine = SessionManager::new(&key(), MemoryStore::new());
609 let theirs = SessionManager::new(&AppKey::from_bytes([4u8; 32]), MemoryStore::new());
610 let id = Session::new_id();
611
612 assert_eq!(mine.unsign(&mine.sign(&id)).as_deref(), Some(id.as_str()));
613 assert!(mine.unsign(&theirs.sign(&id)).is_none());
614 assert!(mine.unsign("no-dot-here").is_none());
616 assert!(mine.unsign(&format!("not-an-id.{}", base64::encode_url(&[0u8; 32]))).is_none());
617 }
618
619 #[tokio::test]
620 async fn flashed_data_is_readable_on_the_next_request_and_gone_after_it() {
621 let client = TestClient::new(counting_router(manager()));
622
623 let flashed = client.get("/flash").await;
624 let cookie = cookie_of(&flashed);
625
626 let next = client
627 .send(Request::new(Method::Get, "/status").with_header("cookie", cookie.clone()))
628 .await;
629 assert_eq!(next.body(), "Saved");
630
631 let after = client
632 .send(Request::new(Method::Get, "/status").with_header("cookie", cookie))
633 .await;
634 assert_eq!(after.body(), "none");
635 }
636
637 #[tokio::test]
638 async fn regenerating_issues_a_new_cookie_and_drops_the_old_record() {
639 let manager = manager();
640 let store = Arc::clone(manager.store());
641 let client = TestClient::new(counting_router(manager));
642
643 let first = client.get("/count").await;
644 let old_cookie = cookie_of(&first);
645 let old_id = old_cookie.split_once('=').unwrap().1.rsplit_once('.').unwrap().0.to_string();
646 assert!(store.read(&old_id).await.unwrap().is_some());
647
648 let rotated = client
649 .send(Request::new(Method::Get, "/rotate").with_header("cookie", old_cookie.clone()))
650 .await;
651 let new_id = rotated.body();
652 let new_cookie = cookie_of(&rotated);
653
654 assert_ne!(new_id, old_id);
655 assert!(store.read(&old_id).await.unwrap().is_none(), "the old id must stop working");
656
657 let carried = store.read(&new_id).await.unwrap().expect("the new id holds the session");
658 assert_eq!(carried.get_string("kept").as_deref(), Some("yes"));
659 assert_ne!(new_cookie, old_cookie);
660 }
661
662 #[tokio::test]
663 async fn an_expired_stored_session_starts_over() {
664 let manager = SessionManager::new(&key(), MemoryStore::with_lifetime(Duration::ZERO));
665 let client = TestClient::new(counting_router(manager));
666
667 let first = client.get("/count").await;
668 let cookie = cookie_of(&first);
669
670 let second = client
671 .send(Request::new(Method::Get, "/count").with_header("cookie", cookie))
672 .await;
673 assert_eq!(second.body(), "1", "the stored session had already expired");
674 }
675
676 #[test]
677 fn configuration_drives_the_cookie_name_lifetime_and_secure_flag() {
678 let config = Config::new();
679 config.set("app.key", AppKey::generate());
680 config.set("session.cookie", "my_app_session");
681 config.set("session.lifetime", 30);
682 config.set("app.env", "production");
683
684 let manager = SessionManager::from_config(&config, MemoryStore::new()).unwrap();
685
686 assert_eq!(manager.inner.cookie, "my_app_session");
687 assert_eq!(manager.inner.lifetime, Duration::from_secs(30 * 60));
688 assert!(manager.inner.secure, "production cookies default to Secure");
689 assert_eq!(SessionManager::lifetime_from_config(&Config::new()), DEFAULT_LIFETIME);
690 }
691
692 #[test]
693 fn builders_override_configuration() {
694 let manager = manager()
695 .cookie_name("custom")
696 .lifetime(Duration::from_secs(60))
697 .path("/app")
698 .secure(true)
699 .same_site(SameSite::Strict);
700
701 assert_eq!(manager.inner.cookie, "custom");
702 assert_eq!(manager.inner.path, "/app");
703 assert!(manager.inner.secure);
704
705 let header = manager.cookie_for(&Session::new_id()).to_header();
706 assert!(header.contains("SameSite=Strict"));
707 assert!(header.contains("Secure"));
708 assert!(header.starts_with("custom="));
709 }
710
711 #[tokio::test]
712 async fn a_handler_without_the_middleware_says_what_is_missing() {
713 let mut router = Router::new();
714 router.get("/", |request: Request| async move {
715 assert!(request.try_session().is_none());
716 request.session().id()
717 });
718
719 let response = TestClient::new(router).get("/").await;
721 assert_eq!(response.status(), 500);
722 }
723}