Skip to main content

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}