actix / actix-extras
1
//! Cookie based sessions. See docs for [`CookieSession`].
2

3
use std::{collections::HashMap, error::Error as StdError, rc::Rc};
4

5
use actix_web::{
6
    body::{AnyBody, MessageBody},
7
    cookie::{Cookie, CookieJar, Key, SameSite},
8
    dev::{Service, ServiceRequest, ServiceResponse, Transform},
9
    http::{header::SET_COOKIE, HeaderValue},
10
    Error, ResponseError,
11
};
12
use derive_more::Display;
13
use futures_util::future::{ok, FutureExt as _, LocalBoxFuture, Ready};
14
use serde_json::error::Error as JsonError;
15
use time::{Duration, OffsetDateTime};
16

17
use crate::{Session, SessionStatus};
18

19
/// Errors that can occur during handling cookie session
20
#[derive(Debug, Display)]
21
pub enum CookieSessionError {
22
    /// Size of the serialized session is greater than 4000 bytes.
23
    #[display(fmt = "Size of the serialized session is greater than 4000 bytes.")]
24
    Overflow,
25

26
    /// Fail to serialize session.
27
    #[display(fmt = "Fail to serialize session")]
28
    Serialize(JsonError),
29
}
30

31
impl ResponseError for CookieSessionError {}
32

33
enum CookieSecurity {
34
    Signed,
35
    Private,
36
}
37

38
struct CookieSessionInner {
39
    key: Key,
40
    security: CookieSecurity,
41
    name: String,
42
    path: String,
43
    domain: Option<String>,
44
    lazy: bool,
45
    secure: bool,
46
    http_only: bool,
47
    max_age: Option<Duration>,
48
    expires_in: Option<Duration>,
49
    same_site: Option<SameSite>,
50
}
51

52
impl CookieSessionInner {
53 1
    fn new(key: &[u8], security: CookieSecurity) -> CookieSessionInner {
54
        CookieSessionInner {
55
            security,
56 1
            key: Key::derive_from(key),
57 1
            name: "actix-session".to_owned(),
58 1
            path: "/".to_owned(),
59
            domain: None,
60
            lazy: false,
61
            secure: true,
62
            http_only: true,
63
            max_age: None,
64
            expires_in: None,
65
            same_site: None,
66
        }
67
    }
68

69 1
    fn set_cookie<B>(
70
        &self,
71
        res: &mut ServiceResponse<B>,
72
        state: impl Iterator<Item = (String, String)>,
73
    ) -> Result<(), Error> {
74 1
        let state: HashMap<String, String> = state.collect();
75

76 1
        if self.lazy && state.is_empty() {
77 1
            return Ok(());
78
        }
79

80 1
        let value =
81 0
            serde_json::to_string(&state).map_err(CookieSessionError::Serialize)?;
82

83 1
        if value.len() > 4064 {
84 0
            return Err(CookieSessionError::Overflow.into());
85
        }
86

87 1
        let mut cookie = Cookie::new(self.name.clone(), value);
88 1
        cookie.set_path(self.path.clone());
89 1
        cookie.set_secure(self.secure);
90 1
        cookie.set_http_only(self.http_only);
91

92 1
        if let Some(ref domain) = self.domain {
93 1
            cookie.set_domain(domain.clone());
94
        }
95

96 1
        if let Some(expires_in) = self.expires_in {
97 1
            cookie.set_expires(OffsetDateTime::now_utc() + expires_in);
98
        }
99

100 1
        if let Some(max_age) = self.max_age {
101 1
            cookie.set_max_age(max_age);
102
        }
103

104 1
        if let Some(same_site) = self.same_site {
105 1
            cookie.set_same_site(same_site);
106
        }
107

108 1
        let mut jar = CookieJar::new();
109

110 0
        match self.security {
111 1
            CookieSecurity::Signed => jar.signed_mut(&self.key).add(cookie),
112 1
            CookieSecurity::Private => jar.private_mut(&self.key).add(cookie),
113
        }
114

115 1
        for cookie in jar.delta() {
116 1
            let val = HeaderValue::from_str(&cookie.encoded().to_string())?;
117 1
            res.headers_mut().append(SET_COOKIE, val);
118
        }
119

120 1
        Ok(())
121
    }
122

123
    /// invalidates session cookie
124 0
    fn remove_cookie<B>(&self, res: &mut ServiceResponse<B>) -> Result<(), Error> {
125 0
        let mut cookie = Cookie::named(self.name.clone());
126 0
        cookie.set_path(self.path.clone());
127 0
        cookie.set_value("");
128 0
        cookie.set_max_age(Duration::zero());
129 0
        cookie.set_expires(OffsetDateTime::now_utc() - Duration::days(365));
130

131 0
        let val = HeaderValue::from_str(&cookie.to_string())?;
132 0
        res.headers_mut().append(SET_COOKIE, val);
133

134 0
        Ok(())
135
    }
136

137 1
    fn load(&self, req: &ServiceRequest) -> (bool, HashMap<String, String>) {
138 1
        if let Ok(cookies) = req.cookies() {
139 1
            for cookie in cookies.iter() {
140 1
                if cookie.name() == self.name {
141 1
                    let mut jar = CookieJar::new();
142 1
                    jar.add_original(cookie.clone());
143

144 1
                    let cookie_opt = match self.security {
145 1
                        CookieSecurity::Signed => jar.signed(&self.key).get(&self.name),
146 0
                        CookieSecurity::Private => {
147 0
                            jar.private(&self.key).get(&self.name)
148
                        }
149
                    };
150

151 1
                    if let Some(cookie) = cookie_opt {
152 1
                        if let Ok(val) = serde_json::from_str(cookie.value()) {
153 1
                            return (false, val);
154
                        }
155
                    }
156
                }
157
            }
158
        }
159

160 1
        (true, HashMap::new())
161
    }
162
}
163

