churust_core/session.rs
1//! Sessions: per-visitor state carried by a cookie.
2//!
3//! A [`SessionStore`] trait plus a cookie-backed
4//! implementation. The session is a string map, seeded into the call by
5//! [`Sessions`] and read with the [`Session`] extractor.
6//!
7//! ```
8//! use churust_core::{Call, Churust, Session, Sessions, TestClient};
9//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
10//! let app = Churust::server()
11//! .install(Sessions::cookie("change-me-in-production"))
12//! .routing(|r| {
13//! r.get("/visit", |s: Session| async move {
14//! let n: u32 = s.get("count").and_then(|v| v.parse().ok()).unwrap_or(0);
15//! s.set("count", (n + 1).to_string());
16//! format!("visit {}", n + 1)
17//! });
18//! })
19//! .build();
20//!
21//! let res = TestClient::new(app).get("/visit").send().await;
22//! assert_eq!(res.text(), "visit 1");
23//! assert!(res.header("set-cookie").is_some());
24//! # });
25//! ```
26
27use crate::call::Call;
28use crate::cookie::Cookie;
29use crate::error::Result;
30use crate::extract::FromCallParts;
31use crate::pipeline::{Middleware, Next};
32use crate::response::{IntoResponse, Response};
33use async_trait::async_trait;
34use std::collections::BTreeMap;
35use std::sync::{Arc, Mutex};
36
37/// The session's contents, plus whether they changed.
38#[derive(Debug, Default)]
39struct Inner {
40 data: BTreeMap<String, String>,
41 dirty: bool,
42}
43
44/// A handle to the current visitor's session.
45///
46/// Cloneable and cheap; all clones share one map. Extract it as a handler
47/// argument.
48#[derive(Clone, Debug, Default)]
49pub struct Session(Arc<Mutex<Inner>>);
50
51impl Session {
52 /// Read a value.
53 pub fn get(&self, key: &str) -> Option<String> {
54 self.0.lock().ok()?.data.get(key).cloned()
55 }
56
57 /// Insert or replace a value.
58 pub fn set(&self, key: impl Into<String>, value: impl Into<String>) {
59 if let Ok(mut g) = self.0.lock() {
60 g.data.insert(key.into(), value.into());
61 g.dirty = true;
62 }
63 }
64
65 /// Remove a value.
66 pub fn remove(&self, key: &str) {
67 if let Ok(mut g) = self.0.lock() {
68 if g.data.remove(key).is_some() {
69 g.dirty = true;
70 }
71 }
72 }
73
74 /// Drop every value.
75 ///
76 /// Call this on logout. Rotating rather than reusing a session is what
77 /// stops a fixated session id from surviving a privilege change.
78 pub fn clear(&self) {
79 if let Ok(mut g) = self.0.lock() {
80 g.data.clear();
81 g.dirty = true;
82 }
83 }
84
85 /// Keep the contents but ask the store for a new identifier.
86 ///
87 /// This is the session fixation defence for a **server-side** store: an
88 /// attacker who plants a known session id must not still hold a valid one
89 /// after the victim's privileges change. Call it on login, and on any other
90 /// privilege change, when you want to keep what is already in the session
91 /// (a cart, a locale) rather than dropping it the way [`clear`](Self::clear)
92 /// does.
93 ///
94 /// It works by removing [`SESSION_ID_KEY`], which a server-side store uses
95 /// to remember which record this session is, so the next write mints a new
96 /// one. [`CookieStore`] keeps no identifier and is unaffected: its payload
97 /// is re-signed on every change already, so a planted cookie never carries
98 /// the post-login contents.
99 pub fn rotate(&self) {
100 if let Ok(mut g) = self.0.lock() {
101 g.data.remove(SESSION_ID_KEY);
102 g.dirty = true;
103 }
104 }
105
106 fn changed(&self) -> bool {
107 self.0.lock().map(|g| g.dirty).unwrap_or(false)
108 }
109
110 fn snapshot(&self) -> BTreeMap<String, String> {
111 self.0.lock().map(|g| g.data.clone()).unwrap_or_default()
112 }
113
114 fn from_map(data: BTreeMap<String, String>) -> Self {
115 Session(Arc::new(Mutex::new(Inner { data, dirty: false })))
116 }
117}
118
119#[async_trait]
120impl FromCallParts for Session {
121 async fn from_call_parts(call: &mut Call) -> Result<Self> {
122 // Absent only when the Sessions plugin is not installed; an empty
123 // session is friendlier than an error, and nothing is persisted.
124 Ok(call.get::<Session>().unwrap_or_default())
125 }
126}
127
128/// The reserved key under which a server-side [`SessionStore`] records which
129/// row, document or Redis key this session is.
130///
131/// Stores that keep everything client-side, [`CookieStore`] among them, have no
132/// identifier and ignore it. It is named in the public API only so that
133/// [`Session::rotate`] has something well-defined to remove, and so a store
134/// implementor knows which key is spoken for.
135pub const SESSION_ID_KEY: &str = "__churust_sid";
136
137/// Where session contents live between requests.
138///
139/// Both operations are `async` because a store backed by server state has to
140/// talk to it. A client-side store such as [`CookieStore`] never awaits
141/// anything and simply returns.
142#[async_trait]
143pub trait SessionStore: Send + Sync + 'static {
144 /// Recover a session from the cookie value, if it is intact.
145 async fn load(&self, raw: &str) -> Option<BTreeMap<String, String>>;
146
147 /// Persist the session and return the new cookie value, or `Ok(None)` to
148 /// leave the visitor's cookie untouched.
149 ///
150 /// `previous` is the cookie value this request arrived with, if any. A
151 /// server-side store needs it to delete the record it is replacing: without
152 /// it, logging out would leave the old record readable until its own TTV
153 /// elapsed, which is exactly the revocation a server-side store exists to
154 /// provide. A client-side store ignores it.
155 ///
156 /// # Errors
157 ///
158 /// Return an `Err` when something the caller is entitled to assume happened
159 /// did not, and the case that matters is a withdrawal: an emptied `data` is
160 /// a logout, and a logout that never reached the backing store is not a
161 /// logout. The middleware then answers with that error instead of the
162 /// handler's response, because a `200` and a farewell page, sent while the
163 /// cookie the visitor just asked to be rid of still authenticates, is worse
164 /// than an honest failure they can retry.
165 ///
166 /// Failing to *save* is a different question and is usually better
167 /// swallowed with `Ok`: the visitor signs in again, which beats a `500` on
168 /// every route while the backing store is unwell. Which failures are worth
169 /// a response is therefore the store's judgement, not a blanket rule.
170 async fn store(
171 &self,
172 data: &BTreeMap<String, String>,
173 previous: Option<&str>,
174 ) -> Result<Option<String>>;
175
176 /// Adopt the session lifetime configured on [`Sessions::max_age`].
177 ///
178 /// Returning a new boxed store opts in; the default returns `None`, which
179 /// means "I manage my own expiry" — the right answer for a store backed by
180 /// server state, which can simply delete the record.
181 ///
182 /// This exists because `Max-Age` on a cookie is only a hint to a
183 /// well-behaved client. A store that keeps its state client-side has to
184 /// enforce the deadline itself, and it can only do that if it is told what
185 /// the deadline is.
186 fn with_session_max_age(&self, _secs: i64) -> Option<Box<dyn SessionStore>> {
187 None
188 }
189}
190
191/// Keeps the whole session in the cookie, signed with HMAC-SHA256 so a client
192/// cannot edit it.
193///
194/// No server-side state, which makes it the simplest thing that works. Two
195/// properties are worth being explicit about:
196///
197/// - **Signed, not encrypted.** The values are readable by whoever holds the
198/// cookie. Do not put anything in it the visitor should not see.
199/// - **The contents travel on every request.** This suits a user id and a flash
200/// message, not a shopping cart.
201/// - **Expiry is carried inside the signature.** `Set-Cookie`'s `Max-Age` is
202/// only a hint to a well-behaved client; a copied cookie ignores it. When the
203/// session plugin sets a max age, the deadline is signed into the payload and
204/// checked on load, so a captured cookie stops working.
205/// - **There is still no revocation.** Logging out clears the client's copy,
206/// but a copy taken beforehand stays valid until its deadline, because
207/// nothing server-side records that it was withdrawn. A store backed by
208/// server state is what fixes that.
209///
210/// Use a key with at least 32 bytes of entropy, and keep it out of source
211/// control.
212pub struct CookieStore {
213 key: Vec<u8>,
214 /// Signed into the payload when set, so the deadline cannot be edited or
215 /// ignored by the client.
216 max_age: Option<i64>,
217}
218
219/// Reserved payload key holding the expiry as a Unix timestamp.
220///
221/// Prefixed so it cannot collide with an application key: `esc` percent-escapes
222/// the separators but leaves `_`, so a caller *could* write this name — hence
223/// it is stripped on load and overwritten on store rather than trusted.
224const EXPIRY_KEY: &str = "__churust_exp";
225
226impl CookieStore {
227 /// Build a store from a signing key.
228 pub fn new(key: impl Into<Vec<u8>>) -> Self {
229 Self {
230 key: key.into(),
231 max_age: None,
232 }
233 }
234
235 /// Sign an expiry `secs` in the future into every cookie this store issues.
236 ///
237 /// Set by [`Sessions::max_age`]; without it a cookie is valid for as long
238 /// as the signing key is.
239 pub fn with_max_age(mut self, secs: i64) -> Self {
240 self.max_age = Some(secs);
241 self
242 }
243
244 /// Seconds since the Unix epoch, or `None` if the clock is before it.
245 fn now_secs() -> Option<i64> {
246 std::time::SystemTime::now()
247 .duration_since(std::time::UNIX_EPOCH)
248 .ok()
249 .map(|d| d.as_secs() as i64)
250 }
251
252 /// HMAC-SHA256 over the payload, hex-encoded.
253 fn sign(&self, payload: &str) -> String {
254 use hmac::Mac;
255 // `new_from_slice` accepts any key length for HMAC, so this cannot fail.
256 let mut mac = <hmac::Hmac<sha2::Sha256> as hmac::Mac>::new_from_slice(&self.key)
257 .expect("HMAC accepts a key of any length");
258 mac.update(payload.as_bytes());
259 let tag = mac.finalize().into_bytes();
260 tag.iter().map(|b| format!("{b:02x}")).collect()
261 }
262}
263
264#[async_trait]
265impl SessionStore for CookieStore {
266 fn with_session_max_age(&self, secs: i64) -> Option<Box<dyn SessionStore>> {
267 // Keyed off the store, not off how `Sessions` happened to be built:
268 // wiring this through a key that only `Sessions::cookie` records meant
269 // `Sessions::with_store(CookieStore::new(k)).max_age(n)` signed no
270 // deadline at all, so a captured cookie replayed until the key rotated.
271 Some(Box::new(CookieStore {
272 key: self.key.clone(),
273 max_age: Some(secs),
274 }))
275 }
276
277 async fn load(&self, raw: &str) -> Option<BTreeMap<String, String>> {
278 let (sig, payload) = raw.split_once('.')?;
279 // Constant-time so a forged signature cannot be refined byte by byte.
280 if !crate::secure_compare(sig, self.sign(payload)) {
281 return None;
282 }
283 let mut out = BTreeMap::new();
284 for pair in payload.split('&') {
285 if pair.is_empty() {
286 continue;
287 }
288 let (k, v) = pair.split_once('=')?;
289 out.insert(crate::cookie::decode(k), crate::cookie::decode(v));
290 }
291
292 // The signature covers the deadline, so this cannot be edited by the
293 // client — only presented or not. A cookie that carries one and is past
294 // it is treated as absent, which starts a fresh session.
295 if let Some(raw_exp) = out.remove(EXPIRY_KEY) {
296 let expires_at = raw_exp.parse::<i64>().ok()?;
297 if Self::now_secs().is_some_and(|now| now >= expires_at) {
298 return None;
299 }
300 }
301 Some(out)
302 }
303
304 /// `previous` is unused: the whole session lives in the cookie, so there is
305 /// no server-side record to withdraw. Nothing here can fail either, for the
306 /// same reason — signing a payload asks nothing of anybody — so this never
307 /// returns `Err`, and an emptied session simply re-signs an empty payload.
308 async fn store(
309 &self,
310 data: &BTreeMap<String, String>,
311 _previous: Option<&str>,
312 ) -> Result<Option<String>> {
313 let mut pairs: Vec<String> = data
314 .iter()
315 // Never let an application key masquerade as the deadline.
316 .filter(|(k, _)| k.as_str() != EXPIRY_KEY)
317 .map(|(k, v)| format!("{}={}", esc(k), esc(v)))
318 .collect();
319 if let (Some(age), Some(now)) = (self.max_age, Self::now_secs()) {
320 pairs.push(format!("{}={}", esc(EXPIRY_KEY), now + age));
321 }
322 let payload = pairs.join("&");
323 Ok(Some(format!("{}.{}", self.sign(&payload), payload)))
324 }
325}
326
327/// Escape the separators the payload format uses.
328fn esc(s: &str) -> String {
329 s.replace('%', "%25")
330 .replace('&', "%26")
331 .replace('=', "%3D")
332 .replace('.', "%2E")
333}
334
335/// Installs session handling. See the [module docs](self).
336pub struct Sessions {
337 store: Arc<dyn SessionStore>,
338 cookie_name: String,
339 secure: bool,
340 max_age: Option<i64>,
341}
342
343impl Sessions {
344 /// Sessions kept entirely in a signed cookie.
345 pub fn cookie(key: impl Into<Vec<u8>>) -> Self {
346 Self::with_store(CookieStore::new(key))
347 }
348
349 /// Sessions kept wherever `store` puts them.
350 pub fn with_store<S: SessionStore>(store: S) -> Self {
351 Self {
352 store: Arc::new(store),
353 cookie_name: "churust_session".into(),
354 secure: false,
355 max_age: None,
356 }
357 }
358
359 /// Change the cookie name (default `churust_session`).
360 pub fn cookie_name(mut self, n: impl Into<String>) -> Self {
361 self.cookie_name = n.into();
362 self
363 }
364
365 /// Mark the cookie `Secure`. Turn this on in production.
366 pub fn secure(mut self, yes: bool) -> Self {
367 self.secure = yes;
368 self
369 }
370
371 /// Expire the session after `secs`.
372 ///
373 /// This sets `Max-Age` on the cookie *and*, for the built-in cookie store,
374 /// signs the deadline into the payload. `Max-Age` alone is only a hint to a
375 /// well-behaved client, so a copied cookie would otherwise stay valid
376 /// forever; the signed deadline is what actually stops replay.
377 ///
378 /// A custom [`SessionStore`] owns its own expiry policy and is untouched.
379 pub fn max_age(mut self, secs: i64) -> Self {
380 self.max_age = Some(secs);
381 // Ask the store to adopt the deadline. A client-side store must sign it
382 // in to enforce it; a server-side store declines and manages its own.
383 if let Some(updated) = self.store.with_session_max_age(secs) {
384 self.store = Arc::from(updated);
385 }
386 self
387 }
388}
389
390impl crate::app::Plugin for Sessions {
391 fn install(self: Box<Self>, app: &mut crate::app::AppBuilder) {
392 app.add_middleware(Arc::new(SessionMiddleware {
393 store: self.store.clone(),
394 cookie_name: self.cookie_name.clone(),
395 secure: self.secure,
396 max_age: self.max_age,
397 }));
398 }
399}
400
401struct SessionMiddleware {
402 store: Arc<dyn SessionStore>,
403 cookie_name: String,
404 secure: bool,
405 max_age: Option<i64>,
406}
407
408#[async_trait]
409impl Middleware for SessionMiddleware {
410 async fn handle(&self, mut call: Call, next: Next) -> Response {
411 let arrived_with = call.cookie(&self.cookie_name);
412 let loaded = match arrived_with.as_deref() {
413 Some(raw) => self.store.load(raw).await.unwrap_or_default(),
414 None => BTreeMap::new(),
415 };
416
417 let session = Session::from_map(loaded);
418 call.insert(session.clone());
419
420 let mut res = next.run(call).await;
421
422 // Only re-issue when something changed: rewriting an unchanged cookie
423 // on every response is noise, and it would extend the expiry of a
424 // session the visitor is not actually using.
425 if session.changed() {
426 match self
427 .store
428 .store(&session.snapshot(), arrived_with.as_deref())
429 .await
430 {
431 Ok(Some(value)) => {
432 let mut c = Cookie::new(self.cookie_name.clone(), value).secure(self.secure);
433 if let Some(age) = self.max_age {
434 c = c.max_age(age);
435 }
436 res = res.with_cookie(c);
437 }
438 Ok(None) => {}
439 // The handler has already written what it thinks happened, and
440 // on the path that matters — a logout the store could not carry
441 // out — what it thinks happened is wrong. Its response is
442 // therefore discarded rather than decorated: answering `200`
443 // while the cookie the visitor asked to be rid of still
444 // authenticates is exactly the outcome this branch exists to
445 // prevent, and the visitor would have no reason to try again.
446 // No cookie is set either, because none should be: the session
447 // is still live, and clearing the client's copy would only make
448 // the survivor harder to notice.
449 Err(e) => return e.into_response(),
450 }
451 }
452 res
453 }
454}