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}