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

3
use std::{collections::HashMap, rc::Rc};
4

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

15
use crate::{Session, SessionStatus};
16

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

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

29
impl ResponseError for CookieSessionError {}
30

31
enum CookieSecurity {
32
    Signed,
33
    Private,
34
}
35

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

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

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

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

78 1
        let value =
79 0
            serde_json::to_string(&state).map_err(CookieSessionError::Serialize)?;
80 1
        if value.len() > 4064 {
81 0
            return Err(CookieSessionError::Overflow.into());
82
        }
83

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

89 1
        if let Some(ref domain) = self.domain {
90 1
            cookie.set_domain(domain.clone());
91
        }
92

93 1
        if let Some(expires_in) = self.expires_in {
94 1
            cookie.set_expires(OffsetDateTime::now_utc() + expires_in);
95
        }
96

97 1
        if let Some(max_age) = self.max_age {
98 1
            cookie.set_max_age(max_age);
99
        }
100

101 1
        if let Some(same_site) = self.same_site {
102 1
            cookie.set_same_site(same_site);
103
        }
104

105 1
        let mut jar = CookieJar::new();
106

107 1
        match self.security {
108 1
            CookieSecurity::Signed => jar.signed(&self.key).add(cookie),
109 1
            CookieSecurity::Private => jar.private(&self.key).add(cookie),
110
        }
111

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

117 1
        Ok(())
118
    }
119

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

128 0
        let val = HeaderValue::from_str(&cookie.to_string())?;
129 0
        res.headers_mut().append(SET_COOKIE, val);
130

131 0
        Ok(())
132
    }
133

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

141 1
                    let cookie_opt = match self.security {
142 1
                        CookieSecurity::Signed => jar.signed(&self.key).get(&self.name),
143 0
                        CookieSecurity::Private => {
144 0
                            jar.private(&self.key).get(&self.name)
145
                        }
146
                    };
147 1
                    if let Some(cookie) = cookie_opt {
148 1
                        if let Ok(val) = serde_json::from_str(cookie.value()) {
149 1
                            return (false, val);
150
                        }
151
                    }
152
                }
153
            }
154
        }
155 1
        (true, HashMap::new())
156
    }
157
}
158

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

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

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

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

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

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

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

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

257
    /// Sets the `http_only` field in the session cookie being built.
258 1
    pub fn http_only(mut self, value: bool) -> CookieSession {
259 1
        Rc::get_mut(&mut self.0).unwrap().http_only = value;
260 1
        self
261
    }
262

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

269
    /// Sets the `max-age` field in the session cookie being built.
270 1
    pub fn max_age(self, seconds: i64) -> CookieSession {
271 1
        self.max_age_time(Duration::seconds(seconds))
272
    }
273

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

280
    /// Sets the `expires` field in the session cookie being built.
281 1
    pub fn expires_in(self, seconds: i64) -> CookieSession {
282 1
        self.expires_in_time(Duration::seconds(seconds))
283
    }
284

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

292
impl<S, B: 'static> Transform<S, ServiceRequest> for CookieSession
293
where
294
    S: Service<ServiceRequest, Response = ServiceResponse<B>>,
295
    S::Future: 'static,
296
    S::Error: 'static,
297
{
298
    type Response = ServiceResponse<B>;
299
    type Error = S::Error;
300
    type InitError = ();
301
    type Transform = CookieSessionMiddleware<S>;
302
    type Future = Ready<Result<Self::Transform, Self::InitError>>;
303

304 1
    fn new_transform(&self, service: S) -> Self::Future {
305 1
        ok(CookieSessionMiddleware {
306 1
            service,
307 1
            inner: self.0.clone(),
308
        })
309
    }
310
}
311

312
/// Cookie session middleware
313
pub struct CookieSessionMiddleware<S> {
314
    service: S,
315
    inner: Rc<CookieSessionInner>,
316
}
317

318
impl<S, B: 'static> Service<ServiceRequest> for CookieSessionMiddleware<S>
319
where
320
    S: Service<ServiceRequest, Response = ServiceResponse<B>>,
321
    S::Future: 'static,
