Skip to main content

rainier_session/
middleware.rs

1//! [`StartSession`] — the middleware that loads a session and writes it back.
2
3use std::sync::Arc;
4
5use rainier_http::{Cookie, Request, Response, SameSite};
6use rainier_middleware::{Middleware, Next};
7use rainier_support::Result;
8
9use crate::manager::{SessionConfig, SessionManager};
10use crate::session::Session;
11use crate::store::SessionStore;
12
13/// Loads the session before the handler and persists it afterwards.
14///
15/// A route without this middleware has no session, and
16/// [`request.session()`](crate::SessionRequestExt::session) is `None` there —
17/// which is the honest answer, and better than handing out a bag that silently
18/// fails to persist.
19pub struct StartSession {
20    store: Arc<dyn SessionStore>,
21    config: SessionConfig,
22}
23
24impl StartSession {
25    /// Start sessions in `store`, with the default cookie settings.
26    pub fn new(store: Arc<dyn SessionStore>) -> Self {
27        Self { store, config: SessionConfig::default() }
28    }
29
30    /// Start sessions with explicit cookie settings.
31    pub fn with_config(store: Arc<dyn SessionStore>, config: SessionConfig) -> Self {
32        Self { store, config }
33    }
34
35    /// Take the settings and the store from a manager — the usual way, since
36    /// the manager is what the container holds.
37    pub fn from_manager(manager: &SessionManager) -> Self {
38        Self::with_config(Arc::clone(manager.store()), manager.config().clone())
39    }
40
41    /// The cookie carrying this session.
42    ///
43    /// The **value** comes from the store, not from here: a server-side store
44    /// returns the id, and a client-side one returns the whole encrypted
45    /// session. Keeping that decision in the store is what lets the two kinds
46    /// share this middleware.
47    fn cookie(&self, value: &str) -> Cookie {
48        let config = &self.config;
49        let mut cookie = Cookie::new(&config.cookie, value)
50            .path(&config.path)
51            .http_only(true)
52            .secure(config.secure)
53            .same_site(config.same_site)
54            .max_age(config.lifetime.num_seconds());
55
56        if let Some(domain) = &config.domain {
57            cookie = cookie.domain(domain);
58        }
59        cookie
60    }
61
62    /// Load the session the request's cookie names, or start a fresh one.
63    async fn load(&self, request: &Request) -> Session {
64        let Some(value) = request.cookie(&self.config.cookie) else {
65            return Session::new();
66        };
67
68        // The store decides what a cookie value means, and whether it is one
69        // this application could have issued. A value it rejects gets a fresh
70        // session rather than an error: a client cannot be trusted to send a
71        // good one, and a bad one is not worth failing a page over.
72        let (id, carried) = match self.store.decode(value) {
73            Ok(decoded) => decoded,
74            Err(_) => return Session::new(),
75        };
76
77        // A client-side store has already produced the data; there is nothing
78        // to read.
79        if let Some(data) = carried {
80            return Session::restore(id, data);
81        }
82
83        match self.store.read(&id).await {
84            Ok(Some(data)) => Session::restore(id, data),
85            Ok(None) => Session::new(),
86            Err(e) => {
87                // The store being unreachable must not take the request with
88                // it: a visitor gets a fresh session and the page renders.
89                // Failing here would turn a cache blip into a total outage.
90                tracing::error!(error = %e, "could not read the session; starting a new one");
91                Session::new()
92            }
93        }
94    }
95
96    /// Write the session back and clean up any ids it superseded.
97    ///
98    /// Returns the cookie value to send, which for a client-side store *is* the
99    /// session.
100    async fn save(&self, session: &Session) -> Result<String> {
101        if !self.store.is_client_side() {
102            for id in session.superseded_ids() {
103                self.store.destroy(&id).await?;
104            }
105        }
106
107        let id = session.id();
108        let data = session.age_and_take();
109
110        if !self.store.is_client_side() {
111            self.store.write(&id, &data).await?;
112        }
113
114        // Encoded after ageing, so the cookie carries what the next request
115        // should see rather than what this one did.
116        self.store.encode(&id, &data)
117    }
118}
119
120#[async_trait::async_trait]
121impl Middleware for StartSession {
122    async fn handle(&self, mut request: Request, next: Next) -> Response {
123        let session = self.load(&request).await;
124        let started_empty = session.is_empty();
125
126        request.extensions_mut().insert(session.clone());
127        let response = next.run(request).await;
128
129        // A session that was empty and stayed empty is not worth a row or a
130        // cookie — otherwise every anonymous hit on a public page allocates
131        // both, and the table fills with nothing.
132        if started_empty && !session.is_dirty() {
133            return response;
134        }
135
136        match self.save(&session).await {
137            Ok(value) => response.with_cookie(&self.cookie(&value)),
138            Err(e) => {
139                // The response is already built and is very likely correct; the
140                // cost of a failed save is a lost session, not a wrong page.
141                // Sending no cookie is better than sending a stale one.
142                tracing::error!(error = %e, "could not save the session");
143                response
144            }
145        }
146    }
147
148    fn name(&self) -> &'static str {
149        "StartSession"
150    }
151}
152
153impl std::fmt::Debug for StartSession {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        f.debug_struct("StartSession")
156            .field("store", &self.store.name())
157            .field("cookie", &self.config.cookie)
158            .finish()
159    }
160}
161
162/// Reaching a request's session.
163pub trait SessionRequestExt {
164    /// The session, if [`StartSession`] ran for this route.
165    fn session(&self) -> Option<&Session>;
166}
167
168impl SessionRequestExt for Request {
169    fn session(&self) -> Option<&Session> {
170        self.extension::<Session>()
171    }
172}
173
174/// The default cookie settings, exposed for tests and for a custom config.
175impl Default for SessionConfig {
176    fn default() -> Self {
177        Self {
178            cookie: "rainier_session".to_string(),
179            path: "/".to_string(),
180            domain: None,
181            secure: false,
182            same_site: SameSite::Lax,
183            lifetime: chrono::Duration::hours(2),
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::session::SessionData;
192    use crate::store::MemorySessionStore;
193    use rainier_middleware::Pipeline;
194    use rainier_support::BoxedFuture;
195
196    fn middleware() -> (Arc<StartSession>, Arc<MemorySessionStore>) {
197        let store = Arc::new(MemorySessionStore::default());
198        (Arc::new(StartSession::new(Arc::clone(&store) as Arc<dyn SessionStore>)), store)
199    }
200
201    async fn run<F>(start: &Arc<StartSession>, request: Request, handler: F) -> Response
202    where
203        F: Fn(Request) -> BoxedFuture<Response> + Send + Sync + 'static,
204    {
205        Pipeline::new()
206            .through_arc(Arc::clone(start) as Arc<dyn Middleware>)
207            .then(handler)
208            .run(request)
209            .await
210    }
211
212    fn session_cookie(response: &Response) -> Option<String> {
213        let header = response.header("set-cookie")?;
214        let value = header.split(';').next()?.strip_prefix("rainier_session=")?;
215        Some(value.to_string())
216    }
217
218    #[tokio::test]
219    async fn a_handler_that_writes_gets_a_cookie_and_a_stored_row() {
220        let (start, store) = middleware();
221
222        let response = run(&start, Request::builder().build(), |request| {
223            Box::pin(async move {
224                request.session().expect("a session").put("user_id", 42u64).unwrap();
225                Response::text("ok")
226            })
227        })
228        .await;
229
230        let id = session_cookie(&response).expect("a session cookie");
231        assert_eq!(store.read(&id).await.unwrap().unwrap().values["user_id"], 42);
232    }
233
234    #[tokio::test]
235    async fn an_anonymous_request_allocates_nothing() {
236        let (start, store) = middleware();
237
238        let response =
239            run(&start, Request::builder().build(), |_| Box::pin(async { Response::text("ok") }))
240                .await;
241
242        assert!(response.header("set-cookie").is_none(), "no cookie for a read-only visit");
243        assert!(store.is_empty(), "and no row");
244    }
245
246    #[tokio::test]
247    async fn a_session_survives_a_second_request() {
248        let (start, _store) = middleware();
249
250        let first = run(&start, Request::builder().build(), |request| {
251            Box::pin(async move {
252                request.session().unwrap().put("user_id", 7u64).unwrap();
253                Response::text("ok")
254            })
255        })
256        .await;
257        let id = session_cookie(&first).unwrap();
258
259        let second = run(
260            &start,
261            Request::builder().header("cookie", &format!("rainier_session={id}")).build(),
262            |request| {
263                let value = request.session().unwrap().get::<u64>("user_id");
264                Box::pin(async move { Response::text(format!("{value:?}")) })
265            },
266        )
267        .await;
268
269        let body = second.into_http().into_body().collect().await.unwrap();
270        assert_eq!(body, "Some(7)");
271    }
272
273    #[tokio::test]
274    async fn an_unknown_or_malformed_cookie_starts_a_fresh_session() {
275        let (start, store) = middleware();
276
277        for hostile in ["nonsense", "../../etc/passwd", &"f".repeat(64), &"z".repeat(64)] {
278            let response = run(
279                &start,
280                Request::builder().header("cookie", &format!("rainier_session={hostile}")).build(),
281                |request| {
282                    Box::pin(async move {
283                        request.session().unwrap().put("x", 1).unwrap();
284                        Response::text("ok")
285                    })
286                },
287            )
288            .await;
289
290            let id = session_cookie(&response).expect("a fresh cookie");
291            assert_ne!(id, hostile, "a client must not choose its own session id");
292        }
293        assert!(!store.is_empty());
294    }
295
296    #[tokio::test]
297    async fn regenerating_deletes_the_old_row() {
298        let (start, store) = middleware();
299
300        let first = run(&start, Request::builder().build(), |request| {
301            Box::pin(async move {
302                request.session().unwrap().put("user_id", 7u64).unwrap();
303                Response::text("ok")
304            })
305        })
306        .await;
307        let before = session_cookie(&first).unwrap();
308
309        let second = run(
310            &start,
311            Request::builder().header("cookie", &format!("rainier_session={before}")).build(),
312            |request| {
313                // What a login does.
314                request.session().unwrap().regenerate();
315                Box::pin(async { Response::text("ok") })
316            },
317        )
318        .await;
319        let after = session_cookie(&second).unwrap();
320
321        assert_ne!(after, before);
322        assert!(store.read(&before).await.unwrap().is_none(), "the old row must not linger");
323        assert_eq!(store.read(&after).await.unwrap().unwrap().values["user_id"], 7);
324    }
325
326    #[tokio::test]
327    async fn the_cookie_is_http_only_and_same_site() {
328        let (start, _) = middleware();
329
330        let response = run(&start, Request::builder().build(), |request| {
331            Box::pin(async move {
332                request.session().unwrap().put("x", 1).unwrap();
333                Response::text("ok")
334            })
335        })
336        .await;
337
338        let header = response.header("set-cookie").unwrap();
339        assert!(header.contains("HttpOnly"), "{header}");
340        assert!(header.contains("SameSite=Lax"), "{header}");
341        assert!(header.contains("Path=/"), "{header}");
342    }
343
344    #[tokio::test]
345    async fn flash_data_is_readable_next_request_and_gone_after() {
346        let (start, _) = middleware();
347
348        let first = run(&start, Request::builder().build(), |request| {
349            Box::pin(async move {
350                request.session().unwrap().flash("status", "Saved.").unwrap();
351                Response::text("ok")
352            })
353        })
354        .await;
355        let id = session_cookie(&first).unwrap();
356        let cookie = format!("rainier_session={id}");
357
358        let read = |cookie: String| {
359            let start = Arc::clone(&start);
360            async move {
361                let response =
362                    run(&start, Request::builder().header("cookie", &cookie).build(), |request| {
363                        let status = request.session().unwrap().string("status");
364                        Box::pin(async move { Response::text(status.unwrap_or_default()) })
365                    })
366                    .await;
367                String::from_utf8(
368                    response.into_http().into_body().collect().await.unwrap().to_vec(),
369                )
370                .unwrap()
371            }
372        };
373
374        assert_eq!(read(cookie.clone()).await, "Saved.");
375        assert_eq!(read(cookie).await, "", "flash data must not survive twice");
376    }
377
378    #[tokio::test]
379    async fn a_route_without_the_middleware_has_no_session() {
380        let response = Pipeline::new()
381            .then(|request: Request| async move {
382                Response::text(format!("{}", request.session().is_some()))
383            })
384            .run(Request::builder().build())
385            .await;
386
387        let body = response.into_http().into_body().collect().await.unwrap();
388        assert_eq!(body, "false");
389    }
390
391    #[tokio::test]
392    async fn a_store_that_fails_does_not_fail_the_request() {
393        struct Broken;
394        impl SessionStore for Broken {
395            fn name(&self) -> &str {
396                "broken"
397            }
398            fn read<'a>(
399                &'a self,
400                _: &'a str,
401            ) -> rainier_support::BoxFuture<'a, Result<Option<SessionData>>> {
402                Box::pin(async { Err(rainier_support::Error::internal("down")) })
403            }
404            fn write<'a>(
405                &'a self,
406                _: &'a str,
407                _: &'a SessionData,
408            ) -> rainier_support::BoxFuture<'a, Result<()>> {
409                Box::pin(async { Err(rainier_support::Error::internal("down")) })
410            }
411            fn destroy<'a>(&'a self, _: &'a str) -> rainier_support::BoxFuture<'a, Result<()>> {
412                Box::pin(async { Ok(()) })
413            }
414        }
415
416        let start = Arc::new(StartSession::new(Arc::new(Broken)));
417        let response = run(&start, Request::builder().build(), |request| {
418            Box::pin(async move {
419                request.session().unwrap().put("x", 1).unwrap();
420                Response::text("the page still renders")
421            })
422        })
423        .await;
424
425        assert_eq!(response.status(), rainier_http::StatusCode::OK);
426    }
427}