164
/// Use cookies for session storage.
165
///
166
/// `CookieSession` creates sessions which are limited to storing
167
/// fewer than 4000 bytes of data (as the payload must fit into a single
168
/// cookie). An Internal Server Error is generated if the session contains more
169
/// than 4000 bytes.
170
///
171
/// A cookie may have a security policy of *signed* or *private*. Each has a
172
/// respective `CookieSession` constructor.
173
///
174
/// A *signed* cookie is stored on the client as plaintext alongside
175
/// a signature such that the cookie may be viewed but not modified by the
176
/// client.
177
///
178
/// A *private* cookie is stored on the client as encrypted text
179
/// such that it may neither be viewed nor modified by the client.
180
///
181
/// The constructors take a key as an argument.
182
/// This is the private key for cookie session - when this value is changed,
183
/// all session data is lost. The constructors will panic if the key is less
184
/// than 32 bytes in length.
185
///
186
/// The backend relies on `cookie` crate to create and read cookies.
187
/// By default all cookies are percent encoded, but certain symbols may
188
/// cause troubles when reading cookie, if they are not properly percent encoded.
189
///
190
/// # Examples
191
/// ```
192
/// use actix_session::CookieSession;
193
/// use actix_web::{web, App, HttpResponse, HttpServer};
194
///
195
/// let app = App::new().wrap(
196
///     CookieSession::signed(&[0; 32])
197
///         .domain("www.rust-lang.org")
198
///         .name("actix_session")
199
///         .path("/")
200
///         .secure(true))
201
///     .service(web::resource("/").to(|| HttpResponse::Ok()));
202
/// ```
203
pub struct CookieSession(Rc<CookieSessionInner>);
204

