Skip to main content

ullun/
session.rs

1//! Cookie-based server-side sessions
2//!
3//! The browser only holds an unguessable session id (256 bits from the OS
4//! CSPRNG) in an `HttpOnly`, `SameSite=Lax` cookie; the data lives in a
5//! [`SessionStore`]. Anonymous visitors that never write to the session get no
6//! cookie at all.
7//!
8//! ```no_run
9//! use ullun::prelude::*;
10//! use ullun::session::SessionLayer;
11//!
12//! # async fn run() {
13//! App::new()
14//!     .middleware(SessionLayer::new())
15//!     .post("/login", |req: Request| async move {
16//!         let session = req.session()?;
17//!         session.renew();                               // new id on privilege change: no fixation
18//!         session.set("user", "ann")?;
19//!         Ok(Response::redirect("/"))
20//!     })
21//!     .get("/", |req: Request| async move {
22//!         let user: Option<String> = req.session()?.get("user");
23//!         Ok(Response::text(format!("hello {}", user.unwrap_or_else(|| "stranger".into()))))
24//!     })
25//!     .run("127.0.0.1:3000")
26//!     .await
27//!     .unwrap();
28//! # }
29//! ```
30//!
31//! Defaults: cookie `__Host-sid` (needs HTTPS: `Secure`, `Path=/`, no `Domain`),
32//! 30 minutes idle and 12 hours absolute lifetime. For plain-HTTP development
33//! use [`SessionLayer::insecure_cookies`]. Concurrent requests of one session
34//! are not merged: the last response to finish wins, so keep session data small
35//! and put mutable state in your database.
36
37use crate::cookies::{Cookie, SameSite};
38use crate::error::{Error, Result};
39use crate::middleware::{Middleware, Next};
40use crate::request::Request;
41use crate::response::Response;
42use crate::security::random_token;
43use async_trait::async_trait;
44use serde::de::DeserializeOwned;
45use serde::Serialize;
46use serde_json::{Map, Value};
47use std::collections::HashMap;
48use std::sync::{Arc, Mutex, MutexGuard};
49use std::time::{SystemTime, UNIX_EPOCH};
50
51const FLASH_KEY: &str = "_flash";
52const CSRF_KEY: &str = "_csrf";
53
54/// What a store keeps per session
55#[derive(Debug, Clone, Default)]
56pub struct SessionRecord {
57    pub data: Map<String, Value>,
58    pub created_at: u64,
59    pub last_seen: u64,
60}
61
62/// Where session data lives. Implement it for Redis, a database, ...
63#[async_trait]
64pub trait SessionStore: Send + Sync + 'static {
65    async fn load(&self, id: &str) -> Option<SessionRecord>;
66    async fn save(&self, id: &str, record: &SessionRecord);
67    async fn delete(&self, id: &str);
68}
69
70/// In-process store with a size cap; sessions are lost on restart and not
71/// shared between instances
72pub struct MemoryStore {
73    sessions: Mutex<HashMap<String, SessionRecord>>,
74    max_sessions: usize,
75}
76
77impl MemoryStore {
78    /// At most 100 000 sessions
79    pub fn new() -> Self {
80        Self::with_capacity(100_000)
81    }
82
83    /// When full, the least recently seen sessions are dropped to make room
84    pub fn with_capacity(max_sessions: usize) -> Self {
85        Self {
86            sessions: Mutex::new(HashMap::new()),
87            max_sessions: max_sessions.max(1),
88        }
89    }
90
91    pub fn len(&self) -> usize {
92        self.lock().len()
93    }
94
95    pub fn is_empty(&self) -> bool {
96        self.len() == 0
97    }
98
99    fn lock(&self) -> MutexGuard<'_, HashMap<String, SessionRecord>> {
100        self.sessions.lock().unwrap_or_else(|e| e.into_inner())
101    }
102}
103
104impl Default for MemoryStore {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110#[async_trait]
111impl SessionStore for MemoryStore {
112    async fn load(&self, id: &str) -> Option<SessionRecord> {
113        self.lock().get(id).cloned()
114    }
115
116    async fn save(&self, id: &str, record: &SessionRecord) {
117        let mut map = self.lock();
118        if map.len() >= self.max_sessions && !map.contains_key(id) {
119            // evict the oldest tenth (at least one) by last use
120            let mut by_age: Vec<(u64, String)> =
121                map.iter().map(|(k, v)| (v.last_seen, k.clone())).collect();
122            by_age.sort();
123            for (_, key) in by_age.into_iter().take((self.max_sessions / 10).max(1)) {
124                map.remove(&key);
125            }
126        }
127        map.insert(id.to_string(), record.clone());
128    }
129
130    async fn delete(&self, id: &str) {
131        self.lock().remove(id);
132    }
133}
134
135#[derive(Default)]
136struct Inner {
137    /// Id the request arrived with (a valid, unexpired session), if any
138    id: Option<String>,
139    data: Map<String, Value>,
140    created_at: u64,
141    last_seen: u64,
142    dirty: bool,
143    renew: bool,
144    destroy: bool,
145}
146
147/// The session of the current request; cheap to clone
148#[derive(Clone)]
149pub struct Session {
150    inner: Arc<Mutex<Inner>>,
151}
152
153impl Session {
154    fn lock(&self) -> MutexGuard<'_, Inner> {
155        self.inner.lock().unwrap_or_else(|e| e.into_inner())
156    }
157
158    /// A value, if present and of the requested type
159    pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
160        let inner = self.lock();
161        inner
162            .data
163            .get(key)
164            .and_then(|v| serde_json::from_value(v.clone()).ok())
165    }
166
167    pub fn contains(&self, key: &str) -> bool {
168        self.lock().data.contains_key(key)
169    }
170
171    /// Store a value; creates the session (and its cookie) if needed
172    pub fn set<T: Serialize>(&self, key: &str, value: T) -> Result<()> {
173        let value = serde_json::to_value(value)
174            .map_err(|e| Error::internal(format!("Session value: {}", e)))?;
175        let mut inner = self.lock();
176        inner.data.insert(key.to_string(), value);
177        inner.dirty = true;
178        Ok(())
179    }
180
181    pub fn remove(&self, key: &str) {
182        let mut inner = self.lock();
183        if inner.data.remove(key).is_some() {
184            inner.dirty = true;
185        }
186    }
187
188    /// Drop all data but keep the session
189    pub fn clear(&self) {
190        let mut inner = self.lock();
191        inner.data.clear();
192        inner.dirty = true;
193    }
194
195    /// Give the session a fresh id, keeping its data. Call it whenever the
196    /// privilege level changes (login, logout, role change) so that an id an
197    /// attacker planted or learned earlier becomes useless.
198    pub fn renew(&self) {
199        let mut inner = self.lock();
200        inner.renew = true;
201        inner.dirty = true;
202    }
203
204    /// Delete the session and its cookie (logout)
205    pub fn destroy(&self) {
206        let mut inner = self.lock();
207        inner.destroy = true;
208        inner.data.clear();
209    }
210
211    /// Queue a message for the next page (survives one redirect)
212    pub fn flash(&self, message: impl Into<String>) -> Result<()> {
213        let mut inner = self.lock();
214        let list = inner
215            .data
216            .entry(FLASH_KEY.to_string())
217            .or_insert_with(|| Value::Array(Vec::new()));
218        if let Value::Array(list) = list {
219            list.push(Value::String(message.into()));
220        }
221        inner.dirty = true;
222        Ok(())
223    }
224
225    /// Messages queued with [`flash`](Session::flash); each is returned once
226    pub fn take_flash(&self) -> Vec<String> {
227        let mut inner = self.lock();
228        match inner.data.remove(FLASH_KEY) {
229            Some(Value::Array(list)) => {
230                inner.dirty = true;
231                list.into_iter()
232                    .filter_map(|v| v.as_str().map(str::to_owned))
233                    .collect()
234            }
235            _ => Vec::new(),
236        }
237    }
238
239    /// The CSRF token of this session, created on first use. Rotates with
240    /// [`renew`](Session::renew)'s data like any other value. See [`crate::csrf`].
241    pub fn csrf_token(&self) -> Result<String> {
242        if let Some(t) = self.get::<String>(CSRF_KEY) {
243            return Ok(t);
244        }
245        let token = random_token(32)?;
246        self.set(CSRF_KEY, &token)?;
247        Ok(token)
248    }
249
250    /// True when this request belongs to a stored session
251    pub fn is_established(&self) -> bool {
252        self.lock().id.is_some()
253    }
254}
255
256impl Request {
257    /// The session of this request (needs the [`SessionLayer`] middleware;
258    /// answers `500` otherwise)
259    pub fn session(&self) -> Result<&Session> {
260        self.extensions
261            .get::<Session>()
262            .ok_or_else(|| Error::internal("SessionLayer middleware is not installed"))
263    }
264}
265
266/// Session middleware
267pub struct SessionLayer {
268    store: Arc<dyn SessionStore>,
269    cookie_name: String,
270    secure: bool,
271    idle_ttl: u64,
272    absolute_ttl: u64,
273    clock: Arc<dyn Fn() -> u64 + Send + Sync>,
274}
275
276impl SessionLayer {
277    /// In-memory store, `__Host-sid` cookie, 30 min idle / 12 h absolute
278    pub fn new() -> Self {
279        Self::with_store(MemoryStore::new())
280    }
281
282    pub fn with_store(store: impl SessionStore) -> Self {
283        Self {
284            store: Arc::new(store),
285            cookie_name: "__Host-sid".into(),
286            secure: true,
287            idle_ttl: 30 * 60,
288            absolute_ttl: 12 * 3600,
289            clock: Arc::new(|| {
290                SystemTime::now()
291                    .duration_since(UNIX_EPOCH)
292                    .map(|d| d.as_secs())
293                    .unwrap_or(0)
294            }),
295        }
296    }
297
298    /// Cookie name (default `__Host-sid`, only valid together with `Secure`)
299    pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
300        self.cookie_name = name.into();
301        self
302    }
303
304    /// For plain-HTTP development: no `Secure` flag and the cookie name `sid`
305    /// (the `__Host-` prefix is rejected by browsers without `Secure`)
306    pub fn insecure_cookies(mut self) -> Self {
307        self.secure = false;
308        if self.cookie_name.starts_with("__Host-") {
309            self.cookie_name = "sid".into();
310        }
311        self
312    }
313
314    /// A session ends after this many seconds without a request
315    pub fn idle_timeout(mut self, secs: u64) -> Self {
316        self.idle_ttl = secs.max(1);
317        self
318    }
319
320    /// A session ends this many seconds after it was created, however active
321    pub fn absolute_timeout(mut self, secs: u64) -> Self {
322        self.absolute_ttl = secs.max(1);
323        self
324    }
325
326    /// Replace the time source (tests)
327    pub fn clock(mut self, clock: impl Fn() -> u64 + Send + Sync + 'static) -> Self {
328        self.clock = Arc::new(clock);
329        self
330    }
331
332    fn cookie(&self, value: &str, max_age: i64) -> Cookie {
333        let c = Cookie::new(&self.cookie_name, value)
334            .path("/")
335            .max_age(max_age)
336            .http_only()
337            .same_site(SameSite::Lax);
338        if self.secure {
339            c.secure()
340        } else {
341            c
342        }
343    }
344}
345
346impl Default for SessionLayer {
347    fn default() -> Self {
348        Self::new()
349    }
350}
351
352fn valid_id(id: &str) -> bool {
353    id.len() == 64
354        && id
355            .bytes()
356            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
357}
358
359#[async_trait]
360impl Middleware for SessionLayer {
361    async fn handle(&self, mut req: Request, next: Next) -> Result<Response> {
362        let now = (self.clock)();
363        let presented = req
364            .cookies()
365            .get(&self.cookie_name)
366            .filter(|id| valid_id(id))
367            .map(str::to_owned);
368
369        let mut inner = Inner {
370            created_at: now,
371            ..Inner::default()
372        };
373        let mut expired_cookie = false;
374        if let Some(id) = presented {
375            match self.store.load(&id).await {
376                Some(rec)
377                    if now.saturating_sub(rec.last_seen) < self.idle_ttl
378                        && now.saturating_sub(rec.created_at) < self.absolute_ttl =>
379                {
380                    inner.id = Some(id);
381                    inner.data = rec.data;
382                    inner.created_at = rec.created_at;
383                    inner.last_seen = rec.last_seen;
384                }
385                Some(_) => {
386                    self.store.delete(&id).await;
387                    expired_cookie = true;
388                }
389                None => expired_cookie = true, // unknown id: forget the cookie
390            }
391        }
392        let session = Session {
393            inner: Arc::new(Mutex::new(inner)),
394        };
395        req.extensions.insert(session.clone());
396
397        let mut res = match next.run(req).await {
398            Ok(res) => res,
399            Err(err) => Response::from_error(err),
400        };
401
402        let (old_id, data, created_at, last_seen, dirty, renew, destroy) = {
403            let i = session.lock();
404            (
405                i.id.clone(),
406                i.data.clone(),
407                i.created_at,
408                i.last_seen,
409                i.dirty,
410                i.renew,
411                i.destroy,
412            )
413        };
414
415        if destroy {
416            if let Some(id) = &old_id {
417                self.store.delete(id).await;
418            }
419            if old_id.is_some() || expired_cookie {
420                res = res.cookie(self.cookie("", 0));
421            }
422            return Ok(res);
423        }
424
425        match (old_id, dirty) {
426            // changed (or created): persist, under a new id when renewed or new
427            (old, true) => {
428                let id = match (&old, renew) {
429                    (Some(old), false) => old.clone(),
430                    (old, _) => {
431                        if let Some(old) = old {
432                            self.store.delete(old).await;
433                        }
434                        random_token(32)?
435                    }
436                };
437                self.store
438                    .save(
439                        &id,
440                        &SessionRecord {
441                            data,
442                            // a renewed session starts a fresh absolute lifetime
443                            created_at: if renew { now } else { created_at },
444                            last_seen: now,
445                        },
446                    )
447                    .await;
448                let remaining = self
449                    .absolute_ttl
450                    .saturating_sub(if renew {
451                        0
452                    } else {
453                        now.saturating_sub(created_at)
454                    })
455                    .min(self.idle_ttl);
456                res = res.cookie(self.cookie(&id, remaining as i64));
457            }
458            // unchanged but alive: keep the idle timer running
459            (Some(id), false) => {
460                if now.saturating_sub(last_seen) >= self.idle_ttl / 4 {
461                    self.store
462                        .save(
463                            &id,
464                            &SessionRecord {
465                                data,
466                                created_at,
467                                last_seen: now,
468                            },
469                        )
470                        .await;
471                    let remaining = self
472                        .absolute_ttl
473                        .saturating_sub(now.saturating_sub(created_at))
474                        .min(self.idle_ttl);
475                    res = res.cookie(self.cookie(&id, remaining as i64));
476                }
477            }
478            (None, false) => {
479                if expired_cookie {
480                    res = res.cookie(self.cookie("", 0));
481                }
482            }
483        }
484        Ok(res)
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn id_format() {
494        assert!(valid_id(&"a1".repeat(32)));
495        assert!(!valid_id(&"A1".repeat(32)));
496        assert!(!valid_id(&"a1".repeat(31)));
497        assert!(!valid_id(""));
498        assert!(!valid_id(&format!("{}; Path=/", "a".repeat(40))));
499    }
500
501    #[tokio::test]
502    async fn memory_store_evicts_the_least_recently_seen() {
503        let store = MemoryStore::with_capacity(3);
504        for (id, seen) in [("a", 10), ("b", 20), ("c", 30)] {
505            store
506                .save(
507                    id,
508                    &SessionRecord {
509                        last_seen: seen,
510                        ..Default::default()
511                    },
512                )
513                .await;
514        }
515        store
516            .save(
517                "d",
518                &SessionRecord {
519                    last_seen: 40,
520                    ..Default::default()
521                },
522            )
523            .await;
524        assert_eq!(store.len(), 3);
525        assert!(store.load("a").await.is_none());
526        assert!(store.load("d").await.is_some());
527        // overwriting an existing id never evicts
528        store
529            .save(
530                "d",
531                &SessionRecord {
532                    last_seen: 50,
533                    ..Default::default()
534                },
535            )
536            .await;
537        assert_eq!(store.len(), 3);
538        store.delete("b").await;
539        assert_eq!(store.len(), 2);
540    }
541
542    #[test]
543    fn session_values_and_flash() {
544        let s = Session {
545            inner: Arc::new(Mutex::new(Inner::default())),
546        };
547        assert!(s.get::<String>("k").is_none());
548        s.set("k", "v").unwrap();
549        s.set("n", 5u32).unwrap();
550        assert_eq!(s.get::<String>("k").as_deref(), Some("v"));
551        assert_eq!(s.get::<u32>("n"), Some(5));
552        assert_eq!(s.get::<u32>("k"), None); // wrong type
553        s.remove("n");
554        assert!(!s.contains("n"));
555
556        s.flash("one").unwrap();
557        s.flash("two").unwrap();
558        assert_eq!(s.take_flash(), ["one", "two"]);
559        assert!(s.take_flash().is_empty());
560
561        let t1 = s.csrf_token().unwrap();
562        assert_eq!(t1.len(), 64);
563        assert_eq!(s.csrf_token().unwrap(), t1);
564        s.clear();
565        assert_ne!(s.csrf_token().unwrap(), t1);
566    }
567}