Skip to main content

rustlavel_auth/
session.rs

1//! The session value: what is remembered about one visitor between requests.
2//!
3//! A `Session` is a plain map plus Laravel's flash lifecycle, and knows nothing
4//! about HTTP or storage. That separation is what makes it testable without a
5//! server and swappable between the in-memory and file stores.
6
7use crate::random;
8use rustlavel_core::Json;
9use std::collections::{BTreeMap, BTreeSet};
10
11/// How many bytes of entropy a session id carries.
12///
13/// A session id is a bearer token: whoever presents it *is* the user, so it has
14/// to be far beyond guessing range even for an attacker making millions of
15/// attempts. 32 bytes — 256 bits — is the size at which enumeration stops being
16/// a threat model and starts being arithmetic.
17pub const ID_BYTES: usize = 32;
18
19/// Whether a string could have been produced by [`Session::new_id`].
20///
21/// Session ids arrive from a cookie, which is to say from the client, and the
22/// file store turns one into a path. Checking the shape before anything else
23/// touches it means a value like `../../.env` is rejected as a session id long
24/// before it can be rejected as a filename.
25pub fn is_valid_id(id: &str) -> bool {
26    (ID_BYTES * 2..=128).contains(&id.len())
27        && id.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
28}
29
30/// One visitor's session.
31#[derive(Debug, Clone, PartialEq)]
32pub struct Session {
33    id: String,
34    data: BTreeMap<String, Json>,
35    /// Flashed during this request; must still be readable in the next one.
36    fresh_flash: BTreeSet<String>,
37    /// Flashed during the previous request; readable now, dropped at its end.
38    stale_flash: BTreeSet<String>,
39    /// Unix seconds of the last write, for expiry.
40    updated_at: u64,
41    dirty: bool,
42}
43
44impl Session {
45    /// Where the CSRF token lives, and the name of the form field that carries
46    /// it back. Laravel's `_token`, so a template ported across looks familiar.
47    pub const TOKEN_KEY: &'static str = "_token";
48
49    /// Start a session with a brand new id.
50    pub fn new() -> Self {
51        Session::with_id(Session::new_id())
52    }
53
54    /// Rebuild a session around a known id.
55    pub fn with_id(id: impl Into<String>) -> Self {
56        Session {
57            id: id.into(),
58            data: BTreeMap::new(),
59            fresh_flash: BTreeSet::new(),
60            stale_flash: BTreeSet::new(),
61            updated_at: crate::unix_now(),
62            dirty: false,
63        }
64    }
65
66    /// A fresh identifier: [`ID_BYTES`] of OS entropy, hex encoded.
67    pub fn new_id() -> String {
68        random::hex(ID_BYTES)
69    }
70
71    pub fn id(&self) -> &str {
72        &self.id
73    }
74
75    /// When this session was last written, in unix seconds.
76    pub fn updated_at(&self) -> u64 {
77        self.updated_at
78    }
79
80    /// Whether anything changed since the session was loaded.
81    ///
82    /// The middleware uses this to avoid writing a session file for every
83    /// anonymous request that never touched the session.
84    pub fn is_dirty(&self) -> bool {
85        self.dirty
86    }
87
88    pub fn get(&self, key: &str) -> Option<&Json> {
89        self.data.get(key)
90    }
91
92    /// A value as a string, which is what most session data actually is.
93    pub fn get_string(&self, key: &str) -> Option<String> {
94        match self.data.get(key)? {
95            Json::String(value) => Some(value.clone()),
96            Json::Null => None,
97            other => Some(other.to_string()),
98        }
99    }
100
101    pub fn has(&self, key: &str) -> bool {
102        self.data.contains_key(key)
103    }
104
105    pub fn put(&mut self, key: impl Into<String>, value: impl Into<Json>) {
106        self.data.insert(key.into(), value.into());
107        self.dirty = true;
108    }
109
110    /// Remove a key, handing back whatever was there.
111    pub fn forget(&mut self, key: &str) -> Option<Json> {
112        let removed = self.data.remove(key);
113        self.fresh_flash.remove(key);
114        self.stale_flash.remove(key);
115        if removed.is_some() {
116            self.dirty = true;
117        }
118        removed
119    }
120
121    /// Remove everything, keeping the id.
122    ///
123    /// On logout prefer [`Session::invalidate`], which also rotates the id.
124    pub fn flush(&mut self) {
125        self.data.clear();
126        self.fresh_flash.clear();
127        self.stale_flash.clear();
128        self.dirty = true;
129    }
130
131    pub fn all(&self) -> &BTreeMap<String, Json> {
132        &self.data
133    }
134
135    /// Store a value that survives exactly one further request.
136    ///
137    /// The redirect-with-a-message pattern: a POST handler flashes `status`,
138    /// redirects, and the GET that follows renders it. By the request after
139    /// that, it is gone — so a refresh does not show the message twice.
140    pub fn flash(&mut self, key: impl Into<String>, value: impl Into<Json>) {
141        let key = key.into();
142        self.fresh_flash.insert(key.clone());
143        self.put(key, value);
144    }
145
146    /// Keep one flashed value for another request.
147    pub fn keep(&mut self, key: &str) {
148        if self.stale_flash.remove(key) {
149            self.fresh_flash.insert(key.to_string());
150            self.dirty = true;
151        }
152    }
153
154    /// Keep every flashed value for another request.
155    pub fn reflash(&mut self) {
156        let carried = std::mem::take(&mut self.stale_flash);
157        if !carried.is_empty() {
158            self.dirty = true;
159        }
160        self.fresh_flash.extend(carried);
161    }
162
163    /// Advance the flash lifecycle by one request.
164    ///
165    /// Called by the session middleware after the handler has run: what was
166    /// flashed *last* request has now been read and is dropped, and what was
167    /// flashed *this* request takes its place.
168    pub fn age_flash(&mut self) {
169        let expiring: Vec<String> =
170            self.stale_flash.difference(&self.fresh_flash).cloned().collect();
171        for key in expiring {
172            self.data.remove(&key);
173            self.dirty = true;
174        }
175        self.stale_flash = std::mem::take(&mut self.fresh_flash);
176    }
177
178    /// Give this session a new id, keeping its contents.
179    ///
180    /// This is the defence against session fixation. An attacker who can plant
181    /// a cookie — through a subdomain, an open redirect, or a shared machine —
182    /// hands the victim a session id he already knows, waits for the victim to
183    /// log in, and then presents the same id to arrive inside the authenticated
184    /// session. Rotating the id at the moment privileges change makes the id he
185    /// planted worthless: it is no longer the id the session answers to.
186    ///
187    /// [`crate::Guard::login`] calls this for exactly that reason.
188    pub fn regenerate(&mut self) -> &str {
189        self.id = Session::new_id();
190        // The CSRF token goes with the id, which it did not use to.
191        //
192        // Rotating the id stops an attacker who planted a session cookie from
193        // riding the session afterwards — but the token he planted with it was
194        // left in place, and it is the token the *authenticated* session then
195        // answers to. He could drive state-changing requests from a page of his
196        // own for the life of that session, which is most of what rotating the
197        // id was meant to prevent. Laravel's `Store::regenerate` calls
198        // `regenerateToken` for this reason.
199        //
200        // Forgotten rather than replaced: a session that never needed a token
201        // should not be given one, and `token()` mints one when something
202        // actually asks.
203        self.forget(Session::TOKEN_KEY);
204        self.dirty = true;
205        &self.id
206    }
207
208    /// Throw the contents away *and* rotate the id — what logout wants.
209    pub fn invalidate(&mut self) -> &str {
210        self.flush();
211        self.regenerate()
212    }
213
214    /// The CSRF token for this session, generated on first use.
215    ///
216    /// Living in the session is what makes it available to templates: a view
217    /// renders `session.get("_token")` into a hidden field, and the `csrf`
218    /// middleware compares what comes back against this same value.
219    pub fn token(&mut self) -> String {
220        if let Some(token) = self.get_string(Session::TOKEN_KEY) {
221            return token;
222        }
223        let token = random::hex(ID_BYTES);
224        self.put(Session::TOKEN_KEY, token.clone());
225        token
226    }
227
228    /// Serialize for a store. The id is the key, so it is not in the payload.
229    pub fn to_json(&self) -> Json {
230        Json::object([
231            ("data", Json::Object(self.data.clone())),
232            (
233                "flash",
234                Json::Array(self.stale_flash.iter().cloned().map(Json::String).collect()),
235            ),
236            ("updated_at", Json::Number(self.updated_at as f64)),
237        ])
238    }
239
240    /// Rebuild from a store's payload.
241    ///
242    /// A payload that is not an object at all yields an empty session rather
243    /// than an error: a corrupt session file should log the visitor out, not
244    /// take the site down.
245    pub fn from_json(id: impl Into<String>, payload: &Json) -> Self {
246        let mut session = Session::with_id(id);
247
248        if let Some(Json::Object(data)) = payload.get("data") {
249            session.data = data.clone();
250        }
251        if let Some(Json::Array(flash)) = payload.get("flash") {
252            session.stale_flash =
253                flash.iter().filter_map(|key| key.as_str().map(str::to_string)).collect();
254        }
255        if let Some(updated_at) = payload.get("updated_at").and_then(Json::as_f64) {
256            session.updated_at = updated_at as u64;
257        }
258
259        session.dirty = false;
260        session
261    }
262
263    /// Stamp the session as written now. Stores call this before persisting so
264    /// an active visitor's session keeps sliding forward instead of expiring
265    /// a fixed time after login.
266    pub fn touch(&mut self) {
267        self.updated_at = crate::unix_now();
268    }
269}
270
271impl Default for Session {
272    fn default() -> Self {
273        Session::new()
274    }
275}
276
277#[cfg(test)]
278mod tests {
279
280    /// Session fixation, the half that was missing.
281    ///
282    /// An attacker who can plant a cookie — a subdomain, an HTTP hop, a shared
283    /// machine — seeds the victim with a session he made, and therefore with a
284    /// `_token` he knows. Rotating the id on sign-in stopped him riding the
285    /// session. It did not stop the token he knows from being the token the
286    /// authenticated session answers to, which let him drive state-changing
287    /// requests from a page of his own for the life of that session.
288    #[test]
289    fn rotating_the_id_rotates_the_csrf_token() {
290        let mut session = Session::new();
291        let planted = session.token();
292        let before = session.id().to_string();
293
294        session.regenerate();
295
296        assert_ne!(session.id(), before, "the id did not rotate");
297        assert_ne!(
298            session.token(),
299            planted,
300            "the token survived the rotation, so a planted one still works after sign-in"
301        );
302    }
303
304    /// And a session nobody asked a token of does not acquire one just by
305    /// rotating: a token is minted when something needs it, not before.
306    #[test]
307    fn rotating_does_not_mint_a_token_nobody_asked_for() {
308        let mut session = Session::new();
309        session.regenerate();
310        assert!(session.get_string(Session::TOKEN_KEY).is_none());
311    }
312    use super::*;
313
314    #[test]
315    fn new_sessions_get_a_long_random_hexadecimal_id() {
316        let first = Session::new();
317        let second = Session::new();
318
319        assert_eq!(first.id().len(), ID_BYTES * 2);
320        assert!(is_valid_id(first.id()));
321        assert_ne!(first.id(), second.id());
322    }
323
324    #[test]
325    fn id_validation_rejects_anything_that_is_not_our_own_shape() {
326        assert!(is_valid_id(&Session::new_id()));
327        assert!(!is_valid_id(""));
328        assert!(!is_valid_id("short"));
329        assert!(!is_valid_id("../../../etc/passwd"));
330        // The right length, the wrong alphabet.
331        assert!(!is_valid_id(&"g".repeat(64)));
332        assert!(!is_valid_id(&"A".repeat(64)));
333        assert!(!is_valid_id(&"a".repeat(129)));
334    }
335
336    #[test]
337    fn values_are_put_read_and_forgotten() {
338        let mut session = Session::new();
339        assert!(!session.is_dirty());
340
341        session.put("name", "Ada");
342        session.put("visits", 3);
343
344        assert_eq!(session.get_string("name").as_deref(), Some("Ada"));
345        assert_eq!(session.get("visits").and_then(Json::as_i64), Some(3));
346        assert!(session.has("name"));
347        assert!(session.is_dirty());
348
349        assert_eq!(session.forget("name"), Some(Json::from("Ada")));
350        assert!(!session.has("name"));
351        assert_eq!(session.forget("name"), None);
352    }
353
354    #[test]
355    fn flush_empties_the_session_but_keeps_the_id() {
356        let mut session = Session::new();
357        let id = session.id().to_string();
358        session.put("a", 1);
359        session.flash("b", 2);
360
361        session.flush();
362
363        assert!(session.all().is_empty());
364        assert_eq!(session.id(), id);
365    }
366
367    #[test]
368    fn flash_data_survives_exactly_one_request() {
369        // Request one flashes a message; it is readable straight away.
370        let mut session = Session::new();
371        session.flash("status", "Profile saved");
372        assert_eq!(session.get_string("status").as_deref(), Some("Profile saved"));
373        session.age_flash();
374
375        // Request two — the redirect target — still sees it.
376        assert_eq!(session.get_string("status").as_deref(), Some("Profile saved"));
377        session.age_flash();
378
379        // Request three does not.
380        assert_eq!(session.get_string("status"), None);
381    }
382
383    #[test]
384    fn ordinary_values_are_not_swept_away_with_the_flash() {
385        let mut session = Session::new();
386        session.put("user_id", 7);
387        session.flash("status", "hi");
388
389        session.age_flash();
390        session.age_flash();
391
392        assert_eq!(session.get("user_id").and_then(Json::as_i64), Some(7));
393        assert_eq!(session.get_string("status"), None);
394    }
395
396    #[test]
397    fn re_flashing_a_key_gives_it_another_request() {
398        let mut session = Session::new();
399        session.flash("status", "first");
400        session.age_flash();
401
402        // The next request flashes the same key again; the value must not be
403        // swept away by the ageing that happens after it.
404        session.flash("status", "second");
405        session.age_flash();
406
407        assert_eq!(session.get_string("status").as_deref(), Some("second"));
408    }
409
410    #[test]
411    fn keep_and_reflash_extend_flashed_values() {
412        let mut session = Session::new();
413        session.flash("a", 1);
414        session.flash("b", 2);
415        session.age_flash();
416
417        session.keep("a");
418        session.age_flash();
419
420        assert!(session.has("a"));
421        assert!(!session.has("b"));
422
423        session.flash("c", 3);
424        session.age_flash();
425        session.reflash();
426        session.age_flash();
427
428        assert!(session.has("c"));
429    }
430
431    #[test]
432    fn regenerate_rotates_the_id_and_keeps_the_data() {
433        let mut session = Session::new();
434        session.put("user_id", 7);
435        let before = session.id().to_string();
436
437        let after = session.regenerate().to_string();
438
439        assert_ne!(before, after);
440        assert!(is_valid_id(&after));
441        assert_eq!(session.get("user_id").and_then(Json::as_i64), Some(7));
442    }
443
444    #[test]
445    fn invalidate_rotates_the_id_and_drops_the_data() {
446        let mut session = Session::new();
447        session.put("user_id", 7);
448        let before = session.id().to_string();
449
450        session.invalidate();
451
452        assert_ne!(session.id(), before);
453        assert!(session.all().is_empty());
454    }
455
456    #[test]
457    fn the_csrf_token_is_generated_once_and_then_reused() {
458        let mut session = Session::new();
459        let first = session.token();
460
461        assert_eq!(first.len(), ID_BYTES * 2);
462        assert_eq!(session.token(), first);
463        assert_eq!(session.get_string(Session::TOKEN_KEY), Some(first));
464    }
465
466    #[test]
467    fn serialization_round_trips_data_and_pending_flash() {
468        let mut session = Session::new();
469        session.put("user_id", 7);
470        session.flash("status", "saved");
471        session.age_flash();
472
473        let restored = Session::from_json(session.id(), &session.to_json());
474
475        assert_eq!(restored.get("user_id").and_then(Json::as_i64), Some(7));
476        assert_eq!(restored.get_string("status").as_deref(), Some("saved"));
477        assert!(!restored.is_dirty(), "a freshly loaded session has not been changed yet");
478
479        // And the restored flash still expires on schedule.
480        let mut restored = restored;
481        restored.age_flash();
482        assert_eq!(restored.get_string("status"), None);
483    }
484
485    #[test]
486    fn a_corrupt_payload_yields_an_empty_session_rather_than_an_error() {
487        let session = Session::from_json(Session::new_id(), &Json::String("garbage".into()));
488
489        assert!(session.all().is_empty());
490        assert!(is_valid_id(session.id()));
491    }
492}