ntex_session/
cookie.rs

1//! Cookie session.
2//!
3//! [**CookieSession**](struct.CookieSession.html)
4//! uses cookies as session storage. `CookieSession` creates sessions
5//! which are limited to storing fewer than 4000 bytes of data, as the payload
6//! must fit into a single cookie. An internal server error is generated if a
7//! session contains more than 4000 bytes.
8//!
9//! A cookie may have a security policy of *signed* or *private*. Each has
10//! a respective `CookieSession` constructor.
11//!
12//! A *signed* cookie may be viewed but not modified by the client. A *private*
13//! cookie may neither be viewed nor modified by the client.
14//!
15//! The constructors take a key as an argument. This is the private key
16//! for cookie session - when this value is changed, all session data is lost.
17
18use std::{collections::HashMap, convert::Infallible, rc::Rc};
19
20use cookie::{Cookie, CookieJar, Key, SameSite};
21use derive_more::{Display, From};
22use ntex::http::{HttpMessage, header::HeaderValue, header::SET_COOKIE};
23use ntex::service::{Middleware, Service, ServiceCtx};
24use ntex::web::{DefaultError, ErrorRenderer, WebRequest, WebResponse, WebResponseError};
25use serde_json::error::Error as JsonError;
26use time::{Duration, OffsetDateTime};
27
28use crate::{Session, SessionStatus};
29
30/// Errors that can occur during handling cookie session
31#[derive(Debug, From, Display)]
32pub enum CookieSessionError {
33    /// Size of the serialized session is greater than 4000 bytes.
34    #[display("Size of the serialized session is greater than 4000 bytes.")]
35    Overflow,
36    /// Fail to serialize session.
37    #[display("Fail to serialize session")]
38    Serialize(JsonError),
39}
40
41impl WebResponseError<DefaultError> for CookieSessionError {}
42
43enum CookieSecurity {
44    Signed,
45    Private,
46}
47
48struct CookieSessionInner {
49    key: Key,
50    security: CookieSecurity,
51    name: String,
52    path: String,
53    domain: Option<String>,
54    secure: bool,
55    http_only: bool,
56    max_age: Option<Duration>,
57    expires_in: Option<Duration>,
58    same_site: Option<SameSite>,
59}
60
61impl CookieSessionInner {
62    fn new(key: &[u8], security: CookieSecurity) -> Self {
63        CookieSessionInner {
64            security,
65            key: Key::derive_from(key),
66            name: "ntex-session".to_owned(),
67            path: "/".to_owned(),
68            domain: None,
69            secure: true,
70            http_only: true,
71            max_age: None,
72            expires_in: None,
73            same_site: None,
74        }
75    }
76
77    fn set_cookie(
78        &self,
79        res: &mut WebResponse,
80        state: impl Iterator<Item = (String, String)>,
81    ) -> Result<(), CookieSessionError> {
82        let state: HashMap<String, String> = state.collect();
83        let value = serde_json::to_string(&state).map_err(CookieSessionError::Serialize)?;
84        if value.len() > 4064 {
85            return Err(CookieSessionError::Overflow);
86        }
87
88        let mut cookie = Cookie::new(self.name.clone(), value);
89        cookie.set_path(self.path.clone());
90        cookie.set_secure(self.secure);
91        cookie.set_http_only(self.http_only);
92
93        if let Some(ref domain) = self.domain {
94            cookie.set_domain(domain.clone());
95        }
96
97        if let Some(expires_in) = self.expires_in {
98            cookie.set_expires(OffsetDateTime::now_utc() + expires_in);
99        }
100
101        if let Some(max_age) = self.max_age {
102            cookie.set_max_age(max_age);
103        }
104
105        if let Some(same_site) = self.same_site {
106            cookie.set_same_site(same_site);
107        }
108
109        let mut jar = CookieJar::new();
110
111        match self.security {
112            CookieSecurity::Signed => jar.signed_mut(&self.key).add(cookie),
113            CookieSecurity::Private => jar.private_mut(&self.key).add(cookie),
114        }
115
116        for cookie in jar.delta() {
117            let val = HeaderValue::from_str(&cookie.encoded().to_string()).unwrap();
118            res.headers_mut().append(SET_COOKIE, val);
119        }
120
121        Ok(())
122    }
123
124    /// invalidates session cookie
125    fn remove_cookie(&self, res: &mut WebResponse) -> Result<(), Infallible> {
126        let mut cookie = Cookie::from(self.name.clone());
127        cookie.set_value("");
128        cookie.set_max_age(Duration::ZERO);
129        cookie.set_expires(OffsetDateTime::now_utc() - Duration::days(365));
130
131        let val = HeaderValue::from_str(&cookie.to_string()).unwrap();
132        res.headers_mut().append(SET_COOKIE, val);
133
134        Ok(())
135    }
136
137    fn load<Err>(&self, req: &WebRequest<Err>) -> (bool, HashMap<String, String>) {
138        if let Ok(cookies) = req.cookies() {
139            for cookie in cookies.iter() {
140                if cookie.name() == self.name {
141                    let mut jar = CookieJar::new();
142                    jar.add_original(cookie.clone());
143
144                    let cookie_opt = match self.security {
145                        CookieSecurity::Signed => jar.signed(&self.key).get(&self.name),
146                        CookieSecurity::Private => jar.private(&self.key).get(&self.name),
147                    };
148                    if let Some(cookie) = cookie_opt {
149                        if let Ok(val) = serde_json::from_str(cookie.value()) {
150                            return (false, val);
151                        }
152                    }
153                }
154            }
155        }
156        (true, HashMap::new())
157    }
158}
159
160/// Use cookies for session storage.
161///
162/// `CookieSession` creates sessions which are limited to storing
163/// fewer than 4000 bytes of data (as the payload must fit into a single
164/// cookie). An Internal Server Error is generated if the session contains more
165/// than 4000 bytes.
166///
167/// A cookie may have a security policy of *signed* or *private*. Each has a
168/// respective `CookieSessionBackend` constructor.
169///
170/// A *signed* cookie is stored on the client as plaintext alongside
171/// a signature such that the cookie may be viewed but not modified by the
172/// client.
173///
174/// A *private* cookie is stored on the client as encrypted text
175/// such that it may neither be viewed nor modified by the client.
176///
177/// The constructors take a key as an argument.
178/// This is the private key for cookie session - when this value is changed,
179/// all session data is lost. The constructors will panic if the key is less
180/// than 32 bytes in length.
181///
182/// The backend relies on `cookie` crate to create and read cookies.
183/// By default all cookies are percent encoded, but certain symbols may
184/// cause troubles when reading cookie, if they are not properly percent encoded.
185///
186/// # Example
187///
188/// ```rust
189/// use ntex_session::CookieSession;
190/// use ntex::web::{self, App, HttpResponse, HttpServer};
191///
192/// let app = App::new().wrap(
193///     CookieSession::signed(&[0; 32])
194///         .domain("www.rust-lang.org")
195///         .name("ntex-session")
196///         .path("/")
197///         .secure(true))
198///     .service(web::resource("/").to(|| async { HttpResponse::Ok() }));
199/// ```
200pub struct CookieSession(Rc<CookieSessionInner>);
201
202impl CookieSession {
203    /// Construct new *signed* `CookieSessionBackend` instance.
204    ///
205    /// Panics if key length is less than 32 bytes.
206    pub fn signed(key: &[u8]) -> Self {
207        CookieSession(Rc::new(CookieSessionInner::new(key, CookieSecurity::Signed)))
208    }
209
210    /// Construct new *private* `CookieSessionBackend` instance.
211    ///
212    /// Panics if key length is less than 32 bytes.
213    pub fn private(key: &[u8]) -> Self {
214        CookieSession(Rc::new(CookieSessionInner::new(key, CookieSecurity::Private)))
215    }
216
217    /// Sets the `path` field in the session cookie being built.
218    pub fn path<S: Into<String>>(mut self, value: S) -> Self {
219        Rc::get_mut(&mut self.0).unwrap().path = value.into();
220        self
221    }
222
223    /// Sets the `name` field in the session cookie being built.
224    pub fn name<S: Into<String>>(mut self, value: S) -> Self {
225        Rc::get_mut(&mut self.0).unwrap().name = value.into();
226        self
227    }
228
229    /// Sets the `domain` field in the session cookie being built.
230    pub fn domain<S: Into<String>>(mut self, value: S) -> Self {
231        Rc::get_mut(&mut self.0).unwrap().domain = Some(value.into());
232        self
233    }
234
235    /// Sets the `secure` field in the session cookie being built.
236    ///
237    /// If the `secure` field is set, a cookie will only be transmitted when the
238    /// connection is secure - i.e. `https`
239    pub fn secure(mut self, value: bool) -> Self {
240        Rc::get_mut(&mut self.0).unwrap().secure = value;
241        self
242    }
243
244    /// Sets the `http_only` field in the session cookie being built.
245    pub fn http_only(mut self, value: bool) -> Self {
246        Rc::get_mut(&mut self.0).unwrap().http_only = value;
247        self
248    }
249
250    /// Sets the `same_site` field in the session cookie being built.
251    pub fn same_site(mut self, value: SameSite) -> Self {
252        Rc::get_mut(&mut self.0).unwrap().same_site = Some(value);
253        self
254    }
255
256    /// Sets the `max-age` field in the session cookie being built.
257    pub fn max_age(self, seconds: i64) -> Self {
258        self.max_age_time(Duration::seconds(seconds))
259    }
260
261    /// Sets the `max-age` field in the session cookie being built.
262    pub fn max_age_time(mut self, value: time::Duration) -> Self {
263        Rc::get_mut(&mut self.0).unwrap().max_age = Some(value);
264        self
265    }
266
267    /// Sets the `expires` field in the session cookie being built.
268    pub fn expires_in(self, seconds: i64) -> Self {
269        self.expires_in_time(Duration::seconds(seconds))
270    }
271
272    /// Sets the `expires` field in the session cookie being built.
273    pub fn expires_in_time(mut self, value: Duration) -> Self {
274        Rc::get_mut(&mut self.0).unwrap().expires_in = Some(value);
275        self
276    }
277}
278
279impl<S, C> Middleware<S, C> for CookieSession {
280    type Service = CookieSessionMiddleware<S>;
281
282    fn create(&self, service: S, _: C) -> Self::Service {
283        CookieSessionMiddleware { service, inner: self.0.clone() }
284    }
285}
286
287/// Cookie session middleware
288pub struct CookieSessionMiddleware<S> {
289    service: S,
290    inner: Rc<CookieSessionInner>,
291}
292
293impl<S, Err> Service<WebRequest<Err>> for CookieSessionMiddleware<S>
294where
295    S: Service<WebRequest<Err>, Response = WebResponse>,
296    S::Error: 'static,
297    Err: ErrorRenderer,
298    Err::Container: From<CookieSessionError>,
299{
300    type Response = WebResponse;
301    type Error = S::Error;
302
303    ntex::forward_ready!(service);
304    ntex::forward_shutdown!(service);
305
306    /// On first request, a new session cookie is returned in response, regardless
307    /// of whether any session state is set.  With subsequent requests, if the
308    /// session state changes, then set-cookie is returned in response.  As
309    /// a user logs out, call session.purge() to set SessionStatus accordingly
310    /// and this will trigger removal of the session cookie in the response.
311    async fn call(
312        &self,
313        req: WebRequest<Err>,
314        ctx: ServiceCtx<'_, Self>,
315    ) -> Result<Self::Response, Self::Error> {
316        let inner = self.inner.clone();
317        let (is_new, state) = self.inner.load(&req);
318        let prolong_expiration = self.inner.expires_in.is_some();
319        Session::set_session(state.into_iter(), &req);
320
321        ctx.call(&self.service, req).await.map(|mut res| {
322            match Session::get_changes(&mut res) {
323                (SessionStatus::Changed, Some(state))
324                | (SessionStatus::Renewed, Some(state)) => {
325                    res.checked_expr::<Err, _, _>(|res| inner.set_cookie(res, state))
326                }
327                (SessionStatus::Unchanged, Some(state)) if prolong_expiration => {
328                    res.checked_expr::<Err, _, _>(|res| inner.set_cookie(res, state))
329                }
330                (SessionStatus::Unchanged, _) =>
331                // set a new session cookie upon first request (new client)
332                {
333                    if is_new {
334                        let state: HashMap<String, String> = HashMap::new();
335                        res.checked_expr::<Err, _, _>(|res| {
336                            inner.set_cookie(res, state.into_iter())
337                        })
338                    } else {
339                        res
340                    }
341                }
342                (SessionStatus::Purged, _) => {
343                    let _ = inner.remove_cookie(&mut res);
344                    res
345                }
346                _ => res,
347            }
348        })
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use ntex::web::{self, App, test};
356    use ntex::{time, util::Bytes};
357
358    #[ntex::test]
359    async fn cookie_session() {
360        let app = test::init_service(
361            App::new().wrap(CookieSession::signed(&[0; 32]).secure(false)).service(
362                web::resource("/").to(|ses: Session| async move {
363                    let _ = ses.set("counter", 100);
364                    "test"
365                }),
366            ),
367        )
368        .await;
369
370        let request = test::TestRequest::get().to_request();
371        let response = app.call(request).await.unwrap();
372        assert!(response.response().cookies().any(|c| c.name() == "ntex-session"));
373    }
374
375    #[ntex::test]
376    async fn private_cookie() {
377        let app = test::init_service(
378            App::new().wrap(CookieSession::private(&[0; 32]).secure(false)).service(
379                web::resource("/").to(|ses: Session| async move {
380                    let _ = ses.set("counter", 100);
381                    "test"
382                }),
383            ),
384        )
385        .await;
386
387        let request = test::TestRequest::get().to_request();
388        let response = app.call(request).await.unwrap();
389        assert!(response.response().cookies().any(|c| c.name() == "ntex-session"));
390    }
391
392    #[ntex::test]
393    async fn cookie_session_extractor() {
394        let app = test::init_service(
395            App::new().wrap(CookieSession::signed(&[0; 32]).secure(false)).service(
396                web::resource("/").to(|ses: Session| async move {
397                    let _ = ses.set("counter", 100);
398                    "test"
399                }),
400            ),
401        )
402        .await;
403
404        let request = test::TestRequest::get().to_request();
405        let response = app.call(request).await.unwrap();
406        assert!(response.response().cookies().any(|c| c.name() == "ntex-session"));
407    }
408
409    #[ntex::test]
410    async fn basics() {
411        let app = test::init_service(
412            App::new()
413                .wrap(
414                    CookieSession::signed(&[0; 32])
415                        .path("/test/")
416                        .name("ntex-test")
417                        .domain("localhost")
418                        .http_only(true)
419                        .same_site(SameSite::Lax)
420                        .max_age(100),
421                )
422                .service(web::resource("/").to(|ses: Session| async move {
423                    let _ = ses.set("counter", 100);
424                    "test"
425                }))
426                .service(web::resource("/test/").to(|ses: Session| async move {
427                    let val: usize = ses.get("counter").unwrap().unwrap();
428                    format!("counter: {}", val)
429                })),
430        )
431        .await;
432
433        let request = test::TestRequest::get().to_request();
434        let response = app.call(request).await.unwrap();
435        let cookie = response
436            .response()
437            .cookies()
438            .find(|c| c.name() == "ntex-test")
439            .unwrap()
440            .into_owned();
441        assert_eq!(cookie.path().unwrap(), "/test/");
442
443        let request = test::TestRequest::with_uri("/test/").cookie(cookie).to_request();
444        let body = test::read_response(&app, request).await;
445        assert_eq!(body, Bytes::from_static(b"counter: 100"));
446    }
447
448    #[ntex::test]
449    async fn prolong_expiration() {
450        let app = test::init_service(
451            App::new()
452                .wrap(CookieSession::signed(&[0; 32]).secure(false).expires_in(60))
453                .service(web::resource("/").to(|ses: Session| async move {
454                    let _ = ses.set("counter", 100);
455                    "test"
456                }))
457                .service(web::resource("/test/").to(|| async move { "no-changes-in-session" })),
458        )
459        .await;
460
461        let request = test::TestRequest::get().to_request();
462        let response = app.call(request).await.unwrap();
463        let expires_1 = response
464            .response()
465            .cookies()
466            .find(|c| c.name() == "ntex-session")
467            .expect("Cookie is set")
468            .expires()
469            .expect("Expiration is set");
470
471        time::sleep(time::Seconds::ONE).await;
472
473        let request = test::TestRequest::with_uri("/test/").to_request();
474        let response = app.call(request).await.unwrap();
475        let expires_2 = response
476            .response()
477            .cookies()
478            .find(|c| c.name() == "ntex-session")
479            .expect("Cookie is set")
480            .expires()
481            .expect("Expiration is set");
482
483        assert!(
484            expires_2.datetime().unwrap() - expires_1.datetime().unwrap()
485                >= Duration::seconds(1)
486        );
487    }
488}