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}