205
impl CookieSession {
206
    /// Construct new *signed* `CookieSession` instance.
207
    ///
208
    /// Panics if key length is less than 32 bytes.
209 1
    pub fn signed(key: &[u8]) -> CookieSession {
210 1
        CookieSession(Rc::new(CookieSessionInner::new(
211 0
            key,
212 1
            CookieSecurity::Signed,
213
        )))
214
    }
215

216
    /// Construct new *private* `CookieSession` instance.
217
    ///
218
    /// Panics if key length is less than 32 bytes.
219 1
    pub fn private(key: &[u8]) -> CookieSession {
220 1
        CookieSession(Rc::new(CookieSessionInner::new(
221 0
            key,
222 1
            CookieSecurity::Private,
223
        )))
224
    }
225

226
    /// Sets the `path` field in the session cookie being built.
227 1
    pub fn path<S: Into<String>>(mut self, value: S) -> CookieSession {
228 1
        Rc::get_mut(&mut self.0).unwrap().path = value.into();
229 1
        self
230
    }
231

232
    /// Sets the `name` field in the session cookie being built.
233 1
    pub fn name<S: Into<String>>(mut self, value: S) -> CookieSession {
234 1
        Rc::get_mut(&mut self.0).unwrap().name = value.into();
235 1
        self
236
    }
237

238
    /// Sets the `domain` field in the session cookie being built.
239 1
    pub fn domain<S: Into<String>>(mut self, value: S) -> CookieSession {
240 1
        Rc::get_mut(&mut self.0).unwrap().domain = Some(value.into());
241 1
        self
242
    }
243

244
    /// When true, prevents adding session cookies to responses until
245
    /// the session contains data. Default is `false`.
246
    ///
247
    /// Useful when trying to comply with laws that require consent for setting cookies.
248 1
    pub fn lazy(mut self, value: bool) -> CookieSession {
249 1
        Rc::get_mut(&mut self.0).unwrap().lazy = value;
250 1
        self
251
    }
252

253
    /// Sets the `secure` field in the session cookie being built.
254
    ///
255
    /// If the `secure` field is set, a cookie will only be transmitted when the
256
    /// connection is secure - i.e. `https`
257 1
    pub fn secure(mut self, value: bool) -> CookieSession {
258 1
        Rc::get_mut(&mut self.0).unwrap().secure = value;
259 1
        self
260
    }
261

262
    /// Sets the `http_only` field in the session cookie being built.
263 1
    pub fn http_only(mut self, value: bool) -> CookieSession {
264 1
        Rc::get_mut(&mut self.0).unwrap().http_only = value;
265 1
        self
266
    }
267

268
    /// Sets the `same_site` field in the session cookie being built.
269 1
    pub fn same_site(mut self, value: SameSite) -> CookieSession {
270 1
        Rc::get_mut(&mut self.0).unwrap().same_site = Some(value);
271 1
        self
272
    }
273

274
    /// Sets the `max-age` field in the session cookie being built.
275 1
    pub fn max_age(self, seconds: i64) -> CookieSession {
276 1
        self.max_age_time(Duration::seconds(seconds))
277
    }
278

279
    /// Sets the `max-age` field in the session cookie being built.
280 1
    pub fn max_age_time(mut self, value: time::Duration) -> CookieSession {
281 1
        Rc::get_mut(&mut self.0).unwrap().max_age = Some(value);
282 1
        self
283
    }
284

285
    /// Sets the `expires` field in the session cookie being built.
286 1
    pub fn expires_in(self, seconds: i64) -> CookieSession {
287 1
        self.expires_in_time(Duration::seconds(seconds))
288
    }
289

290
    /// Sets the `expires` field in the session cookie being built.
291 1
    pub fn expires_in_time(mut self, value: Duration) -> CookieSession {
292 1
        Rc::get_mut(&mut self.0).unwrap().expires_in = Some(value);
293 1
        self
294
    }
295
}
296

297
impl<S, B> Transform<S, ServiceRequest> for CookieSession
298
where
299
    S: Service<ServiceRequest, Response = ServiceResponse<B>>,
300
    S::Future: 'static,
301
    S::Error: 'static,
302
    B: MessageBody + 'static,
303
    B::Error: StdError,
304
{
305
    type Response = ServiceResponse;
306
    type Error = S::Error;
307
    type InitError = ();
308
    type Transform = CookieSessionMiddleware<S>;
309
    type Future = Ready<Result<Self::Transform, Self::InitError>>;
310

311 1
    fn new_transform(&self, service: S) -> Self::Future {
312 1
        ok(CookieSessionMiddleware {
313 1
            service,
314 1
            inner: self.0.clone(),
315
        })
316
    }
317
}
318

319
/// Cookie based session middleware.
320
pub struct CookieSessionMiddleware<S> {
321
    service: S,
322
    inner: Rc<CookieSessionInner>,
323
}
324

325
impl<S, B> Service<ServiceRequest> for CookieSessionMiddleware<S>
326
where
327
    S: Service<ServiceRequest, Response = ServiceResponse<B>>,
328
    S::Future: 'static,
329
    S::Error: 'static,
330
    B: MessageBody + 'static,
331
    B::Error: StdError,
