Skip to main content

trillium_sessions/
session_handler.rs

1const BASE64_DIGEST_LEN: usize = 44;
2use async_session::{
3    Session, SessionStore, base64,
4    hmac::{Hmac, Mac, NewMac},
5    sha2::Sha256,
6};
7use std::{
8    fmt::{self, Debug, Display, Formatter},
9    iter,
10    sync::Arc,
11    time::{Duration, SystemTime},
12};
13use trillium::{BoxedHandler, Conn, Handler, Status};
14use trillium_cookies::{
15    CookiesConnExt,
16    cookie::{Cookie, Key, SameSite},
17};
18
19/// # Handler to enable sessions.
20///
21/// See crate-level docs for an overview of this crate's approach to
22/// sessions and security.
23pub struct SessionHandler<Store> {
24    store: Store,
25    cookie_path: String,
26    cookie_name: String,
27    cookie_domain: Option<String>,
28    session_ttl: Option<Duration>,
29    save_unchanged: bool,
30    same_site_policy: SameSite,
31    key: Key,
32    older_keys: Vec<Key>,
33    store_error_handler: BoxedHandler,
34}
35
36/// The error returned by a session store that could not be reached
37///
38/// This is set as conn state before the handler provided to
39/// [`SessionHandler::with_store_error_handler`] runs, and remains in state for the rest of the
40/// request if that handler does not halt. Read it with
41/// [`SessionConnExt::session_store_error`](crate::SessionConnExt::session_store_error).
42#[derive(Clone, Debug)]
43pub struct SessionStoreError(Arc<async_session::Error>);
44
45impl Display for SessionStoreError {
46    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
47        Display::fmt(&*self.0, f)
48    }
49}
50
51impl std::ops::Deref for SessionStoreError {
52    type Target = async_session::Error;
53
54    fn deref(&self) -> &Self::Target {
55        &self.0
56    }
57}
58
59/// Halts with a 503, the default response when the session store cannot be reached.
60#[derive(Clone, Copy, Debug)]
61struct ServiceUnavailable;
62
63impl Handler for ServiceUnavailable {
64    async fn run(&self, conn: Conn) -> Conn {
65        conn.with_status(Status::ServiceUnavailable).halt()
66    }
67}
68
69impl<Store: SessionStore> Debug for SessionHandler<Store> {
70    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
71        f.debug_struct("SessionHandler")
72            .field("store", &self.store)
73            .field("cookie_path", &self.cookie_path)
74            .field("cookie_name", &self.cookie_name)
75            .field("cookie_domain", &self.cookie_domain)
76            .field("session_ttl", &self.session_ttl)
77            .field("save_unchanged", &self.save_unchanged)
78            .field("same_site_policy", &self.same_site_policy)
79            .field("store_error_handler", &self.store_error_handler)
80            .field("key", &"<<secret>>")
81            .field("older_keys", &"<<secret>>")
82            .finish()
83    }
84}
85
86impl<Store: SessionStore> SessionHandler<Store> {
87    /// Constructs a SessionHandler from the given
88    /// [`async_session::SessionStore`] and secret.
89    ///
90    /// The `secret` MUST be at least 32 bytes long, and MUST be cryptographically random to be
91    /// secure. It is recommended to retrieve this at runtime from the environment instead of
92    /// compiling it into your application.
93    ///
94    /// # Panics
95    ///
96    /// `SessionHandler::new` will panic if the secret is fewer than 32 bytes.
97    ///
98    /// # Defaults
99    ///
100    /// The defaults for `SessionHandler` are:
101    ///
102    /// * cookie path: "/"
103    /// * cookie name: "trillium.sid"
104    /// * session ttl: one day
105    /// * same site: lax
106    /// * save unchanged: enabled
107    /// * older secrets: none
108    ///
109    /// # Customization
110    ///
111    /// Although the above defaults are appropriate for most applications, they can be
112    /// overridden. Please be careful changing these settings, as some of them can weaken your
113    /// application's security:
114    ///
115    /// ```rust
116    /// # use std::time::Duration;
117    /// # let secrets = concat!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ",
118    /// #     "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
119    /// # unsafe { std::env::set_var("TRILLIUM_SESSION_SECRETS", secrets); }
120    ///
121    /// use trillium_cookies::{CookiesHandler, cookie::SameSite};
122    /// use trillium_sessions::{MemoryStore, SessionHandler};
123    ///
124    /// // this logic will be unique to your deployment
125    /// let secrets_var = std::env::var("TRILLIUM_SESSION_SECRETS").unwrap();
126    /// let session_secrets = secrets_var.split(' ').collect::<Vec<_>>();
127    ///
128    /// let handler = (
129    ///     CookiesHandler::new(),
130    ///     SessionHandler::new(MemoryStore::new(), session_secrets[0])
131    ///         .with_cookie_name("custom.cookie.name")
132    ///         .with_cookie_path("/some/path")
133    ///         .with_cookie_domain("trillium.rs")
134    ///         .with_same_site_policy(SameSite::Strict)
135    ///         .with_session_ttl(Some(Duration::from_secs(1)))
136    ///         .with_older_secrets(&session_secrets[1..])
137    ///         .without_save_unchanged(),
138    /// );
139    /// ```
140    pub fn new(store: Store, secret: impl AsRef<[u8]>) -> Self {
141        Self {
142            store,
143            save_unchanged: true,
144            cookie_path: "/".into(),
145            cookie_name: "trillium.sid".into(),
146            cookie_domain: None,
147            same_site_policy: SameSite::Lax,
148            session_ttl: Some(Duration::from_secs(24 * 60 * 60)),
149            key: Key::derive_from(secret.as_ref()),
150            older_keys: vec![],
151            store_error_handler: BoxedHandler::new(ServiceUnavailable),
152        }
153    }
154
155    /// Sets the handler that runs when the session store cannot be reached.
156    ///
157    /// The default halts with a [`Status::ServiceUnavailable`], because continuing would serve
158    /// the request as though the visitor had no session and then mint a replacement one,
159    /// orphaning the session they actually have and logging them out for good rather than for the
160    /// duration of the outage.
161    ///
162    /// The provided handler runs with a [`SessionStoreError`] in conn state. If it halts, the
163    /// request ends there; if it does not, the request proceeds with an empty session, which is
164    /// the behavior an application that only uses sessions for optional personalization may
165    /// prefer. Passing the noop handler `()` selects that behavior directly.
166    ///
167    /// ```
168    /// # use trillium_sessions::{MemoryStore, SessionHandler};
169    /// # let secret = "01234567890123456789012345678901234567890123456789";
170    /// // serve anonymous traffic through a session store outage
171    /// SessionHandler::new(MemoryStore::new(), secret).with_store_error_handler(());
172    /// ```
173    pub fn with_store_error_handler(mut self, handler: impl Handler) -> Self {
174        self.store_error_handler = BoxedHandler::new(handler);
175        self
176    }
177
178    /// Sets a cookie path for this session handler.
179    /// The default for this value is "/"
180    pub fn with_cookie_path(mut self, cookie_path: impl AsRef<str>) -> Self {
181        cookie_path.as_ref().clone_into(&mut self.cookie_path);
182        self
183    }
184
185    /// Sets a session ttl.
186    ///
187    /// This will be used both for the cookie expiry and also for the session-internal expiry.
188    ///
189    /// The default for this value is one day. Set this to None to not set a cookie or session
190    /// expiry. This is not recommended.
191    pub fn with_session_ttl(mut self, session_ttl: Option<Duration>) -> Self {
192        self.session_ttl = session_ttl;
193        self
194    }
195
196    /// Sets the name of the cookie that the session is stored with or in.
197    ///
198    /// If you are running multiple trillium applications on the same domain, you will need
199    /// different values for each application. The default value is "trillium.sid"
200    pub fn with_cookie_name(mut self, cookie_name: impl AsRef<str>) -> Self {
201        cookie_name.as_ref().clone_into(&mut self.cookie_name);
202        self
203    }
204
205    /// Disables the `save_unchanged` setting.
206    ///
207    /// When `save_unchanged` is enabled, a session will cookie will always be set. With
208    /// `save_unchanged` disabled, the session data must be modified from the `Default` value in
209    /// order for it to save. If a session already exists and its data unmodified in the course of a
210    /// request, the session will only be persisted if `save_unchanged` is enabled.
211    pub fn without_save_unchanged(mut self) -> Self {
212        self.save_unchanged = false;
213        self
214    }
215
216    /// Sets the same site policy for the session cookie.
217    ///
218    /// The default is [`SameSite::Lax`], which withholds the session cookie from the cross-site
219    /// requests that carry csrf risk — form submissions and subresource loads — while still
220    /// sending it when someone follows a link to the application from elsewhere.
221    ///
222    /// [`SameSite::Strict`] additionally withholds it on top-level navigation, so a visitor
223    /// arriving from a link in an email or a search result arrives without their session and has
224    /// to navigate again to get one. That trade is usually worth making only for a second cookie
225    /// gating high-value operations, not for the session itself.
226    ///
227    /// [`SameSite::None`] disables the protection entirely and requires a secure cookie.
228    ///
229    /// See [MDN on
230    /// SameSite](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#samesitesamesite-value)
231    /// for more information about this setting.
232    pub fn with_same_site_policy(mut self, policy: SameSite) -> Self {
233        self.same_site_policy = policy;
234        self
235    }
236
237    /// Sets the domain of the cookie.
238    pub fn with_cookie_domain(mut self, cookie_domain: impl AsRef<str>) -> Self {
239        self.cookie_domain = Some(cookie_domain.as_ref().to_owned());
240        self
241    }
242
243    /// Sets optional older signing keys that will not be used to sign cookies, but can be used to
244    /// validate previously signed cookies.
245    pub fn with_older_secrets(mut self, secrets: &[impl AsRef<[u8]>]) -> Self {
246        self.older_keys = secrets
247            .iter()
248            .map(AsRef::as_ref)
249            .map(Key::derive_from)
250            .collect();
251        self
252    }
253
254    //--- methods below here are private ---
255
256    async fn load(&self, cookie_value: Option<&str>) -> Result<Session, async_session::Error> {
257        let Some(cookie_value) = cookie_value else {
258            return Ok(Session::default());
259        };
260
261        Ok(self
262            .store
263            .load_session(String::from(cookie_value))
264            .await?
265            .and_then(|session| session.validate())
266            .unwrap_or_default())
267    }
268
269    fn build_cookie(&self, secure: bool, cookie_value: String) -> Cookie<'static> {
270        let mut cookie: Cookie<'static> = Cookie::build((self.cookie_name.clone(), cookie_value))
271            .http_only(true)
272            .same_site(self.same_site_policy)
273            .secure(secure)
274            .path(self.cookie_path.clone())
275            .into();
276
277        if let Some(ttl) = self.session_ttl {
278            cookie.set_expires(Some((SystemTime::now() + ttl).into()));
279        }
280
281        if let Some(cookie_domain) = self.cookie_domain.clone() {
282            cookie.set_domain(cookie_domain)
283        }
284
285        self.sign_cookie(&mut cookie);
286
287        cookie
288    }
289
290    // the following is reused verbatim from
291    // https://github.com/SergioBenitez/cookie-rs/blob/master/src/secure/signed.rs#L37-46
292    /// Signs the cookie's value providing integrity and authenticity.
293    fn sign_cookie(&self, cookie: &mut Cookie<'_>) {
294        // Compute HMAC-SHA256 of the cookie's value.
295        let mut mac = Hmac::<Sha256>::new_from_slice(self.key.signing()).expect("good key");
296        mac.update(cookie.value().as_bytes());
297
298        // Cookie's new value is [MAC | original-value].
299        let mut new_value = base64::encode(mac.finalize().into_bytes());
300        new_value.push_str(cookie.value());
301        cookie.set_value(new_value);
302    }
303
304    // the following is based on
305    // https://github.com/SergioBenitez/cookie-rs/blob/master/src/secure/signed.rs#L51-L66
306    /// Given a signed value `str` where the signature is prepended to `value`, verifies the signed
307    /// value and returns it. If there's a problem, returns an `Err` with a string describing the
308    /// issue.
309    fn verify_signature<'a>(&self, cookie_value: &'a str) -> Option<&'a str> {
310        if cookie_value.len() < BASE64_DIGEST_LEN {
311            log::trace!("length of value is <= BASE64_DIGEST_LEN");
312            return None;
313        }
314
315        // Split [MAC | original-value] into its two parts.
316        let (digest_str, value) = cookie_value.split_at(BASE64_DIGEST_LEN);
317        let digest = match base64::decode(digest_str) {
318            Ok(digest) => digest,
319            Err(_) => {
320                log::trace!("bad base64 digest");
321                return None;
322            }
323        };
324
325        iter::once(&self.key)
326            .chain(self.older_keys.iter())
327            .find_map(|key| {
328                let mut mac = Hmac::<Sha256>::new_from_slice(key.signing()).expect("good key");
329                mac.update(value.as_bytes());
330                mac.verify(&digest).ok()
331            })
332            .map(|_| value)
333    }
334}
335
336impl<Store: SessionStore> Handler for SessionHandler<Store> {
337    async fn run(&self, mut conn: Conn) -> Conn {
338        let session = conn.take_state::<Session>();
339
340        let cookie_value = conn
341            .cookies()
342            .get(&self.cookie_name)
343            .and_then(|cookie| self.verify_signature(cookie.value()));
344
345        let mut session = match session {
346            Some(session) => session,
347            None => match self.load(cookie_value).await {
348                Ok(session) => session,
349                Err(error) => {
350                    log::error!("could not load session:\n\n{error}");
351                    conn = self
352                        .store_error_handler
353                        .run(conn.with_state(SessionStoreError(Arc::new(error))))
354                        .await;
355
356                    if conn.is_halted() {
357                        return conn;
358                    }
359
360                    Session::default()
361                }
362            },
363        };
364
365        if let Some(ttl) = self.session_ttl {
366            session.expire_in(ttl);
367        }
368
369        conn.with_state(session)
370    }
371
372    async fn init(&mut self, info: &mut trillium::Info) {
373        self.store_error_handler.init(info).await;
374    }
375
376    async fn before_send(&self, mut conn: Conn) -> Conn {
377        // reverse of run order: the store error handler ran inside this handler's `run`, so its
378        // `before_send` precedes ours
379        if conn.state::<SessionStoreError>().is_some() {
380            conn = self.store_error_handler.before_send(conn).await;
381        }
382
383        if let Some(session) = conn.take_state::<Session>() {
384            let session_to_keep = session.clone();
385            let secure = conn.is_secure();
386            if session.is_destroyed() {
387                self.store.destroy_session(session).await.ok();
388                conn.cookies_mut()
389                    .remove(Cookie::from(self.cookie_name.clone()));
390            } else if self.save_unchanged || session.data_changed() {
391                match self.store.store_session(session).await {
392                    Ok(Some(cookie_value)) => {
393                        conn.cookies_mut()
394                            .add(self.build_cookie(secure, cookie_value));
395                    }
396
397                    Ok(None) => {}
398
399                    Err(e) => {
400                        log::error!("could not store session:\n\n{e}")
401                    }
402                }
403            }
404
405            conn.with_state(session_to_keep)
406        } else {
407            conn
408        }
409    }
410}
411
412/// Alias for [`SessionHandler::new`]
413pub fn sessions<Store>(store: Store, secret: impl AsRef<[u8]>) -> SessionHandler<Store>
414where
415    Store: SessionStore,
416{
417    SessionHandler::new(store, secret)
418}