Skip to main content

rust_web_server/session/
mod.rs

1//! Server-side session management.
2//!
3//! [`SessionStore`] is a thread-safe, TTL-aware in-memory store. Store it
4//! inside your application state (`AppWithState<S>`) so every handler shares
5//! the same session map automatically.
6//!
7//! [`Session`] holds the key/value data for one session. Retrieve it with
8//! [`SessionStore::load`], mutate it, then persist changes with
9//! [`SessionStore::save`].
10//!
11//! Helper functions [`session_id_from_request`], [`session_cookie`], and
12//! [`destroy_cookie`] translate between the HTTP cookie layer and the store.
13//!
14//! # Security note
15//!
16//! Session IDs are generated from a non-cryptographic hash of the system
17//! clock and an atomic counter. Sufficient for most internal applications.
18//! For public-facing services requiring unpredictable IDs, supply your own
19//! CSPRNG via [`SessionStore::create_with_id`].
20//!
21//! # Example
22//!
23//! ```rust,no_run
24//! use rust_web_server::app::App;
25//! use rust_web_server::core::New;
26//! use rust_web_server::session::{self, SessionStore};
27//! use rust_web_server::header::Header;
28//! use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
29//!
30//! struct State { sessions: SessionStore }
31//!
32//! let app = App::with_state(State { sessions: SessionStore::new(3600) })
33//!     .post("/login", |req, _params, _conn, state| {
34//!         // verify credentials …
35//!         let mut sess = state.sessions.create();
36//!         sess.set("user_id", "42");
37//!         state.sessions.save(&sess);
38//!
39//!         let mut r = Response::new();
40//!         r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
41//!         r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
42//!         r.headers.push(Header {
43//!             name: "Set-Cookie".to_string(),
44//!             value: session::session_cookie(&sess.id, "sid", 3600),
45//!         });
46//!         r
47//!     })
48//!     .get("/profile", |req, _params, _conn, state| {
49//!         let mut r = Response::new();
50//!         let sid = match session::session_id_from_request(&req, "sid") {
51//!             Some(id) => id,
52//!             None => {
53//!                 r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
54//!                 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
55//!                 return r;
56//!             }
57//!         };
58//!         let sess = match state.sessions.load(&sid) {
59//!             Some(s) => s,
60//!             None => {
61//!                 r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
62//!                 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
63//!                 return r;
64//!             }
65//!         };
66//!         let user_id = sess.get("user_id").unwrap_or("guest");
67//!         r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
68//!         r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
69//!         r
70//!     });
71//! ```
72
73#[cfg(test)]
74mod tests;
75
76use std::collections::HashMap;
77use std::sync::atomic::{AtomicU64, Ordering};
78use std::sync::{Arc, Mutex};
79use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
80
81use crate::cookie::{CookieJar, SetCookie};
82use crate::request::Request;
83
84// ── ID generation ─────────────────────────────────────────────────────────────
85
86static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
87
88fn generate_id() -> String {
89    let nanos = SystemTime::now()
90        .duration_since(UNIX_EPOCH)
91        .map(|d| d.as_nanos() as u64)
92        .unwrap_or(0);
93    let count = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
94    // splitmix64 finalizer applied to two independent seeds
95    let mut x = nanos ^ count.wrapping_mul(0x9e3779b97f4a7c15);
96    x ^= x >> 30;
97    x = x.wrapping_mul(0xbf58476d1ce4e5b9);
98    x ^= x >> 27;
99    x = x.wrapping_mul(0x94d049bb133111eb);
100    x ^= x >> 31;
101    let mut y = count ^ nanos.wrapping_mul(0x517cc1b727220a95);
102    y ^= y >> 30;
103    y = y.wrapping_mul(0xbf58476d1ce4e5b9);
104    y ^= y >> 27;
105    y = y.wrapping_mul(0x94d049bb133111eb);
106    y ^= y >> 31;
107    format!("{:016x}{:016x}", x, y)
108}
109
110// ── Session ───────────────────────────────────────────────────────────────────
111
112/// Data for a single session, keyed by [`Session::id`].
113///
114/// After mutating the session, call [`SessionStore::save`] to persist the
115/// changes back to the store.
116pub struct Session {
117    /// Opaque session identifier — store this in the client cookie.
118    pub id: String,
119    pub(crate) data: HashMap<String, String>,
120}
121
122impl Session {
123    /// Return the value for `key`, or `None` if absent.
124    pub fn get(&self, key: &str) -> Option<&str> {
125        self.data.get(key).map(String::as_str)
126    }
127
128    /// Insert or update `key` with `value`.
129    pub fn set(&mut self, key: &str, value: impl Into<String>) {
130        self.data.insert(key.to_string(), value.into());
131    }
132
133    /// Remove `key`. No-op if absent.
134    pub fn remove(&mut self, key: &str) {
135        self.data.remove(key);
136    }
137
138    /// Return `true` if `key` is present.
139    pub fn contains(&self, key: &str) -> bool {
140        self.data.contains_key(key)
141    }
142}
143
144// ── SessionStore ──────────────────────────────────────────────────────────────
145
146struct Entry {
147    data: HashMap<String, String>,
148    expires_at: Instant,
149}
150
151struct Inner {
152    sessions: HashMap<String, Entry>,
153}
154
155/// Thread-safe in-memory session store with TTL-based expiry.
156///
157/// Cloning is cheap — all clones share the same backing map via `Arc`.
158/// Place one instance in your application state and share it across handlers.
159pub struct SessionStore {
160    inner: Arc<Mutex<Inner>>,
161    ttl: Duration,
162}
163
164impl Clone for SessionStore {
165    fn clone(&self) -> Self {
166        SessionStore { inner: Arc::clone(&self.inner), ttl: self.ttl }
167    }
168}
169
170impl SessionStore {
171    /// Create a new store where sessions expire `ttl_secs` seconds after
172    /// creation.
173    pub fn new(ttl_secs: u64) -> Self {
174        SessionStore {
175            inner: Arc::new(Mutex::new(Inner { sessions: HashMap::new() })),
176            ttl: Duration::from_secs(ttl_secs),
177        }
178    }
179
180    /// Create a new empty session with a generated ID, insert it into the
181    /// store, and return it. Mutate the session then call [`save`][Self::save].
182    pub fn create(&self) -> Session {
183        self.create_with_id(generate_id())
184    }
185
186    /// Create a new empty session using `id` (caller supplies the value,
187    /// e.g. from a CSPRNG). Inserts the session and returns it.
188    pub fn create_with_id(&self, id: String) -> Session {
189        let entry = Entry {
190            data: HashMap::new(),
191            expires_at: Instant::now() + self.ttl,
192        };
193        self.inner.lock().unwrap().sessions.insert(id.clone(), entry);
194        Session { id, data: HashMap::new() }
195    }
196
197    /// Load a session by ID. Returns `None` if unknown or expired.
198    pub fn load(&self, id: &str) -> Option<Session> {
199        let inner = self.inner.lock().unwrap();
200        let entry = inner.sessions.get(id)?;
201        if Instant::now() > entry.expires_at {
202            return None;
203        }
204        Some(Session { id: id.to_string(), data: entry.data.clone() })
205    }
206
207    /// Persist a session's data back to the store. No-op if the session ID
208    /// is no longer present (e.g. already destroyed or expired and purged).
209    pub fn save(&self, session: &Session) {
210        let mut inner = self.inner.lock().unwrap();
211        if let Some(entry) = inner.sessions.get_mut(&session.id) {
212            entry.data = session.data.clone();
213        }
214    }
215
216    /// Delete a session immediately. Also clear the client cookie using
217    /// [`destroy_cookie`].
218    pub fn destroy(&self, id: &str) {
219        self.inner.lock().unwrap().sessions.remove(id);
220    }
221
222    /// Remove all sessions whose TTL has elapsed. Call periodically to
223    /// reclaim memory (e.g. once per minute from a background thread).
224    pub fn purge_expired(&self) {
225        let now = Instant::now();
226        self.inner.lock().unwrap().sessions.retain(|_, e| e.expires_at > now);
227    }
228
229    /// Number of sessions in the store, including expired but not yet purged.
230    pub fn len(&self) -> usize {
231        self.inner.lock().unwrap().sessions.len()
232    }
233
234    /// `true` if the store contains no sessions.
235    pub fn is_empty(&self) -> bool {
236        self.len() == 0
237    }
238}
239
240// ── Cookie helpers ────────────────────────────────────────────────────────────
241
242/// Extract the session ID from the named cookie in a request's `Cookie`
243/// header. Returns `None` if the header is absent or the cookie is missing.
244pub fn session_id_from_request(request: &Request, cookie_name: &str) -> Option<String> {
245    let header = request.get_header("Cookie".to_string())?;
246    let jar = CookieJar::parse(&header.value);
247    jar.get(cookie_name).map(|c| c.value.clone())
248}
249
250/// Build a `Set-Cookie` header value that stores `session_id` in
251/// `cookie_name` with `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Max-Age`.
252///
253/// # Example
254///
255/// ```rust,no_run
256/// use rust_web_server::{session, header::Header};
257///
258/// let value = session::session_cookie("abc123", "sid", 3600);
259/// // response.headers.push(Header { name: "Set-Cookie".to_string(), value });
260/// ```
261pub fn session_cookie(session_id: &str, cookie_name: &str, ttl_secs: u64) -> String {
262    SetCookie::new(cookie_name, session_id)
263        .path("/")
264        .http_only()
265        .same_site("Lax")
266        .max_age(ttl_secs as i64)
267        .build()
268}
269
270/// Build a `Set-Cookie` header value that clears `cookie_name` in the
271/// browser (`Max-Age=0`). Use after calling [`SessionStore::destroy`].
272pub fn destroy_cookie(cookie_name: &str) -> String {
273    SetCookie::new(cookie_name, "").path("/").max_age(0).build()
274}