actix_extended_session/
lib.rs

1//! Extended session management for Actix Web.
2//!
3//! `actix-extended-session` provides an easy-to-use framework to manage sessions in applications built on
4//! top of Actix Web. [`SessionMiddleware`] is the middleware underpinning the functionality
5//! provided by `actix-extended-session`; it takes care of all the session cookie handling and instructs the
6//! **storage backend** to create/delete/update the session state based on the operations performed
7//! against the active [`Session`].
8//!
9//! Further reading on sessions:
10//! - [RFC6265](https://datatracker.ietf.org/doc/html/rfc6265);
11//! - [OWASP's session management cheat-sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html).
12//!
13//! # Getting started
14//! To start using sessions in your Actix Web application you must register [`SessionMiddleware`]
15//! as a middleware on your `App`:
16//!
17//! ```no_run
18//! use actix_web::{web, App, HttpServer, HttpResponse, Error};
19//! use actix_extended_session::{Session, SessionMiddleware, storage::CookieSessionStore};
20//! use actix_web::cookie::Key;
21//!
22//! #[actix_web::main]
23//! async fn main() -> std::io::Result<()> {
24//!     // The secret key would usually be read from a configuration file/environment variables.
25//!     let secret_key = Key::generate();
26//!     HttpServer::new(move ||
27//!             App::new()
28//!             // Add session management to your application using Redis for session state storage
29//!             .wrap(
30//!                 SessionMiddleware::new(
31//!                     CookieSessionStore::default(),
32//!                     secret_key.clone()
33//!                 )
34//!             )
35//!             .default_service(web::to(|| HttpResponse::Ok())))
36//!         .bind(("127.0.0.1", 8080))?
37//!         .run()
38//!         .await
39//! }
40//! ```
41//!
42//! The session state can be accessed and modified by your request handlers using the [`Session`]
43//! extractor. Note that this doesn't work in the stream of a streaming response.
44//!
45//! ```no_run
46//! use actix_web::Error;
47//! use actix_extended_session::Session;
48//! use serde_json::Value;
49//!
50//! fn index(session: Session) -> Result<&'static str, Error> {
51//!     // access the session state
52//!     if let Some(count) = session.get::<i32>("counter")? {
53//!         println!("SESSION value: {}", count);
54//!         // modify the session state
55//!         session.insert("counter", Value::from(count + 1));
56//!     } else {
57//!         session.insert("counter", Value::from(1));
58//!     }
59//!
60//!     Ok("Welcome!")
61//! }
62//! ```
63//!
64//! [`SessionStore`]: storage::SessionStore
65//! [`CookieSessionStore`]: storage::CookieSessionStore
66//! [`RedisSessionStore`]: storage::RedisSessionStore
67//! [`RedisActorSessionStore`]: storage::RedisActorSessionStore
68
69#![forbid(unsafe_code)]
70#![deny(rust_2018_idioms, nonstandard_style)]
71#![warn(future_incompatible, missing_docs)]
72#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
73#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
74#![cfg_attr(docsrs, feature(doc_auto_cfg))]
75
76pub mod config;
77mod middleware;
78mod session;
79mod session_ext;
80pub mod storage;
81
82pub use self::{
83    middleware::SessionMiddleware,
84    session::{Session, SessionGetError, SessionStatus},
85    session_ext::SessionExt,
86};
87
88#[cfg(test)]
89pub mod test_helpers {
90    use crate::storage::SessionStore;
91    use actix_web::cookie::Key;
92
93    /// Generate a random cookie signing/encryption key.
94    pub fn key() -> Key {
95        Key::generate()
96    }
97
98    /// A ready-to-go acceptance test suite to verify that sessions behave as expected
99    /// regardless of the underlying session store.
100    ///
101    /// `is_invalidation_supported` must be set to `true` if the backend supports
102    /// "remembering" that a session has been invalidated (e.g. by logging out).
103    /// It should be to `false` if the backend allows multiple cookies to be active
104    /// at the same time (e.g. cookie store backend).
105    pub async fn acceptance_test_suite<F, Store>(store_builder: F, is_invalidation_supported: bool)
106    where
107        Store: SessionStore + 'static,
108        F: Fn() -> Store + Clone + Send + 'static,
109    {
110        acceptance_tests::basic_workflow(store_builder.clone()).await;
111        acceptance_tests::expiration_is_refreshed_on_changes(store_builder.clone()).await;
112        acceptance_tests::complex_workflow(store_builder.clone(), is_invalidation_supported).await;
113        acceptance_tests::lifecycle(store_builder.clone()).await;
114        acceptance_tests::guard(store_builder.clone()).await;
115    }
116
117    mod acceptance_tests {
118        use actix_web::{
119            cookie::time,
120            dev::{Service, ServiceResponse},
121            guard, middleware, test,
122            web::{self, get, post, resource, Bytes},
123            App, HttpResponse, Result,
124        };
125        use serde::{Deserialize, Serialize};
126        use serde_json::{json, Value};
127
128        use crate::config::SessionLifecycle;
129        use crate::{
130            storage::SessionStore, test_helpers::key, Session, SessionExt, SessionMiddleware,
131        };
132
133        pub(super) async fn basic_workflow<F, Store>(store_builder: F)
134        where
135            Store: SessionStore + 'static,
136            F: Fn() -> Store + Clone + Send + 'static,
137        {
138            let app = test::init_service(
139                App::new()
140                    .wrap(
141                        SessionMiddleware::builder(store_builder(), key())
142                            .cookie_path("/test/".into())
143                            .cookie_name("actix-test".into())
144                            .cookie_domain(Some("localhost".into()))
145                            .session_ttl(time::Duration::seconds(100))
146                            .build(),
147                    )
148                    .service(web::resource("/").to(|ses: Session| async move {
149                        ses.insert("user_id", Value::from(1));
150                        ses.insert("counter", Value::from(100));
151                        "test"
152                    }))
153                    .service(web::resource("/test/").to(|ses: Session| async move {
154                        let val: usize = ses.get("counter").unwrap().unwrap();
155                        format!("counter: {val}")
156                    })),
157            )
158            .await;
159
160            let request = test::TestRequest::get().to_request();
161            let response = app.call(request).await.unwrap();
162            let cookie = response.get_cookie("actix-test").unwrap().clone();
163            assert_eq!(cookie.path().unwrap(), "/test/");
164
165            let request = test::TestRequest::with_uri("/test/")
166                .cookie(cookie)
167                .to_request();
168            let body = test::call_and_read_body(&app, request).await;
169            assert_eq!(body, Bytes::from_static(b"counter: 100"));
170        }
171
172        pub(super) async fn expiration_is_refreshed_on_changes<F, Store>(store_builder: F)
173        where
174            Store: SessionStore + 'static,
175            F: Fn() -> Store + Clone + Send + 'static,
176        {
177            let session_ttl = time::Duration::seconds(60);
178            let app = test::init_service(
179                App::new()
180                    .wrap(
181                        SessionMiddleware::builder(store_builder(), key())
182                            .session_ttl(session_ttl)
183                            .build(),
184                    )
185                    .service(web::resource("/").to(|ses: Session| async move {
186                        ses.insert("counter", Value::from(100));
187                        "test"
188                    }))
189                    .service(web::resource("/test/").to(|| async move { "no-changes-in-session" })),
190            )
191            .await;
192
193            let request = test::TestRequest::get().to_request();
194            let response = app.call(request).await.unwrap();
195            let cookie_1 = response.get_cookie("id").expect("Cookie is set");
196            assert_eq!(cookie_1.max_age(), Some(session_ttl));
197
198            let request = test::TestRequest::with_uri("/test/")
199                .cookie(cookie_1.clone())
200                .to_request();
201            let response = app.call(request).await.unwrap();
202            assert!(response.response().cookies().next().is_none());
203
204            let request = test::TestRequest::get().cookie(cookie_1).to_request();
205            let response = app.call(request).await.unwrap();
206            let cookie_2 = response.get_cookie("id").expect("Cookie is set");
207            assert_eq!(cookie_2.max_age(), Some(session_ttl));
208        }
209
210        pub(super) async fn lifecycle<F, Store>(store_builder: F)
211        where
212            Store: SessionStore + 'static,
213            F: Fn() -> Store + Clone + Send + 'static,
214        {
215            let session_ttl = time::Duration::seconds(60);
216            let app = test::init_service(
217                App::new()
218                    .wrap(
219                        SessionMiddleware::builder(store_builder(), key())
220                            .session_ttl(session_ttl)
221                            .build(),
222                    )
223                    .service(web::resource("/").to(|ses: Session| async move {
224                        ses.insert("counter", Value::from(100));
225                        "test"
226                    }))
227                    .service(
228                        web::resource("/switch_lifecycle/").to(|ses: Session| async move {
229                            ses.insert("counter", Value::from(100));
230                            ses.set_lifecycle(SessionLifecycle::BrowserSession);
231                            "ok"
232                        }),
233                    ),
234            )
235            .await;
236
237            // Should set a persistent session cookie
238            let request = test::TestRequest::get().to_request();
239            let response = app.call(request).await.unwrap();
240            let cookie_1 = response.get_cookie("id").expect("Cookie is set");
241            assert_eq!(cookie_1.max_age(), Some(session_ttl));
242
243            // Switch session lifecycle
244            let request = test::TestRequest::with_uri("/switch_lifecycle/")
245                .cookie(cookie_1.clone())
246                .to_request();
247            let response = app.call(request).await.unwrap();
248            let cookie_1 = response.get_cookie("id").expect("Cookie is set");
249            assert!(response.response().status().is_success());
250
251            // Should set a browser session cookie
252            let request = test::TestRequest::get().cookie(cookie_1).to_request();
253            let response = app.call(request).await.unwrap();
254            let cookie_2 = response.get_cookie("id").expect("Cookie is set");
255            assert_eq!(cookie_2.max_age(), None);
256        }
257
258        pub(super) async fn guard<F, Store>(store_builder: F)
259        where
260            Store: SessionStore + 'static,
261            F: Fn() -> Store + Clone + Send + 'static,
262        {
263            let srv = actix_test::start(move || {
264                App::new()
265                    .wrap(
266                        SessionMiddleware::builder(store_builder(), key())
267                            .cookie_name("test-session".into())
268                            .session_ttl(time::Duration::days(7))
269                            .build(),
270                    )
271                    .wrap(middleware::Logger::default())
272                    .service(resource("/").route(get().to(index)))
273                    .service(resource("/do_something").route(post().to(do_something)))
274                    .service(resource("/login").route(post().to(login)))
275                    .service(resource("/logout").route(post().to(logout)))
276                    .service(
277                        web::scope("/protected")
278                            .guard(guard::fn_guard(|g| {
279                                g.get_session().get::<String>("user_id").unwrap().is_some()
280                            }))
281                            .service(resource("/count").route(get().to(count))),
282                    )
283            });
284
285            // Step 1: GET without session info
286            //   - response should be a unsuccessful status
287            let req_1 = srv.get("/protected/count").send();
288            let resp_1 = req_1.await.unwrap();
289            assert!(!resp_1.status().is_success());
290
291            // Step 2: POST to login
292            //   - set-cookie actix-session will be in response  (session cookie #1)
293            //   - updates session state: {"counter": 0, "user_id": "ferris"}
294            let req_2 = srv.post("/login").send_json(&json!({"user_id": "ferris"}));
295            let resp_2 = req_2.await.unwrap();
296            let cookie_1 = resp_2
297                .cookies()
298                .unwrap()
299                .clone()
300                .into_iter()
301                .find(|c| c.name() == "test-session")
302                .unwrap();
303
304            // Step 3: POST to do_something
305            //   - adds new session state:  {"counter": 1, "user_id": "ferris" }
306            //   - set-cookie actix-session should be in response (session cookie #2)
307            //   - response should be: {"counter": 1, "user_id": None}
308            let req_3 = srv.post("/do_something").cookie(cookie_1.clone()).send();
309            let mut resp_3 = req_3.await.unwrap();
310            let result_3 = resp_3.json::<IndexResponse>().await.unwrap();
311            assert_eq!(
312                result_3,
313                IndexResponse {
314                    user_id: Some("ferris".into()),
315                    counter: 1
316                }
317            );
318            let cookie_2 = resp_3
319                .cookies()
320                .unwrap()
321                .clone()
322                .into_iter()
323                .find(|c| c.name() == "test-session")
324                .unwrap();
325
326            // Step 4: GET using a existing user id
327            //   - response should be: {"counter": 3, "user_id": "ferris"}
328            let req_4 = srv.get("/protected/count").cookie(cookie_2.clone()).send();
329            let mut resp_4 = req_4.await.unwrap();
330            let result_4 = resp_4.json::<IndexResponse>().await.unwrap();
331            assert_eq!(
332                result_4,
333                IndexResponse {
334                    user_id: Some("ferris".into()),
335                    counter: 1
336                }
337            );
338        }
339
340        pub(super) async fn complex_workflow<F, Store>(
341            store_builder: F,
342            is_invalidation_supported: bool,
343        ) where
344            Store: SessionStore + 'static,
345            F: Fn() -> Store + Clone + Send + 'static,
346        {
347            let session_ttl = time::Duration::days(7);
348            let srv = actix_test::start(move || {
349                App::new()
350                    .wrap(
351                        SessionMiddleware::builder(store_builder(), key())
352                            .cookie_name("test-session".into())
353                            .session_ttl(session_ttl)
354                            .build(),
355                    )
356                    .wrap(middleware::Logger::default())
357                    .service(resource("/").route(get().to(index)))
358                    .service(resource("/do_something").route(post().to(do_something)))
359                    .service(resource("/login").route(post().to(login)))
360                    .service(resource("/logout").route(post().to(logout)))
361            });
362
363            // Step 1:  GET index
364            //   - set-cookie actix-session should NOT be in response (session data is empty)
365            //   - response should be: {"counter": 0, "user_id": None}
366            let req_1a = srv.get("/").send();
367            let mut resp_1 = req_1a.await.unwrap();
368            assert!(resp_1.cookies().unwrap().is_empty());
369            let result_1 = resp_1.json::<IndexResponse>().await.unwrap();
370            assert_eq!(
371                result_1,
372                IndexResponse {
373                    user_id: None,
374                    counter: 0
375                }
376            );
377
378            // Step 2: POST to do_something
379            //   - adds new session state in redis:  {"counter": 1}
380            //   - set-cookie actix-session should be in response (session cookie #1)
381            //   - response should be: {"counter": 1, "user_id": None}
382            let req_2 = srv.post("/do_something").send();
383            let mut resp_2 = req_2.await.unwrap();
384            let result_2 = resp_2.json::<IndexResponse>().await.unwrap();
385            assert_eq!(
386                result_2,
387                IndexResponse {
388                    user_id: None,
389                    counter: 1
390                }
391            );
392            let cookie_1 = resp_2
393                .cookies()
394                .unwrap()
395                .clone()
396                .into_iter()
397                .find(|c| c.name() == "test-session")
398                .unwrap();
399            assert_eq!(cookie_1.max_age(), Some(session_ttl));
400
401            // Step 3:  GET index, including session cookie #1 in request
402            //   - set-cookie will *not* be in response
403            //   - response should be: {"counter": 1, "user_id": None}
404            let req_3 = srv.get("/").cookie(cookie_1.clone()).send();
405            let mut resp_3 = req_3.await.unwrap();
406            assert!(resp_3.cookies().unwrap().is_empty());
407            let result_3 = resp_3.json::<IndexResponse>().await.unwrap();
408            assert_eq!(
409                result_3,
410                IndexResponse {
411                    user_id: None,
412                    counter: 1
413                }
414            );
415
416            // Step 4: POST again to do_something, including session cookie #1 in request
417            //   - set-cookie will be in response (session cookie #2)
418            //   - updates session state:  {"counter": 2}
419            //   - response should be: {"counter": 2, "user_id": None}
420            let req_4 = srv.post("/do_something").cookie(cookie_1.clone()).send();
421            let mut resp_4 = req_4.await.unwrap();
422            let result_4 = resp_4.json::<IndexResponse>().await.unwrap();
423            assert_eq!(
424                result_4,
425                IndexResponse {
426                    user_id: None,
427                    counter: 2
428                }
429            );
430            let cookie_2 = resp_4
431                .cookies()
432                .unwrap()
433                .clone()
434                .into_iter()
435                .find(|c| c.name() == "test-session")
436                .unwrap();
437            assert_eq!(cookie_2.max_age(), cookie_1.max_age());
438
439            // Step 5: POST to login, including session cookie #2 in request
440            //   - set-cookie actix-session will be in response  (session cookie #3)
441            //   - updates session state: {"counter": 2, "user_id": "ferris"}
442            let req_5 = srv
443                .post("/login")
444                .cookie(cookie_2.clone())
445                .send_json(&json!({"user_id": "ferris"}));
446            let mut resp_5 = req_5.await.unwrap();
447            let cookie_3 = resp_5
448                .cookies()
449                .unwrap()
450                .clone()
451                .into_iter()
452                .find(|c| c.name() == "test-session")
453                .unwrap();
454            assert_ne!(cookie_2.value(), cookie_3.value());
455
456            let result_5 = resp_5.json::<IndexResponse>().await.unwrap();
457            assert_eq!(
458                result_5,
459                IndexResponse {
460                    user_id: Some("ferris".into()),
461                    counter: 2
462                }
463            );
464
465            // Step 6: GET index, including session cookie #3 in request
466            //   - response should be: {"counter": 2, "user_id": "ferris"}
467            let req_6 = srv.get("/").cookie(cookie_3.clone()).send();
468            let mut resp_6 = req_6.await.unwrap();
469            let result_6 = resp_6.json::<IndexResponse>().await.unwrap();
470            assert_eq!(
471                result_6,
472                IndexResponse {
473                    user_id: Some("ferris".into()),
474                    counter: 2
475                }
476            );
477
478            // Step 7: POST again to do_something, including session cookie #3 in request
479            //   - updates session state: {"counter": 3, "user_id": "ferris"}
480            //   - response should be: {"counter": 3, "user_id": "ferris"}
481            let req_7 = srv.post("/do_something").cookie(cookie_3.clone()).send();
482            let mut resp_7 = req_7.await.unwrap();
483            let result_7 = resp_7.json::<IndexResponse>().await.unwrap();
484            assert_eq!(
485                result_7,
486                IndexResponse {
487                    user_id: Some("ferris".into()),
488                    counter: 3
489                }
490            );
491
492            // Step 8: GET index, including session cookie #2 in request
493            // If invalidation is supported, no state will be found associated to this session.
494            // If invalidation is not supported, the old state will still be retrieved.
495            let req_8 = srv.get("/").cookie(cookie_2.clone()).send();
496            let mut resp_8 = req_8.await.unwrap();
497            if is_invalidation_supported {
498                assert!(resp_8.cookies().unwrap().is_empty());
499                let result_8 = resp_8.json::<IndexResponse>().await.unwrap();
500                assert_eq!(
501                    result_8,
502                    IndexResponse {
503                        user_id: None,
504                        counter: 0
505                    }
506                );
507            } else {
508                let result_8 = resp_8.json::<IndexResponse>().await.unwrap();
509                assert_eq!(
510                    result_8,
511                    IndexResponse {
512                        user_id: None,
513                        counter: 2
514                    }
515                );
516            }
517
518            // Step 9: POST to logout, including session cookie #3
519            //   - set-cookie actix-session will be in response with session cookie #3
520            //     invalidation logic
521            let req_9 = srv.post("/logout").cookie(cookie_3.clone()).send();
522            let resp_9 = req_9.await.unwrap();
523            let cookie_3 = resp_9
524                .cookies()
525                .unwrap()
526                .clone()
527                .into_iter()
528                .find(|c| c.name() == "test-session")
529                .unwrap();
530            assert_eq!(0, cookie_3.max_age().map(|t| t.whole_seconds()).unwrap());
531            assert_eq!("/", cookie_3.path().unwrap());
532
533            // Step 10: GET index, including session cookie #3 in request
534            //   - set-cookie actix-session should NOT be in response if invalidation is supported
535            //   - response should be: {"counter": 0, "user_id": None}
536            let req_10 = srv.get("/").cookie(cookie_3.clone()).send();
537            let mut resp_10 = req_10.await.unwrap();
538            if is_invalidation_supported {
539                assert!(resp_10.cookies().unwrap().is_empty());
540            }
541            let result_10 = resp_10.json::<IndexResponse>().await.unwrap();
542            assert_eq!(
543                result_10,
544                IndexResponse {
545                    user_id: None,
546                    counter: 0
547                }
548            );
549        }
550
551        #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
552        pub struct IndexResponse {
553            user_id: Option<String>,
554            counter: i32,
555        }
556
557        async fn index(session: Session) -> Result<HttpResponse> {
558            let user_id: Option<String> = session.get::<String>("user_id").unwrap();
559            let counter: i32 = session
560                .get::<i32>("counter")
561                .unwrap_or(Some(0))
562                .unwrap_or(0);
563
564            Ok(HttpResponse::Ok().json(&IndexResponse { user_id, counter }))
565        }
566
567        async fn do_something(session: Session) -> Result<HttpResponse> {
568            let user_id: Option<String> = session.get::<String>("user_id").unwrap();
569            let counter: i32 = session
570                .get::<i32>("counter")
571                .unwrap_or(Some(0))
572                .map_or(1, |inner| inner + 1);
573            session.insert("counter", Value::from(counter));
574
575            Ok(HttpResponse::Ok().json(&IndexResponse { user_id, counter }))
576        }
577
578        async fn count(session: Session) -> Result<HttpResponse> {
579            let user_id: Option<String> = session.get::<String>("user_id").unwrap();
580            let counter: i32 = session.get::<i32>("counter").unwrap().unwrap();
581
582            Ok(HttpResponse::Ok().json(&IndexResponse { user_id, counter }))
583        }
584
585        #[derive(Deserialize)]
586        struct Identity {
587            user_id: String,
588        }
589
590        async fn login(user_id: web::Json<Identity>, session: Session) -> Result<HttpResponse> {
591            let id = user_id.into_inner().user_id;
592            session.insert("user_id", Value::from(id.clone()));
593            session.renew();
594
595            let counter: i32 = session
596                .get::<i32>("counter")
597                .unwrap_or(Some(0))
598                .unwrap_or(0);
599
600            Ok(HttpResponse::Ok().json(&IndexResponse {
601                user_id: Some(id),
602                counter,
603            }))
604        }
605
606        async fn logout(session: Session) -> Result<HttpResponse> {
607            let id: Option<String> = session.get("user_id")?;
608
609            let body = if let Some(id) = id {
610                session.purge();
611                format!("Logged out: {id}")
612            } else {
613                "Could not log out anonymous user".to_owned()
614            };
615
616            Ok(HttpResponse::Ok().body(body))
617        }
618
619        trait ServiceResponseExt {
620            fn get_cookie(&self, cookie_name: &str) -> Option<actix_web::cookie::Cookie<'_>>;
621        }
622
623        impl ServiceResponseExt for ServiceResponse {
624            fn get_cookie(&self, cookie_name: &str) -> Option<actix_web::cookie::Cookie<'_>> {
625                self.response().cookies().find(|c| c.name() == cookie_name)
626            }
627        }
628    }
629}