Skip to main content

rainier_session/
store.rs

1//! Where sessions live — the [`SessionStore`] port and its implementations.
2
3use std::collections::HashMap;
4use std::sync::Mutex;
5
6use chrono::{DateTime, Duration, Utc};
7use rainier_support::{BoxFuture, Error, Result};
8
9use crate::session::SessionData;
10
11/// Where session data is kept between requests.
12///
13/// The four required methods are the server-side shape: an id in the cookie,
14/// the data somewhere else. [`encode`](Self::encode) and
15/// [`decode`](Self::decode) exist so a store can instead put the **whole
16/// session in the cookie** — see
17/// [`CookieSessionStore`](crate::CookieSessionStore) — without the middleware
18/// needing to know which kind it has.
19pub trait SessionStore: Send + Sync + 'static {
20    /// A label for diagnostics — `"memory"`, `"database"`, `"cache"`,
21    /// `"cookie"`.
22    fn name(&self) -> &str;
23
24    /// The data for this id, or `None` if it is unknown or expired.
25    fn read<'a>(&'a self, id: &'a str) -> BoxFuture<'a, Result<Option<SessionData>>>;
26
27    /// Write the data back, refreshing its expiry.
28    fn write<'a>(&'a self, id: &'a str, data: &'a SessionData) -> BoxFuture<'a, Result<()>>;
29
30    /// Remove a session.
31    fn destroy<'a>(&'a self, id: &'a str) -> BoxFuture<'a, Result<()>>;
32
33    /// Discard every expired session. Called periodically, not per request.
34    fn gc(&self) -> BoxFuture<'_, Result<u64>> {
35        Box::pin(async { Ok(0) })
36    }
37
38    /// Whether the session travels in the cookie rather than being stored here.
39    ///
40    /// A client-side store's [`read`](Self::read) and [`write`](Self::write) are
41    /// never called by the middleware.
42    fn is_client_side(&self) -> bool {
43        false
44    }
45
46    /// The cookie value carrying this session.
47    ///
48    /// Server-side stores return the id, which is the default.
49    fn encode(&self, id: &str, data: &SessionData) -> Result<String> {
50        let _ = data;
51        Ok(id.to_string())
52    }
53
54    /// Recover a session id — and, for a client-side store, its data — from a
55    /// cookie value.
56    ///
57    /// The default rejects anything that is not the shape
58    /// [`generate_session_id`](crate::generate_session_id) produces. A cookie
59    /// is client-supplied, so an id we did not mint is not worth a round trip,
60    /// and letting a client choose its own id is how session fixation starts.
61    fn decode(&self, value: &str) -> Result<(String, Option<SessionData>)> {
62        if is_well_formed(value) {
63            Ok((value.to_string(), None))
64        } else {
65            Err(Error::bad_request("that is not a session id this application issued"))
66        }
67    }
68}
69
70/// Whether an id looks like one [`generate_session_id`](crate::generate_session_id)
71/// produced: 64 hexadecimal characters.
72pub fn is_well_formed(id: &str) -> bool {
73    id.len() == 64 && id.bytes().all(|byte| byte.is_ascii_hexdigit())
74}
75
76/// Sessions held in this process's memory.
77///
78/// Right for development, tests, and a single instance. Two instances behind a
79/// load balancer will not see each other's sessions, so a user's session
80/// appears to vanish and reappear as they are routed around — which looks like
81/// a bug in your login code and is not.
82pub struct MemorySessionStore {
83    entries: Mutex<HashMap<String, Entry>>,
84    lifetime: Duration,
85}
86
87#[derive(Debug, Clone)]
88struct Entry {
89    data: SessionData,
90    expires_at: DateTime<Utc>,
91}
92
93impl Default for MemorySessionStore {
94    fn default() -> Self {
95        Self::new(Duration::hours(2))
96    }
97}
98
99impl MemorySessionStore {
100    /// Sessions expiring after `lifetime`.
101    pub fn new(lifetime: Duration) -> Self {
102        Self { entries: Mutex::new(HashMap::new()), lifetime }
103    }
104
105    /// How many sessions are held, expired ones included.
106    pub fn len(&self) -> usize {
107        self.entries.lock().unwrap_or_else(|e| e.into_inner()).len()
108    }
109
110    /// Whether no sessions are held.
111    pub fn is_empty(&self) -> bool {
112        self.len() == 0
113    }
114}
115
116impl SessionStore for MemorySessionStore {
117    fn name(&self) -> &str {
118        "memory"
119    }
120
121    fn read<'a>(&'a self, id: &'a str) -> BoxFuture<'a, Result<Option<SessionData>>> {
122        Box::pin(async move {
123            let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
124            match entries.get(id) {
125                Some(entry) if entry.expires_at > Utc::now() => Ok(Some(entry.data.clone())),
126                Some(_) => {
127                    // Expired: drop it now rather than leave it to accumulate.
128                    entries.remove(id);
129                    Ok(None)
130                }
131                None => Ok(None),
132            }
133        })
134    }
135
136    fn write<'a>(&'a self, id: &'a str, data: &'a SessionData) -> BoxFuture<'a, Result<()>> {
137        Box::pin(async move {
138            self.entries.lock().unwrap_or_else(|e| e.into_inner()).insert(
139                id.to_string(),
140                Entry { data: data.clone(), expires_at: Utc::now() + self.lifetime },
141            );
142            Ok(())
143        })
144    }
145
146    fn destroy<'a>(&'a self, id: &'a str) -> BoxFuture<'a, Result<()>> {
147        Box::pin(async move {
148            self.entries.lock().unwrap_or_else(|e| e.into_inner()).remove(id);
149            Ok(())
150        })
151    }
152
153    fn gc(&self) -> BoxFuture<'_, Result<u64>> {
154        Box::pin(async move {
155            let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
156            let now = Utc::now();
157            let before = entries.len();
158            entries.retain(|_, entry| entry.expires_at > now);
159            Ok((before - entries.len()) as u64)
160        })
161    }
162}
163
164impl std::fmt::Debug for MemorySessionStore {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("MemorySessionStore").field("sessions", &self.len()).finish()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn data(user: u64) -> SessionData {
175        let mut values = serde_json::Map::new();
176        values.insert("user_id".into(), user.into());
177        SessionData { values, flash: Vec::new() }
178    }
179
180    #[tokio::test]
181    async fn a_written_session_reads_back() {
182        let store = MemorySessionStore::default();
183        store.write("abc", &data(42)).await.unwrap();
184
185        let read = store.read("abc").await.unwrap().expect("present");
186        assert_eq!(read.values["user_id"], 42);
187    }
188
189    #[tokio::test]
190    async fn an_unknown_session_reads_as_none() {
191        assert!(MemorySessionStore::default().read("nope").await.unwrap().is_none());
192    }
193
194    #[tokio::test]
195    async fn destroying_a_session_removes_it() {
196        let store = MemorySessionStore::default();
197        store.write("abc", &data(42)).await.unwrap();
198
199        store.destroy("abc").await.unwrap();
200        assert!(store.read("abc").await.unwrap().is_none());
201    }
202
203    #[tokio::test]
204    async fn an_expired_session_reads_as_none_and_is_dropped() {
205        let store = MemorySessionStore::new(Duration::seconds(-1));
206        store.write("abc", &data(42)).await.unwrap();
207
208        assert!(store.read("abc").await.unwrap().is_none());
209        assert!(store.is_empty(), "reading an expired session should clean it up");
210    }
211
212    #[tokio::test]
213    async fn writing_again_refreshes_the_expiry() {
214        let store = MemorySessionStore::new(Duration::seconds(60));
215        store.write("abc", &data(42)).await.unwrap();
216        store.write("abc", &data(43)).await.unwrap();
217
218        assert_eq!(store.read("abc").await.unwrap().unwrap().values["user_id"], 43);
219    }
220
221    #[tokio::test]
222    async fn gc_removes_only_the_expired() {
223        let store = MemorySessionStore::new(Duration::seconds(-1));
224        store.write("a", &data(1)).await.unwrap();
225        store.write("b", &data(2)).await.unwrap();
226
227        assert_eq!(store.gc().await.unwrap(), 2);
228        assert!(store.is_empty());
229
230        let live = MemorySessionStore::default();
231        live.write("a", &data(1)).await.unwrap();
232        assert_eq!(live.gc().await.unwrap(), 0);
233        assert_eq!(live.len(), 1);
234    }
235
236    #[tokio::test]
237    async fn two_sessions_are_independent() {
238        let store = MemorySessionStore::default();
239        store.write("first", &data(42)).await.unwrap();
240        store.write("second", &data(42)).await.unwrap();
241
242        store.destroy("first").await.unwrap();
243
244        assert!(store.read("first").await.unwrap().is_none());
245        assert!(
246            store.read("second").await.unwrap().is_some(),
247            "logging out one device must not log out the others"
248        );
249    }
250}
251
252#[cfg(test)]
253mod port_tests {
254    use super::*;
255
256    #[test]
257    fn a_well_formed_id_is_sixty_four_hex_characters() {
258        assert!(is_well_formed(&crate::session::generate_session_id()));
259        assert!(is_well_formed(&"a".repeat(64)));
260        assert!(is_well_formed(&"F".repeat(64)));
261    }
262
263    #[test]
264    fn anything_else_is_not() {
265        for bad in ["", "short", &"z".repeat(64), &"a".repeat(63), &"a".repeat(65), "../../etc"] {
266            assert!(!is_well_formed(bad), "{bad:?}");
267        }
268    }
269
270    #[test]
271    fn the_default_encoding_is_the_id_itself() {
272        let store = MemorySessionStore::default();
273        let id = crate::session::generate_session_id();
274
275        assert_eq!(store.encode(&id, &SessionData::default()).unwrap(), id);
276        assert!(!store.is_client_side());
277    }
278
279    #[test]
280    fn the_default_decoding_refuses_a_client_chosen_id() {
281        // Session fixation begins with a client picking its own id.
282        let store = MemorySessionStore::default();
283
284        assert!(store.decode("chosen-by-the-client").is_err());
285
286        let id = crate::session::generate_session_id();
287        assert_eq!(store.decode(&id).unwrap(), (id, None));
288    }
289}