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, Formatter},
9    iter,
10    time::{Duration, SystemTime},
11};
12use trillium::{Conn, Handler};
13use trillium_cookies::{
14    CookiesConnExt,
15    cookie::{Cookie, Key, SameSite},
16};
17
18/// # Handler to enable sessions.
19///
20/// See crate-level docs for an overview of this crate's approach to
21/// sessions and security.
22pub struct SessionHandler<Store> {
23    store: Store,
24    cookie_path: String,
25    cookie_name: String,
26    cookie_domain: Option<String>,
27    session_ttl: Option<Duration>,
28    save_unchanged: bool,
29    same_site_policy: SameSite,
30    key: Key,
31    older_keys: Vec<Key>,
32}
33
34impl<Store: SessionStore> Debug for SessionHandler<Store> {
35    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
36        f.debug_struct("SessionHandler")
37            .field("store", &self.store)
38            .field("cookie_path", &self.cookie_path)
39            .field("cookie_name", &self.cookie_name)
40            .field("cookie_domain", &self.cookie_domain)
41            .field("session_ttl", &self.session_ttl)
42            .field("save_unchanged", &self.save_unchanged)
43            .field("same_site_policy", &self.same_site_policy)
44            .field("key", &"<<secret>>")
45            .field("older_keys", &"<<secret>>")
46            .finish()
47    }
48}
49
50impl<Store: SessionStore> SessionHandler<Store> {
51    /// Constructs a SessionHandler from the given
52    /// [`async_session::SessionStore`] and secret.
53    ///
54    /// The `secret` MUST be at least 32 bytes long, and MUST be cryptographically random to be
55    /// secure. It is recommended to retrieve this at runtime from the environment instead of
56    /// compiling it into your application.
57    ///
58    /// # Panics
59    ///
60    /// `SessionHandler::new` will panic if the secret is fewer than 32 bytes.
61    ///
62    /// # Defaults
63    ///
64    /// The defaults for `SessionHandler` are:
65    ///
66    /// * cookie path: "/"
67    /// * cookie name: "trillium.sid"
68    /// * session ttl: one day
69    /// * same site: strict
70    /// * save unchanged: enabled
71    /// * older secrets: none
72    ///
73    /// # Customization
74    ///
75    /// Although the above defaults are appropriate for most applications, they can be
76    /// overridden. Please be careful changing these settings, as they can weaken your application's
77    /// security:
78    ///
79    /// ```rust
80    /// # use std::time::Duration;
81    /// # let secrets = concat!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ",
82    /// #     "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
83    /// # unsafe { std::env::set_var("TRILLIUM_SESSION_SECRETS", secrets); }
84    ///
85    /// use trillium_cookies::{CookiesHandler, cookie::SameSite};
86    /// use trillium_sessions::{MemoryStore, SessionHandler};
87    ///
88    /// // this logic will be unique to your deployment
89    /// let secrets_var = std::env::var("TRILLIUM_SESSION_SECRETS").unwrap();
90    /// let session_secrets = secrets_var.split(' ').collect::<Vec<_>>();
91    ///
92    /// let handler = (
93    ///     CookiesHandler::new(),
94    ///     SessionHandler::new(MemoryStore::new(), session_secrets[0])
95    ///         .with_cookie_name("custom.cookie.name")
96    ///         .with_cookie_path("/some/path")
97    ///         .with_cookie_domain("trillium.rs")
98    ///         .with_same_site_policy(SameSite::Strict)
99    ///         .with_session_ttl(Some(Duration::from_secs(1)))
100    ///         .with_older_secrets(&session_secrets[1..])
101    ///         .without_save_unchanged(),
102    /// );
103    /// ```
104    pub fn new(store: Store, secret: impl AsRef<[u8]>) -> Self {
105        Self {
106            store,
107            save_unchanged: true,
108            cookie_path: "/".into(),
109            cookie_name: "trillium.sid".into(),
110            cookie_domain: None,
111            same_site_policy: SameSite::Lax,
112            session_ttl: Some(Duration::from_secs(24 * 60 * 60)),
113            key: Key::derive_from(secret.as_ref()),
114            older_keys: vec![],
115        }
116    }
117
118    /// Sets a cookie path for this session handler.
119    /// The default for this value is "/"
120    pub fn with_cookie_path(mut self, cookie_path: impl AsRef<str>) -> Self {
121        cookie_path.as_ref().clone_into(&mut self.cookie_path);
122        self
123    }
124
125    /// Sets a session ttl.
126    ///
127    /// This will be used both for the cookie expiry and also for the session-internal expiry.
128    ///
129    /// The default for this value is one day. Set this to None to not set a cookie or session
130    /// expiry. This is not recommended.
131    pub fn with_session_ttl(mut self, session_ttl: Option<Duration>) -> Self {
132        self.session_ttl = session_ttl;
133        self
134    }
135
136    /// Sets the name of the cookie that the session is stored with or in.
137    ///
138    /// If you are running multiple trillium applications on the same domain, you will need
139    /// different values for each application. The default value is "trillium.sid"
140    pub fn with_cookie_name(mut self, cookie_name: impl AsRef<str>) -> Self {
141        cookie_name.as_ref().clone_into(&mut self.cookie_name);
142        self
143    }
144
145    /// Disables the `save_unchanged` setting.
146    ///
147    /// When `save_unchanged` is enabled, a session will cookie will always be set. With
148    /// `save_unchanged` disabled, the session data must be modified from the `Default` value in
149    /// order for it to save. If a session already exists and its data unmodified in the course of a
150    /// request, the session will only be persisted if `save_unchanged` is enabled.
151    pub fn without_save_unchanged(mut self) -> Self {
152        self.save_unchanged = false;
153        self
154    }
155
156    /// Sets the same site policy for the session cookie. Defaults to SameSite::Strict. See
157    /// [incrementally better
158    /// cookies](https://tools.ietf.org/html/draft-west-cookie-incrementalism-01) for more
159    /// information about this setting
160    pub fn with_same_site_policy(mut self, policy: SameSite) -> Self {
161        self.same_site_policy = policy;
162        self
163    }
164
165    /// Sets the domain of the cookie.
166    pub fn with_cookie_domain(mut self, cookie_domain: impl AsRef<str>) -> Self {
167        self.cookie_domain = Some(cookie_domain.as_ref().to_owned());
168        self
169    }
170
171    /// Sets optional older signing keys that will not be used to sign cookies, but can be used to
172    /// validate previously signed cookies.
173    pub fn with_older_secrets(mut self, secrets: &[impl AsRef<[u8]>]) -> Self {
174        self.older_keys = secrets
175            .iter()
176            .map(AsRef::as_ref)
177            .map(Key::derive_from)
178            .collect();
179        self
180    }
181
182    //--- methods below here are private ---
183
184    async fn load_or_create(&self, cookie_value: Option<&str>) -> Session {
185        let session = match cookie_value {
186            Some(cookie_value) => self
187                .store
188                .load_session(String::from(cookie_value))
189                .await
190                .ok()
191                .flatten(),
192            None => None,
193        };
194
195        session
196            .and_then(|session| session.validate())
197            .unwrap_or_default()
198    }
199
200    fn build_cookie(&self, secure: bool, cookie_value: String) -> Cookie<'static> {
201        let mut cookie: Cookie<'static> = Cookie::build((self.cookie_name.clone(), cookie_value))
202            .http_only(true)
203            .same_site(self.same_site_policy)
204            .secure(secure)
205            .path(self.cookie_path.clone())
206            .into();
207
208        if let Some(ttl) = self.session_ttl {
209            cookie.set_expires(Some((SystemTime::now() + ttl).into()));
210        }
211
212        if let Some(cookie_domain) = self.cookie_domain.clone() {
213            cookie.set_domain(cookie_domain)
214        }
215
216        self.sign_cookie(&mut cookie);
217
218        cookie
219    }
220
221    // the following is reused verbatim from
222    // https://github.com/SergioBenitez/cookie-rs/blob/master/src/secure/signed.rs#L37-46
223    /// Signs the cookie's value providing integrity and authenticity.
224    fn sign_cookie(&self, cookie: &mut Cookie<'_>) {
225        // Compute HMAC-SHA256 of the cookie's value.
226        let mut mac = Hmac::<Sha256>::new_from_slice(self.key.signing()).expect("good key");
227        mac.update(cookie.value().as_bytes());
228
229        // Cookie's new value is [MAC | original-value].
230        let mut new_value = base64::encode(mac.finalize().into_bytes());
231        new_value.push_str(cookie.value());
232        cookie.set_value(new_value);
233    }
234
235    // the following is based on
236    // https://github.com/SergioBenitez/cookie-rs/blob/master/src/secure/signed.rs#L51-L66
237    /// Given a signed value `str` where the signature is prepended to `value`, verifies the signed
238    /// value and returns it. If there's a problem, returns an `Err` with a string describing the
239    /// issue.
240    fn verify_signature<'a>(&self, cookie_value: &'a str) -> Option<&'a str> {
241        if cookie_value.len() < BASE64_DIGEST_LEN {
242            log::trace!("length of value is <= BASE64_DIGEST_LEN");
243            return None;
244        }
245
246        // Split [MAC | original-value] into its two parts.
247        let (digest_str, value) = cookie_value.split_at(BASE64_DIGEST_LEN);
248        let digest = match base64::decode(digest_str) {
249            Ok(digest) => digest,
250            Err(_) => {
251                log::trace!("bad base64 digest");
252                return None;
253            }
254        };
255
256        iter::once(&self.key)
257            .chain(self.older_keys.iter())
258            .find_map(|key| {
259                let mut mac = Hmac::<Sha256>::new_from_slice(key.signing()).expect("good key");
260                mac.update(value.as_bytes());
261                mac.verify(&digest).ok()
262            })
263            .map(|_| value)
264    }
265}
266
267impl<Store: SessionStore> Handler for SessionHandler<Store> {
268    async fn run(&self, mut conn: Conn) -> Conn {
269        let session = conn.take_state::<Session>();
270
271        let cookie_value = conn
272            .cookies()
273            .get(&self.cookie_name)
274            .and_then(|cookie| self.verify_signature(cookie.value()));
275
276        let mut session = match session {
277            Some(session) => session,
278            None => self.load_or_create(cookie_value).await,
279        };
280
281        if let Some(ttl) = self.session_ttl {
282            session.expire_in(ttl);
283        }
284
285        conn.with_state(session)
286    }
287
288    async fn before_send(&self, mut conn: Conn) -> Conn {
289        if let Some(session) = conn.take_state::<Session>() {
290            let session_to_keep = session.clone();
291            let secure = conn.is_secure();
292            if session.is_destroyed() {
293                self.store.destroy_session(session).await.ok();
294                conn.cookies_mut()
295                    .remove(Cookie::from(self.cookie_name.clone()));
296            } else if self.save_unchanged || session.data_changed() {
297                match self.store.store_session(session).await {
298                    Ok(Some(cookie_value)) => {
299                        conn.cookies_mut()
300                            .add(self.build_cookie(secure, cookie_value));
301                    }
302
303                    Ok(None) => {}
304
305                    Err(e) => {
306                        log::error!("could not store session:\n\n{e}")
307                    }
308                }
309            }
310
311            conn.with_state(session_to_keep)
312        } else {
313            conn
314        }
315    }
316}
317
318/// Alias for [`SessionHandler::new`]
319pub fn sessions<Store>(store: Store, secret: impl AsRef<[u8]>) -> SessionHandler<Store>
320where
321    Store: SessionStore,
322{
323    SessionHandler::new(store, secret)
324}