332
{
333
    type Response = ServiceResponse;
334
    type Error = S::Error;
335
    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
336

337
    actix_service::forward_ready!(service);
338

339
    /// On first request, a new session cookie is returned in response, regardless
340
    /// of whether any session state is set.  With subsequent requests, if the
341
    /// session state changes, then set-cookie is returned in response.  As
342
    /// a user logs out, call session.purge() to set SessionStatus accordingly
343
    /// and this will trigger removal of the session cookie in the response.
344 1
    fn call(&self, mut req: ServiceRequest) -> Self::Future {
345 1
        let inner = self.inner.clone();
346 1
        let (is_new, state) = self.inner.load(&req);
347 1
        let prolong_expiration = self.inner.expires_in.is_some();
348 1
        Session::set_session(&mut req, state);
349

350 1
        let fut = self.service.call(req);
351

352 1
        async move {
353 1
            let mut res = fut.await?;
354

355 1
            let result = match Session::get_changes(&mut res) {
356 1
                (SessionStatus::Changed, state) | (SessionStatus::Renewed, state) => {
357 1
                    inner.set_cookie(&mut res, state)
358
                }
359

360 1
                (SessionStatus::Unchanged, state) if prolong_expiration => {
361 1
                    inner.set_cookie(&mut res, state)
362
                }
363

364
                // set a new session cookie upon first request (new client)
365 0
                (SessionStatus::Unchanged, _) => {
366 1
                    if is_new {
367 1
                        let state: HashMap<String, String> = HashMap::new();
368 1
                        inner.set_cookie(&mut res, state.into_iter())
369
                    } else {
370 1
                        Ok(())
371
                    }
372
                }
373

374 0
                (SessionStatus::Purged, _) => {
375 0
                    let _ = inner.remove_cookie(&mut res);
376 0
                    Ok(())
377
                }
378
            };
379

380 1
            match result {
381 1
                Ok(()) => Ok(res.map_body(|_, body| AnyBody::from_message(body))),
382 0
                Err(error) => Ok(res.error_response(error)),
383
            }
384
        }
385
        .boxed_local()
386
    }
387
}
388