322
    S::Error: 'static,
323
{
324
    type Response = ServiceResponse<B>;
325
    type Error = S::Error;
326
    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
327

328
    actix_service::forward_ready!(service);
329

330
    /// On first request, a new session cookie is returned in response, regardless
331
    /// of whether any session state is set.  With subsequent requests, if the
332
    /// session state changes, then set-cookie is returned in response.  As
333
    /// a user logs out, call session.purge() to set SessionStatus accordingly
334
    /// and this will trigger removal of the session cookie in the response.
335 1
    fn call(&self, mut req: ServiceRequest) -> Self::Future {
336 1
        let inner = self.inner.clone();
337 1
        let (is_new, state) = self.inner.load(&req);
338 1
        let prolong_expiration = self.inner.expires_in.is_some();
339 1
        Session::set_session(state, &mut req);
340

341 1
        let fut = self.service.call(req);
342

343 1
        async move {
344 1
            fut.await.map(|mut res| {
345 1
                match Session::get_changes(&mut res) {
346 1
                    (SessionStatus::Changed, Some(state))
347 0
                    | (SessionStatus::Renewed, Some(state)) => {
348 1
                        res.checked_expr(|res| inner.set_cookie(res, state))
349
                    }
350 1
                    (SessionStatus::Unchanged, Some(state)) if prolong_expiration => {
351 1
                        res.checked_expr(|res| inner.set_cookie(res, state))
352
                    }
353 0
                    (SessionStatus::Unchanged, _) =>
354
                    // set a new session cookie upon first request (new client)
355
                    {
356 1
                        if is_new {
357 1
                            let state: HashMap<String, String> = HashMap::new();
358 1
                            res.checked_expr(|res| {
359 1
                                inner.set_cookie(res, state.into_iter())
360
                            })
361
                        } else {
362 1
                            res
363
                        }
364
                    }
365 0
                    (SessionStatus::Purged, _) => {
366 0
                        let _ = inner.remove_cookie(&mut res);
367 0
                        res
368
                    }
369 0
                    _ => res,
370
                }
371
            })
372
        }
373
        .boxed_local()
374
    }
375
}
376

