Skip to main content

rainier_session/
lib.rs

1//! # rainier-session
2//!
3//! Sessions: the [`Session`] bag a handler reads and writes, the
4//! [`SessionStore`] port it is persisted through, and the [`StartSession`]
5//! middleware that ties them to a cookie.
6//!
7//! ```
8//! use rainier_session::{MemorySessionStore, SessionRequestExt, StartSession, SessionStore};
9//! use rainier_http::{Request, Response};
10//! use rainier_middleware::{Middleware, Pipeline};
11//! use std::sync::Arc;
12//!
13//! # #[tokio::main] async fn main() {
14//! let store = Arc::new(MemorySessionStore::default());
15//! let start: Arc<dyn Middleware> =
16//!     Arc::new(StartSession::new(Arc::clone(&store) as Arc<dyn SessionStore>));
17//!
18//! let response = Pipeline::new()
19//!     .through_arc(start)
20//!     .then(|request: Request| async move {
21//!         let session = request.session().expect("StartSession ran");
22//!         session.put("user_id", 42u64).unwrap();
23//!         Response::text("ok")
24//!     })
25//!     .run(Request::builder().build())
26//!     .await;
27//!
28//! assert!(response.header("set-cookie").is_some());
29//! # }
30//! ```
31//!
32//! ## The session is on the request, not on a facade
33//!
34//! A session *facade* can work in PHP because the container rebinds a
35//! request-scoped session per request. Rainier's facades resolve from a
36//! **process-global** application, so there is nothing honest for
37//! `Session::instance().get(…)` to return — with two requests in flight, which
38//! one would it be?
39//!
40//! So the split is explicit:
41//!
42//! | | |
43//! |---|---|
44//! | [`request.session()`](SessionRequestExt::session) | this request's bag |
45//! | [`SessionManager`] (the `Session` facade) | the store and its settings |
46//!
47//! The facade is for what genuinely is application-wide: reading or destroying
48//! a session by id, and collecting expired ones.
49//!
50//! ## Flash data
51//!
52//! [`flash`](Session::flash) stores a value for exactly one further request —
53//! the redirect-then-show-a-message pattern, with nothing to remember to clean
54//! up:
55//!
56//! ```
57//! # use rainier_session::{Session, SessionData};
58//! let session = Session::new();
59//! session.flash("status", "Saved.").unwrap();
60//!
61//! // …the request ends, and the next one can read it.
62//! let next = Session::restore("id", session.age_and_take());
63//! assert_eq!(next.string("status").as_deref(), Some("Saved."));
64//!
65//! // …and the one after that cannot.
66//! let later = Session::restore("id", next.age_and_take());
67//! assert!(!later.has("status"));
68//! ```
69//!
70//! ## Drivers
71//!
72//! | Driver | Survives a restart | Shared | Revocable | Note |
73//! |---|---|---|---|---|
74//! | [`MemorySessionStore`] | no | no | yes | development |
75//! | [`DatabaseSessionStore`] | yes | yes | yes | never evicts |
76//! | [`CacheSessionStore`] | depends | yes | yes | expires itself; **can evict** |
77//! | [`CookieSessionStore`] | yes | yes | **no** | no server state at all |
78//!
79//! A memory store behind a load balancer makes a user's session appear to
80//! vanish and reappear as they are routed around, which looks like a bug in
81//! your login code and is not.
82//!
83//! The cookie driver is the odd one: the whole session travels encrypted in the
84//! cookie, so there is nothing to store and nothing to expire — and nothing to
85//! revoke either. A session you cannot see is a session you cannot end.
86
87#![deny(missing_docs)]
88#![deny(rustdoc::broken_intra_doc_links)]
89
90pub mod cache;
91pub mod cookie;
92pub mod database;
93pub mod driver;
94pub mod manager;
95pub mod middleware;
96pub mod session;
97pub mod store;
98
99pub use cache::CacheSessionStore;
100pub use cookie::CookieSessionStore;
101pub use database::{DatabaseSessionStore, SessionRow};
102pub use driver::SessionDriver;
103pub use manager::{SessionConfig, SessionManager};
104pub use middleware::{SessionRequestExt, StartSession};
105pub use session::{generate_session_id, Session, SessionData, TOKEN_KEY};
106pub use store::{is_well_formed, MemorySessionStore, SessionStore};
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use rainier_http::{Request, Response};
112    use rainier_middleware::{Middleware, Pipeline};
113    use std::sync::Arc;
114
115    /// The login flow end to end: a session is regenerated on authentication,
116    /// the old row goes, and the new one carries the user.
117    #[tokio::test]
118    async fn logging_in_rotates_the_session_and_keeps_the_user() {
119        let store = Arc::new(MemorySessionStore::default());
120        let start: Arc<dyn Middleware> =
121            Arc::new(StartSession::new(Arc::clone(&store) as Arc<dyn SessionStore>));
122
123        let run = |cookie: Option<String>, action: &'static str| {
124            let start = Arc::clone(&start);
125            async move {
126                let mut builder = Request::builder();
127                if let Some(cookie) = cookie {
128                    builder = builder.header("cookie", &cookie);
129                }
130                Pipeline::new()
131                    .through_arc(start)
132                    .then(move |request: Request| async move {
133                        let session = request.session().expect("a session");
134                        match action {
135                            "visit" => {
136                                session.put("cart", vec![1u64, 2]).unwrap();
137                            }
138                            "login" => {
139                                session.regenerate();
140                                session.put("user_id", 7u64).unwrap();
141                            }
142                            _ => {}
143                        }
144                        Response::text("ok")
145                    })
146                    .run(builder.build())
147                    .await
148            }
149        };
150
151        let cookie_of = |response: &Response| {
152            response
153                .header("set-cookie")
154                .and_then(|h| h.split(';').next())
155                .and_then(|c| c.strip_prefix("rainier_session="))
156                .map(str::to_string)
157        };
158
159        // An anonymous visit that puts something in the cart.
160        let first = run(None, "visit").await;
161        let anonymous = cookie_of(&first).expect("a cookie");
162
163        // Then they log in.
164        let second = run(Some(format!("rainier_session={anonymous}")), "login").await;
165        let authenticated = cookie_of(&second).expect("a new cookie");
166
167        assert_ne!(authenticated, anonymous, "the id must change on login");
168        assert!(
169            store.read(&anonymous).await.unwrap().is_none(),
170            "the pre-login row is what a fixation attack holds"
171        );
172
173        let data = store.read(&authenticated).await.unwrap().expect("the new session");
174        assert_eq!(data.values["user_id"], 7);
175        assert_eq!(data.values["cart"], serde_json::json!([1, 2]), "the cart survives login");
176    }
177}
178
179#[cfg(test)]
180mod driver_tests {
181    use super::*;
182    use rainier_cache::MemoryCache;
183    use rainier_crypt::{Encryption, Key, KeyRing};
184    use rainier_http::{Request, Response};
185    use rainier_middleware::{Middleware, Pipeline};
186    use std::sync::Arc;
187
188    /// Drive a store through the real middleware twice, following the cookie.
189    ///
190    /// Every driver has to behave the same from the outside, whatever it does
191    /// underneath — which is the point of the port and is not obvious for the
192    /// cookie one, where "underneath" is the cookie itself.
193    async fn counts_across_requests(store: Arc<dyn SessionStore>) -> (u64, u64) {
194        let start: Arc<dyn Middleware> = Arc::new(StartSession::new(store));
195
196        let visit = |cookie: Option<String>| {
197            let start = Arc::clone(&start);
198            async move {
199                let mut builder = Request::builder();
200                if let Some(cookie) = cookie {
201                    builder = builder.header("cookie", &cookie);
202                }
203
204                let response = Pipeline::new()
205                    .through_arc(start)
206                    .then(|request: Request| async move {
207                        let session = request.session().expect("a session");
208                        let seen: u64 = session.get("seen").unwrap_or(0);
209                        session.put("seen", seen + 1).unwrap();
210                        Response::text(seen.to_string())
211                    })
212                    .run(builder.build())
213                    .await;
214
215                let cookie = response
216                    .header("set-cookie")
217                    .and_then(|h| h.split(';').next())
218                    .and_then(|c| c.strip_prefix("rainier_session="))
219                    .map(str::to_string);
220
221                let body = response.into_http().into_body().collect().await.unwrap();
222                (String::from_utf8(body.to_vec()).unwrap().parse::<u64>().unwrap(), cookie)
223            }
224        };
225
226        let (first, cookie) = visit(None).await;
227        let cookie = cookie.expect("the session should be persisted");
228        let (second, _) = visit(Some(format!("rainier_session={cookie}"))).await;
229
230        (first, second)
231    }
232
233    fn crypt() -> Encryption {
234        Encryption::from_keys(KeyRing::new(Key::generate()))
235    }
236
237    #[tokio::test]
238    async fn every_driver_persists_a_session_across_requests() {
239        let drivers: Vec<(&str, Arc<dyn SessionStore>)> = vec![
240            ("memory", Arc::new(MemorySessionStore::default())),
241            ("cache", Arc::new(CacheSessionStore::new(Arc::new(MemoryCache::new())))),
242            ("cookie", Arc::new(CookieSessionStore::new(crypt()))),
243        ];
244
245        for (name, store) in drivers {
246            assert_eq!(counts_across_requests(store).await, (0, 1), "{name}");
247        }
248    }
249
250    #[tokio::test]
251    async fn the_cookie_driver_carries_the_session_and_not_an_id() {
252        // The distinguishing property: the cookie is long, because it *is* the
253        // session rather than a pointer to one.
254        let store: Arc<dyn SessionStore> = Arc::new(CookieSessionStore::new(crypt()));
255        let start: Arc<dyn Middleware> = Arc::new(StartSession::new(Arc::clone(&store)));
256
257        let response = Pipeline::new()
258            .through_arc(start)
259            .then(|request: Request| async move {
260                request.session().unwrap().put("user_id", 42u64).unwrap();
261                Response::text("ok")
262            })
263            .run(Request::builder().build())
264            .await;
265
266        let value = response
267            .header("set-cookie")
268            .and_then(|h| h.split(';').next())
269            .and_then(|c| c.strip_prefix("rainier_session="))
270            .expect("a cookie");
271
272        assert!(value.len() > 64, "a cookie session is longer than an id: {}", value.len());
273        assert!(!is_well_formed(value), "it is not an id at all");
274
275        // The key name, not the value: a two-character value like "42" turns up
276        // in base64 ciphertext by chance often enough to make that assertion
277        // flaky. A seven-character one effectively does not.
278        assert!(!value.contains("user_id"), "the client cannot read it: {value}");
279    }
280
281    #[tokio::test]
282    async fn a_cookie_session_from_another_applications_key_is_discarded() {
283        let store: Arc<dyn SessionStore> = Arc::new(CookieSessionStore::new(crypt()));
284        let theirs = CookieSessionStore::new(crypt());
285
286        let forged = theirs.encode(&generate_session_id(), &SessionData::default()).unwrap();
287        let start: Arc<dyn Middleware> = Arc::new(StartSession::new(store));
288
289        let response = Pipeline::new()
290            .through_arc(start)
291            .then(|request: Request| async move {
292                let session = request.session().expect("a fresh session");
293                Response::text(session.is_empty().to_string())
294            })
295            .run(Request::builder().header("cookie", &format!("rainier_session={forged}")).build())
296            .await;
297
298        let body = response.into_http().into_body().collect().await.unwrap();
299        assert_eq!(body, "true", "a session we did not write must not be trusted");
300    }
301}