Skip to main content

rust_web_server/session/
mod.rs

1//! Server-side session management.
2//!
3//! Three session store implementations are provided:
4//!
5//! * [`SessionStore`] — in-process `HashMap`; fast, zero-config, but sessions
6//!   are lost on restart. Good for single-instance deployments.
7//! * [`DbSessionStore`] — backed by the model-layer [`DbPool`]; sessions
8//!   survive restarts and are shared across multiple processes that use the
9//!   same database. Requires a `model-sqlite`, `model-postgres`, or
10//!   `model-mysql` feature.
11//! * [`RedisSessionStore`] — backed by a Redis server via a hand-rolled RESP
12//!   client; scales horizontally with no shared database needed. Requires a
13//!   running Redis server.
14//!
15//! All three expose the same public API: `create`, `create_with_id`, `load`,
16//! `save`, `destroy`, `purge_expired`, `len`, `is_empty`.
17//!
18//! [`Session`] holds the key/value data for one session. Retrieve it with
19//! the store's `load`, mutate it, then persist changes with `save`.
20//!
21//! Helper functions [`session_id_from_request`], [`session_cookie`], and
22//! [`destroy_cookie`] translate between the HTTP cookie layer and the store.
23//!
24//! # Security note
25//!
26//! Session IDs are generated from a non-cryptographic hash of the system
27//! clock and an atomic counter. Sufficient for most internal applications.
28//! For public-facing services requiring unpredictable IDs, supply your own
29//! CSPRNG via [`SessionStore::create_with_id`].
30//!
31//! # Example
32//!
33//! ```rust,no_run
34//! use rust_web_server::app::App;
35//! use rust_web_server::core::New;
36//! use rust_web_server::session::{self, SessionStore};
37//! use rust_web_server::header::Header;
38//! use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
39//!
40//! struct State { sessions: SessionStore }
41//!
42//! let app = App::with_state(State { sessions: SessionStore::new(3600) })
43//!     .post("/login", |req, _params, _conn, state| {
44//!         // verify credentials …
45//!         let mut sess = state.sessions.create();
46//!         sess.set("user_id", "42");
47//!         state.sessions.save(&sess);
48//!
49//!         let mut r = Response::new();
50//!         r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
51//!         r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
52//!         r.headers.push(Header::new("Set-Cookie", session::session_cookie(&sess.id, "sid", 3600)));
53//!         r
54//!     })
55//!     .get("/profile", |req, _params, _conn, state| {
56//!         let mut r = Response::new();
57//!         let sid = match session::session_id_from_request(&req, "sid") {
58//!             Some(id) => id,
59//!             None => {
60//!                 r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
61//!                 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
62//!                 return r;
63//!             }
64//!         };
65//!         let sess = match state.sessions.load(&sid) {
66//!             Some(s) => s,
67//!             None => {
68//!                 r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
69//!                 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
70//!                 return r;
71//!             }
72//!         };
73//!         let user_id = sess.get("user_id").unwrap_or("guest");
74//!         r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
75//!         r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
76//!         r
77//!     });
78//! ```
79
80#[cfg(test)]
81mod tests;
82
83use std::collections::HashMap;
84use std::sync::atomic::{AtomicU64, Ordering};
85use std::sync::{Arc, Mutex};
86use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
87
88use crate::cookie::{CookieJar, SetCookie};
89use crate::request::Request;
90
91// ── ID generation ─────────────────────────────────────────────────────────────
92
93static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
94
95fn generate_id() -> String {
96    let nanos = SystemTime::now()
97        .duration_since(UNIX_EPOCH)
98        .map(|d| d.as_nanos() as u64)
99        .unwrap_or(0);
100    let count = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
101    // splitmix64 finalizer applied to two independent seeds
102    let mut x = nanos ^ count.wrapping_mul(0x9e3779b97f4a7c15);
103    x ^= x >> 30;
104    x = x.wrapping_mul(0xbf58476d1ce4e5b9);
105    x ^= x >> 27;
106    x = x.wrapping_mul(0x94d049bb133111eb);
107    x ^= x >> 31;
108    let mut y = count ^ nanos.wrapping_mul(0x517cc1b727220a95);
109    y ^= y >> 30;
110    y = y.wrapping_mul(0xbf58476d1ce4e5b9);
111    y ^= y >> 27;
112    y = y.wrapping_mul(0x94d049bb133111eb);
113    y ^= y >> 31;
114    format!("{:016x}{:016x}", x, y)
115}
116
117// ── Session ───────────────────────────────────────────────────────────────────
118
119/// Data for a single session, keyed by [`Session::id`].
120///
121/// After mutating the session, call [`SessionStore::save`] to persist the
122/// changes back to the store.
123pub struct Session {
124    /// Opaque session identifier — store this in the client cookie.
125    pub id: String,
126    pub(crate) data: HashMap<String, String>,
127}
128
129impl Session {
130    /// Return the value for `key`, or `None` if absent.
131    pub fn get(&self, key: &str) -> Option<&str> {
132        self.data.get(key).map(String::as_str)
133    }
134
135    /// Insert or update `key` with `value`.
136    pub fn set(&mut self, key: &str, value: impl Into<String>) {
137        self.data.insert(key.to_string(), value.into());
138    }
139
140    /// Remove `key`. No-op if absent.
141    pub fn remove(&mut self, key: &str) {
142        self.data.remove(key);
143    }
144
145    /// Return `true` if `key` is present.
146    pub fn contains(&self, key: &str) -> bool {
147        self.data.contains_key(key)
148    }
149}
150
151// ── SessionStore ──────────────────────────────────────────────────────────────
152
153struct Entry {
154    data: HashMap<String, String>,
155    expires_at: Instant,
156}
157
158struct Inner {
159    sessions: HashMap<String, Entry>,
160}
161
162/// Thread-safe in-memory session store with TTL-based expiry.
163///
164/// Cloning is cheap — all clones share the same backing map via `Arc`.
165/// Place one instance in your application state and share it across handlers.
166pub struct SessionStore {
167    inner: Arc<Mutex<Inner>>,
168    ttl: Duration,
169}
170
171impl Clone for SessionStore {
172    fn clone(&self) -> Self {
173        SessionStore { inner: Arc::clone(&self.inner), ttl: self.ttl }
174    }
175}
176
177impl SessionStore {
178    /// Create a new store where sessions expire `ttl_secs` seconds after
179    /// creation.
180    pub fn new(ttl_secs: u64) -> Self {
181        SessionStore {
182            inner: Arc::new(Mutex::new(Inner { sessions: HashMap::new() })),
183            ttl: Duration::from_secs(ttl_secs),
184        }
185    }
186
187    /// Create a new empty session with a generated ID, insert it into the
188    /// store, and return it. Mutate the session then call [`save`][Self::save].
189    pub fn create(&self) -> Session {
190        self.create_with_id(generate_id())
191    }
192
193    /// Create a new empty session using `id` (caller supplies the value,
194    /// e.g. from a CSPRNG). Inserts the session and returns it.
195    pub fn create_with_id(&self, id: String) -> Session {
196        let entry = Entry {
197            data: HashMap::new(),
198            expires_at: Instant::now() + self.ttl,
199        };
200        self.inner.lock().unwrap().sessions.insert(id.clone(), entry);
201        Session { id, data: HashMap::new() }
202    }
203
204    /// Load a session by ID. Returns `None` if unknown or expired.
205    pub fn load(&self, id: &str) -> Option<Session> {
206        let inner = self.inner.lock().unwrap();
207        let entry = inner.sessions.get(id)?;
208        if Instant::now() > entry.expires_at {
209            return None;
210        }
211        Some(Session { id: id.to_string(), data: entry.data.clone() })
212    }
213
214    /// Persist a session's data back to the store. No-op if the session ID
215    /// is no longer present (e.g. already destroyed or expired and purged).
216    pub fn save(&self, session: &Session) {
217        let mut inner = self.inner.lock().unwrap();
218        if let Some(entry) = inner.sessions.get_mut(&session.id) {
219            entry.data = session.data.clone();
220        }
221    }
222
223    /// Delete a session immediately. Also clear the client cookie using
224    /// [`destroy_cookie`].
225    pub fn destroy(&self, id: &str) {
226        self.inner.lock().unwrap().sessions.remove(id);
227    }
228
229    /// Remove all sessions whose TTL has elapsed. Call periodically to
230    /// reclaim memory (e.g. once per minute from a background thread).
231    pub fn purge_expired(&self) {
232        let now = Instant::now();
233        self.inner.lock().unwrap().sessions.retain(|_, e| e.expires_at > now);
234    }
235
236    /// Number of sessions in the store, including expired but not yet purged.
237    pub fn len(&self) -> usize {
238        self.inner.lock().unwrap().sessions.len()
239    }
240
241    /// `true` if the store contains no sessions.
242    pub fn is_empty(&self) -> bool {
243        self.len() == 0
244    }
245}
246
247// ── Cookie helpers ────────────────────────────────────────────────────────────
248
249/// Extract the session ID from the named cookie in a request's `Cookie`
250/// header. Returns `None` if the header is absent or the cookie is missing.
251pub fn session_id_from_request(request: &Request, cookie_name: &str) -> Option<String> {
252    let header = request.get_header("Cookie".to_string())?;
253    let jar = CookieJar::parse(&header.value);
254    jar.get(cookie_name).map(|c| c.value.clone())
255}
256
257/// Build a `Set-Cookie` header value that stores `session_id` in
258/// `cookie_name` with `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Max-Age`.
259///
260/// # Example
261///
262/// ```rust,no_run
263/// use rust_web_server::{session, header::Header};
264///
265/// let value = session::session_cookie("abc123", "sid", 3600);
266/// // response.headers.push(Header::new("Set-Cookie", value));
267/// ```
268pub fn session_cookie(session_id: &str, cookie_name: &str, ttl_secs: u64) -> String {
269    SetCookie::new(cookie_name, session_id)
270        .path("/")
271        .http_only()
272        .same_site("Lax")
273        .max_age(ttl_secs as i64)
274        .build()
275}
276
277/// Build a `Set-Cookie` header value that clears `cookie_name` in the
278/// browser (`Max-Age=0`). Use after calling [`SessionStore::destroy`].
279pub fn destroy_cookie(cookie_name: &str) -> String {
280    SetCookie::new(cookie_name, "").path("/").max_age(0).build()
281}
282
283// ── DbSessionStore ────────────────────────────────────────────────────────────
284
285/// Session store backed by a relational database via the model-layer
286/// [`DbPool`][crate::model::DbPool].
287///
288/// Sessions survive process restarts and are visible to every process that
289/// connects to the same database, making this suitable for multi-instance
290/// deployments and zero-downtime restarts.
291///
292/// The first call to [`DbSessionStore::new`] creates the `rws_sessions` table
293/// if it does not already exist:
294///
295/// ```sql
296/// CREATE TABLE IF NOT EXISTS rws_sessions (
297///     id         TEXT    PRIMARY KEY,
298///     data       TEXT    NOT NULL DEFAULT '',
299///     expires_at INTEGER NOT NULL
300/// )
301/// ```
302///
303/// Session data is serialized as a URL-encoded string
304/// (`key1=val1&key2=val2`). Expired sessions are **not** removed
305/// automatically — call [`purge_expired`][DbSessionStore::purge_expired]
306/// periodically.
307///
308/// All methods are `async fn` — `model-*` features imply the `http2` feature
309/// which provides a tokio runtime.
310///
311/// # Example
312///
313/// ```rust,no_run
314/// # #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
315/// # async fn example() -> Result<(), rust_web_server::model::DbError> {
316/// use rust_web_server::model::DbPool;
317/// use rust_web_server::session::DbSessionStore;
318///
319/// let pool = DbPool::memory().await?;
320/// let store = DbSessionStore::new(pool, 3600).await?;
321///
322/// let mut sess = store.create().await?;
323/// sess.set("user_id", "42");
324/// store.save(&sess).await?;
325///
326/// let loaded = store.load(&sess.id).await?.unwrap();
327/// assert_eq!(Some("42"), loaded.get("user_id"));
328/// # Ok(())
329/// # }
330/// ```
331#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
332pub struct DbSessionStore {
333    pool: Arc<crate::model::DbPool>,
334    ttl: Duration,
335}
336
337#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
338impl Clone for DbSessionStore {
339    fn clone(&self) -> Self {
340        DbSessionStore { pool: Arc::clone(&self.pool), ttl: self.ttl }
341    }
342}
343
344#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
345impl DbSessionStore {
346    /// Open (or reuse) a `DbSessionStore` backed by `pool`.
347    ///
348    /// Creates the `rws_sessions` table on the first call if it is absent.
349    /// Returns `Err` if the DDL fails.
350    pub async fn new(pool: crate::model::DbPool, ttl_secs: u64) -> Result<Self, crate::model::DbError> {
351        let store = DbSessionStore {
352            pool: Arc::new(pool),
353            ttl: Duration::from_secs(ttl_secs),
354        };
355        store.ensure_table().await?;
356        Ok(store)
357    }
358
359    async fn ensure_table(&self) -> Result<(), crate::model::DbError> {
360        self.pool.execute(
361            "CREATE TABLE IF NOT EXISTS rws_sessions \
362             (id TEXT PRIMARY KEY, data TEXT NOT NULL DEFAULT '', expires_at INTEGER NOT NULL)",
363            &[],
364        ).await?;
365        Ok(())
366    }
367
368    fn now_epoch() -> i64 {
369        SystemTime::now()
370            .duration_since(UNIX_EPOCH)
371            .map(|d| d.as_secs() as i64)
372            .unwrap_or(0)
373    }
374
375    fn serialize(data: &HashMap<String, String>) -> String {
376        crate::url::URL::build_query(data.clone())
377    }
378
379    fn deserialize(s: &str) -> HashMap<String, String> {
380        crate::url::URL::parse_query(s)
381    }
382
383    /// Create a new empty session and persist it immediately.
384    pub async fn create(&self) -> Result<Session, crate::model::DbError> {
385        self.create_with_id(generate_id()).await
386    }
387
388    /// Create a new empty session with a caller-supplied ID and persist it.
389    pub async fn create_with_id(&self, id: String) -> Result<Session, crate::model::DbError> {
390        let expires_at = Self::now_epoch() + self.ttl.as_secs() as i64;
391        self.pool.execute(
392            "INSERT INTO rws_sessions (id, data, expires_at) VALUES (?, ?, ?)",
393            &[
394                crate::model::Value::Text(id.clone()),
395                crate::model::Value::Text(String::new()),
396                crate::model::Value::Int(expires_at),
397            ],
398        ).await?;
399        Ok(Session { id, data: HashMap::new() })
400    }
401
402    /// Load a session by ID. Returns `None` if unknown or expired.
403    pub async fn load(&self, id: &str) -> Result<Option<Session>, crate::model::DbError> {
404        let now = Self::now_epoch();
405        let rows = self.pool.query_rows(
406            "SELECT data FROM rws_sessions WHERE id = ? AND expires_at > ?",
407            &[
408                crate::model::Value::Text(id.to_string()),
409                crate::model::Value::Int(now),
410            ],
411        ).await?;
412        if rows.is_empty() {
413            return Ok(None);
414        }
415        let raw: String = rows[0].get("data")?;
416        Ok(Some(Session { id: id.to_string(), data: Self::deserialize(&raw) }))
417    }
418
419    /// Persist a session's data back to the store.
420    pub async fn save(&self, session: &Session) -> Result<(), crate::model::DbError> {
421        self.pool.execute(
422            "UPDATE rws_sessions SET data = ? WHERE id = ?",
423            &[
424                crate::model::Value::Text(Self::serialize(&session.data)),
425                crate::model::Value::Text(session.id.clone()),
426            ],
427        ).await?;
428        Ok(())
429    }
430
431    /// Delete a session immediately.
432    pub async fn destroy(&self, id: &str) -> Result<(), crate::model::DbError> {
433        self.pool.execute(
434            "DELETE FROM rws_sessions WHERE id = ?",
435            &[crate::model::Value::Text(id.to_string())],
436        ).await?;
437        Ok(())
438    }
439
440    /// Delete all sessions whose TTL has elapsed.
441    pub async fn purge_expired(&self) -> Result<(), crate::model::DbError> {
442        let now = Self::now_epoch();
443        self.pool.execute(
444            "DELETE FROM rws_sessions WHERE expires_at <= ?",
445            &[crate::model::Value::Int(now)],
446        ).await?;
447        Ok(())
448    }
449
450    /// Total number of sessions in the store, including expired ones not yet purged.
451    pub async fn len(&self) -> Result<usize, crate::model::DbError> {
452        let rows = self.pool.query_rows("SELECT COUNT(*) AS n FROM rws_sessions", &[]).await?;
453        if rows.is_empty() {
454            return Ok(0);
455        }
456        let n: i64 = rows[0].get("n")?;
457        Ok(n as usize)
458    }
459
460    /// `true` if the store contains no sessions.
461    pub async fn is_empty(&self) -> Result<bool, crate::model::DbError> {
462        Ok(self.len().await? == 0)
463    }
464}
465
466// ── RedisSessionStore ─────────────────────────────────────────────────────────
467
468#[cfg(not(target_arch = "wasm32"))]
469use crate::redis_protocol::{RespConn, RespReply};
470
471/// Session store backed by a Redis server.
472///
473/// Sessions are stored as Redis strings keyed by `rws:sess:{id}` and given
474/// a Redis TTL via `SET … EX`. Expired sessions are removed automatically by
475/// Redis — no `purge_expired` sweep is needed.
476///
477/// Cloning is cheap — all clones share the same underlying TCP connection
478/// (one persistent connection per `RedisSessionStore` instance).
479///
480/// # Connection
481///
482/// Specify the server address as `host:port` (e.g. `"127.0.0.1:6379"`).
483/// Pass `Some("password")` for Redis servers that require AUTH.
484/// Use [`RedisSessionStore::from_env`] to read connection details from
485/// `RWS_REDIS_HOST`, `RWS_REDIS_PORT`, and `RWS_REDIS_PASSWORD`.
486///
487/// # Example
488///
489/// ```rust,no_run
490/// use rust_web_server::session::RedisSessionStore;
491///
492/// let store = RedisSessionStore::new("127.0.0.1:6379", None, 3600);
493///
494/// let mut sess = store.create().expect("create session");
495/// sess.set("user_id", "42");
496/// store.save(&sess).expect("save session");
497///
498/// let loaded = store.load(&sess.id).expect("load session").unwrap();
499/// assert_eq!(Some("42"), loaded.get("user_id"));
500/// ```
501#[cfg(not(target_arch = "wasm32"))]
502pub struct RedisSessionStore {
503    conn: Arc<RespConn>,
504    ttl: u64,
505}
506
507#[cfg(not(target_arch = "wasm32"))]
508impl Clone for RedisSessionStore {
509    fn clone(&self) -> Self {
510        RedisSessionStore { conn: Arc::clone(&self.conn), ttl: self.ttl }
511    }
512}
513
514#[cfg(not(target_arch = "wasm32"))]
515impl RedisSessionStore {
516    /// Create a store that connects to `addr` (e.g. `"127.0.0.1:6379"`).
517    /// `password` is passed to Redis `AUTH` if `Some`.
518    pub fn new(addr: impl Into<String>, password: Option<String>, ttl_secs: u64) -> Self {
519        RedisSessionStore {
520            conn: Arc::new(RespConn::new(addr, password)),
521            ttl: ttl_secs,
522        }
523    }
524
525    /// Build a store from environment variables:
526    /// - `RWS_REDIS_HOST` (default `127.0.0.1`)
527    /// - `RWS_REDIS_PORT` (default `6379`)
528    /// - `RWS_REDIS_PASSWORD` (optional)
529    /// - `RWS_REDIS_TTL_SECS` (default `3600`)
530    pub fn from_env() -> Self {
531        let host = std::env::var("RWS_REDIS_HOST").unwrap_or_else(|_| "127.0.0.1".into());
532        let port = std::env::var("RWS_REDIS_PORT").unwrap_or_else(|_| "6379".into());
533        let addr = format!("{}:{}", host, port);
534        let password = std::env::var("RWS_REDIS_PASSWORD").ok();
535        let ttl = std::env::var("RWS_REDIS_TTL_SECS")
536            .ok()
537            .and_then(|v| v.parse().ok())
538            .unwrap_or(3600u64);
539        Self::new(addr, password, ttl)
540    }
541
542    fn key(id: &str) -> Vec<u8> {
543        format!("rws:sess:{}", id).into_bytes()
544    }
545
546    fn serialize(data: &HashMap<String, String>) -> Vec<u8> {
547        crate::url::URL::build_query(data.clone()).into_bytes()
548    }
549
550    fn deserialize(bytes: Vec<u8>) -> HashMap<String, String> {
551        let s = String::from_utf8(bytes).unwrap_or_default();
552        crate::url::URL::parse_query(&s)
553    }
554
555    /// Create a new empty session and persist it to Redis.
556    pub fn create(&self) -> std::io::Result<Session> {
557        self.create_with_id(generate_id())
558    }
559
560    /// Create a new empty session with a caller-supplied ID and persist it.
561    pub fn create_with_id(&self, id: String) -> std::io::Result<Session> {
562        let ttl_str = self.ttl.to_string();
563        self.conn.cmd(&[
564            b"SET",
565            &Self::key(&id),
566            b"",
567            b"EX",
568            ttl_str.as_bytes(),
569        ])?;
570        Ok(Session { id, data: HashMap::new() })
571    }
572
573    /// Load a session by ID. Returns `None` if unknown or expired.
574    pub fn load(&self, id: &str) -> std::io::Result<Option<Session>> {
575        match self.conn.cmd(&[b"GET", &Self::key(id)])? {
576            RespReply::Bulk(Some(bytes)) => {
577                Ok(Some(Session { id: id.to_string(), data: Self::deserialize(bytes) }))
578            }
579            _ => Ok(None),
580        }
581    }
582
583    /// Persist a session's data back to Redis, resetting the TTL.
584    pub fn save(&self, session: &Session) -> std::io::Result<()> {
585        let ttl_str = self.ttl.to_string();
586        let data = Self::serialize(&session.data);
587        self.conn.cmd(&[
588            b"SET",
589            &Self::key(&session.id),
590            &data,
591            b"EX",
592            ttl_str.as_bytes(),
593        ])?;
594        Ok(())
595    }
596
597    /// Delete a session immediately.
598    pub fn destroy(&self, id: &str) -> std::io::Result<()> {
599        self.conn.cmd(&[b"DEL", &Self::key(id)])?;
600        Ok(())
601    }
602
603    /// No-op — Redis expiry removes sessions automatically.
604    pub fn purge_expired(&self) {}
605
606    /// Total number of keys in the Redis database.
607    ///
608    /// Uses `DBSIZE`, which counts *all* keys, not just session keys.
609    /// Useful as a rough indicator; not exact for mixed-use Redis instances.
610    pub fn len(&self) -> std::io::Result<usize> {
611        match self.conn.cmd(&[b"DBSIZE"])? {
612            RespReply::Int(n) => Ok(n as usize),
613            _ => Ok(0),
614        }
615    }
616
617    /// `true` if `DBSIZE` returns 0.
618    pub fn is_empty(&self) -> std::io::Result<bool> {
619        Ok(self.len()? == 0)
620    }
621}