377
#[cfg(test)]
378
mod tests {
379
    use super::*;
380
    use actix_web::web::Bytes;
381
    use actix_web::{test, web, App};
382

383 1
    #[actix_rt::test]
384 1
    async fn cookie_session() {
385 1
        let app = test::init_service(
386 1
            App::new()
387 1
                .wrap(CookieSession::signed(&[0; 32]).secure(false))
388 1
                .service(web::resource("/").to(|ses: Session| async move {
389 1
                    let _ = ses.set("counter", 100);
390
                    "test"
391
                })),
392
        )
393
        .await;
394

395 1
        let request = test::TestRequest::get().to_request();
396 1
        let response = app.call(request).await.unwrap();
397 1
        assert!(response
398
            .response()
399
            .cookies()
400 1
            .any(|c| c.name() == "actix-session"));
401
    }
402

403 1
    #[actix_rt::test]
404 1
    async fn private_cookie() {
405 1
        let app = test::init_service(
406 1
            App::new()
407 1
                .wrap(CookieSession::private(&[0; 32]).secure(false))
408 1
                .service(web::resource("/").to(|ses: Session| async move {
409 1
                    let _ = ses.set("counter", 100);
410
                    "test"
411
                })),
412
        )
413
        .await;
414

415 1
        let request = test::TestRequest::get().to_request();
416 1
        let response = app.call(request).await.unwrap();
417 1
        assert!(response
418
            .response()
419
            .cookies()
420 1
            .any(|c| c.name() == "actix-session"));
421
    }
422

423 1
    #[actix_rt::test]
424 1
    async fn lazy_cookie() {
425 1
        let app = test::init_service(
426 1
            App::new()
427 1
                .wrap(CookieSession::signed(&[0; 32]).secure(false).lazy(true))
428 1
                .service(web::resource("/count").to(|ses: Session| async move {
429 1
                    let _ = ses.set("counter", 100);
430
                    "counting"
431
                }))
432 1
                .service(web::resource("/").to(|_ses: Session| async move { "test" })),
433
        )
434
        .await;
435

436 1
        let request = test::TestRequest::get().to_request();
437 1
        let response = app.call(request).await.unwrap();
438 1
        assert!(response.response().cookies().count() == 0);
439

440 1
        let request = test::TestRequest::with_uri("/count").to_request();
441 1
        let response = app.call(request).await.unwrap();
442

443 1
        assert!(response
444
            .response()
445
            .cookies()
446 1
            .any(|c| c.name() == "actix-session"));
447
    }
448

449 1
    #[actix_rt::test]
450 1
    async fn cookie_session_extractor() {
451 1
        let app = test::init_service(
452 1
            App::new()
453 1
                .wrap(CookieSession::signed(&[0; 32]).secure(false))
454 1
                .service(web::resource("/").to(|ses: Session| async move {
455 1
                    let _ = ses.set("counter", 100);
456
                    "test"
457
                })),
458
        )
459
        .await;
460

461 1
        let request = test::TestRequest::get().to_request();
462 1
        let response = app.call(request).await.unwrap();
463 1
        assert!(response
464
            .response()
465
            .cookies()
466 1
            .any(|c| c.name() == "actix-session"));
467
    }
468

469 1
    #[actix_rt::test]
470 1
    async fn basics() {
471 1
        let app = test::init_service(
472 1
            App::new()
473
                .wrap(
474 1
                    CookieSession::signed(&[0; 32])
475
                        .path("/test/")
476
                        .name("actix-test")
477
                        .domain("localhost")
478
                        .http_only(true)
479 1
                        .same_site(SameSite::Lax)
480
                        .max_age(100),
481
                )
482 1
                .service(web::resource("/").to(|ses: Session| async move {
483 1
                    let _ = ses.set("counter", 100);
484
                    "test"
485
                }))
486 1
                .service(web::resource("/test/").to(|ses: Session| async move {
487 1
                    let val: usize = ses.get("counter").unwrap().unwrap();
488 1
                    format!("counter: {}", val)
489
                })),
490
        )
491
        .await;
492

493 1
        let request = test::TestRequest::get().to_request();
494 1
        let response = app.call(request).await.unwrap();
495 1
        let cookie = response
496
            .response()
497
            .cookies()
498 1
            .find(|c| c.name() == "actix-test")
499
            .unwrap()
500
            .clone();
501 1
        assert_eq!(cookie.path().unwrap(), "/test/");
502

503
        let request = test::TestRequest::with_uri("/test/")
504 1
            .cookie(cookie)
505
            .to_request();
506 1
        let body = test::read_response(&app, request).await;
507 1
        assert_eq!(body, Bytes::from_static(b"counter: 100"));
508
    }
509

510 1
    #[actix_rt::test]
511 1
    async fn prolong_expiration() {
512 1
        let app = test::init_service(
513 1
            App::new()
514 1
                .wrap(CookieSession::signed(&[0; 32]).secure(false).expires_in(60))
515 1
                .service(web::resource("/").to(|ses: Session| async move {
516 1
                    let _ = ses.set("counter", 100);
517
                    "test"
518
                }))
519
                .service(
520 1
                    web::resource("/test/")
521 1
                        .to(|| async move { "no-changes-in-session" }),
522
                ),
523
        )
524
        .await;
525

526 1
        let request = test::TestRequest::get().to_request();
527 1
        let response = app.call(request).await.unwrap();
528 1
        let expires_1 = response
529
            .response()
530
            .cookies()
531 1
            .find(|c| c.name() == "actix-session")
532
            .expect("Cookie is set")
533
            .expires()
534
            .expect("Expiration is set");
535

536 1
        actix_rt::time::sleep(std::time::Duration::from_secs(1)).await;
537

538 1
        let request = test::TestRequest::with_uri("/test/").to_request();
539 1
        let response = app.call(request).await.unwrap();
540 1
        let expires_2 = 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

548 1
        assert!(expires_2 - expires_1 >= Duration::seconds(1));
549
    }
550
}

Read our documentation on viewing source code .

Loading