389
#[cfg(test)]
390
mod tests {
391
    use super::*;
392
    use actix_web::web::Bytes;
393
    use actix_web::{test, web, App};
394

395 1
    #[actix_rt::test]
396 1
    async fn cookie_session() {
397 1
        let app = test::init_service(
398 1
            App::new()
399 1
                .wrap(CookieSession::signed(&[0; 32]).secure(false))
400 1
                .service(web::resource("/").to(|ses: Session| async move {
401 1
                    let _ = ses.insert("counter", 100);
402
                    "test"
403
                })),
404
        )
405
        .await;
406

407 1
        let request = test::TestRequest::get().to_request();
408 1
        let response = app.call(request).await.unwrap();
409 1
        assert!(response
410
            .response()
411
            .cookies()
412 1
            .any(|c| c.name() == "actix-session"));
413
    }
414

415 1
    #[actix_rt::test]
416 1
    async fn private_cookie() {
417 1
        let app = test::init_service(
418 1
            App::new()
419 1
                .wrap(CookieSession::private(&[0; 32]).secure(false))
420 1
                .service(web::resource("/").to(|ses: Session| async move {
421 1
                    let _ = ses.insert("counter", 100);
422
                    "test"
423
                })),
424
        )
425
        .await;
426

427 1
        let request = test::TestRequest::get().to_request();
428 1
        let response = app.call(request).await.unwrap();
429 1
        assert!(response
430
            .response()
431
            .cookies()
432 1
            .any(|c| c.name() == "actix-session"));
433
    }
434

435 1
    #[actix_rt::test]
436 1
    async fn lazy_cookie() {
437 1
        let app = test::init_service(
438 1
            App::new()
439 1
                .wrap(CookieSession::signed(&[0; 32]).secure(false).lazy(true))
440 1
                .service(web::resource("/count").to(|ses: Session| async move {
441 1
                    let _ = ses.insert("counter", 100);
442
                    "counting"
443
                }))
444 1
                .service(web::resource("/").to(|_ses: Session| async move { "test" })),
445
        )
446
        .await;
447

448 1
        let request = test::TestRequest::get().to_request();
449 1
        let response = app.call(request).await.unwrap();
450 1
        assert!(response.response().cookies().count() == 0);
451

452 1
        let request = test::TestRequest::with_uri("/count").to_request();
453 1
        let response = app.call(request).await.unwrap();
454

455 1
        assert!(response
456
            .response()
457
            .cookies()
458 1
            .any(|c| c.name() == "actix-session"));
459
    }
460

461 1
    #[actix_rt::test]
462 1
    async fn cookie_session_extractor() {
463 1
        let app = test::init_service(
464 1
            App::new()
465 1
                .wrap(CookieSession::signed(&[0; 32]).secure(false))
466 1
                .service(web::resource("/").to(|ses: Session| async move {
467 1
                    let _ = ses.insert("counter", 100);
468
                    "test"
469
                })),
470
        )
471
        .await;
472

473 1
        let request = test::TestRequest::get().to_request();
474 1
        let response = app.call(request).await.unwrap();
475 1
        assert!(response
476
            .response()
477
            .cookies()
478 1
            .any(|c| c.name() == "actix-session"));
479
    }
480

481 1
    #[actix_rt::test]
482 1
    async fn basics() {
483 1
        let app = test::init_service(
484 1
            App::new()
485
                .wrap(
486 1
                    CookieSession::signed(&[0; 32])
487
                        .path("/test/")
488
                        .name("actix-test")
489
                        .domain("localhost")
490
                        .http_only(true)
491 1
                        .same_site(SameSite::Lax)
492
                        .max_age(100),
493
                )
494 1
                .service(web::resource("/").to(|ses: Session| async move {
495 1
                    let _ = ses.insert("counter", 100);
496
                    "test"
497
                }))
498 1
                .service(web::resource("/test/").to(|ses: Session| async move {
499 1
                    let val: usize = ses.get("counter").unwrap().unwrap();
500 1
                    format!("counter: {}", val)
501
                })),
502
        )
503
        .await;
504

505 1
        let request = test::TestRequest::get().to_request();
506 1
        let response = app.call(request).await.unwrap();
507 1
        let cookie = response
508
            .response()
509
            .cookies()
510 1
            .find(|c| c.name() == "actix-test")
511
            .unwrap()
512
            .clone();
513 1
        assert_eq!(cookie.path().unwrap(), "/test/");
514

515
        let request = test::TestRequest::with_uri("/test/")
516 1
            .cookie(cookie)
517
            .to_request();
518 1
        let body = test::read_response(&app, request).await;
519 1
        assert_eq!(body, Bytes::from_static(b"counter: 100"));
520
    }
521

522 1
    #[actix_rt::test]
523 1
    async fn prolong_expiration() {
524 1
        let app = test::init_service(
525 1
            App::new()
526 1
                .wrap(CookieSession::signed(&[0; 32]).secure(false).expires_in(60))
527 1
                .service(web::resource("/").to(|ses: Session| async move {
528 1
                    let _ = ses.insert("counter", 100);
529
                    "test"
530
                }))
531
                .service(
532 1
                    web::resource("/test/")
533 1
                        .to(|| async move { "no-changes-in-session" }),
534
                ),
535
        )
536
        .await;
537

538 1
        let request = test::TestRequest::get().to_request();
539 1
        let response = app.call(request).await.unwrap();
540 1
        let expires_1 = response
541
            .response()
542
            .cookies()
543 1
            .find(|c| c.name() == "actix-session")
544
            .expect("Cookie is set")
545
            .expires()
546
            .expect("Expiration is set")
547
            .datetime()
548
            .expect("Expiration is a datetime");
549

550 1
        actix_rt::time::sleep(std::time::Duration::from_secs(1)).await;
551

552 1
        let request = test::TestRequest::with_uri("/test/").to_request();
553 1
        let response = app.call(request).await.unwrap();
554 1
        let expires_2 = response
555
            .response()
556
            .cookies()
557 1
            .find(|c| c.name() == "actix-session")
558
            .expect("Cookie is set")
559
            .expires()
560
            .expect("Expiration is set")
561
            .datetime()
562
            .expect("Expiration is a datetime");
563

564 1
        assert!(expires_2 - expires_1 >= Duration::seconds(1));
565
    }
566
}

Read our documentation on viewing source code .

Loading