From 7e6335a09fa21302cec29904543d707b5da867a3 Mon Sep 17 00:00:00 2001 From: Luca Palmieri Date: Sat, 5 Mar 2022 23:22:14 +0000 Subject: [PATCH] Rework actix session (#212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rob Ede Co-authored-by: Luca P Co-authored-by: Sebastian Rollén <38324289+SebRollen@users.noreply.github.com> --- .github/workflows/ci.yml | 11 +- Cargo.toml | 8 +- actix-redis/CHANGES.md | 7 + actix-redis/Cargo.toml | 32 +- actix-redis/README.md | 35 +- actix-redis/src/lib.rs | 12 +- actix-redis/src/session.rs | 684 -------------- actix-session/CHANGES.md | 19 + actix-session/Cargo.toml | 50 +- actix-session/README.md | 2 +- .../examples/authentication.rs | 33 +- .../examples/basic.rs | 22 +- actix-session/src/_error.rs | 66 -- actix-session/src/cookie.rs | 562 ----------- actix-session/src/lib.rs | 889 +++++++++++------- actix-session/src/middleware.rs | 646 +++++++++++++ actix-session/src/session.rs | 256 +++++ actix-session/src/session_ext.rs | 31 + actix-session/src/storage/cookie.rs | 116 +++ actix-session/src/storage/interface.rs | 104 ++ actix-session/src/storage/mod.rs | 28 + actix-session/src/storage/redis_actor.rs | 294 ++++++ actix-session/src/storage/redis_rs.rs | 297 ++++++ actix-session/src/storage/session_key.rs | 59 ++ actix-session/src/storage/utils.rs | 19 + actix-session/tests/middleware.rs | 56 ++ actix-session/tests/session.rs | 70 ++ 27 files changed, 2647 insertions(+), 1761 deletions(-) delete mode 100644 actix-redis/src/session.rs rename {actix-redis => actix-session}/examples/authentication.rs (68%) rename {actix-redis => actix-session}/examples/basic.rs (51%) delete mode 100644 actix-session/src/_error.rs delete mode 100644 actix-session/src/cookie.rs create mode 100644 actix-session/src/middleware.rs create mode 100644 actix-session/src/session.rs create mode 100644 actix-session/src/session_ext.rs create mode 100644 actix-session/src/storage/cookie.rs create mode 100644 actix-session/src/storage/interface.rs create mode 100644 actix-session/src/storage/mod.rs create mode 100644 actix-session/src/storage/redis_actor.rs create mode 100644 actix-session/src/storage/redis_rs.rs create mode 100644 actix-session/src/storage/session_key.rs create mode 100644 actix-session/src/storage/utils.rs create mode 100644 actix-session/tests/middleware.rs create mode 100644 actix-session/tests/session.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39cbd656d..eef45b3f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,10 +22,15 @@ jobs: services: redis: - image: redis:5.0.7 + image: redis:6 ports: - 6379:6379 - options: --entrypoint redis-server + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + --entrypoint redis-server steps: - uses: actions/checkout@v2 @@ -126,7 +131,7 @@ jobs: timeout-minutes: 40 with: command: ci-test - args: --exclude=actix-redis -- --nocapture + args: --exclude=actix-redis --exclude=actix-session -- --nocapture - name: Clear the cargo caches run: | diff --git a/Cargo.toml b/Cargo.toml index 19d6ac9a3..00626b697 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,12 @@ members = [ ] [patch.crates-io] -actix-cors = { path = "actix-cors" } -actix-session = { path = "actix-session" } +actix-cors = { path = "./actix-cors" } +actix-identity = { path = "./actix-identity" } +actix-protobuf = { path = "./actix-protobuf" } +actix-redis = { path = "./actix-redis" } +actix-session = { path = "./actix-session" } +actix-web-httpauth = { path = "./actix-web-httpauth" } # uncomment to quickly test against local actix-web repo # actix-http = { path = "../actix-web/actix-http" } diff --git a/actix-redis/CHANGES.md b/actix-redis/CHANGES.md index 771d8223f..81ac711ba 100644 --- a/actix-redis/CHANGES.md +++ b/actix-redis/CHANGES.md @@ -1,6 +1,13 @@ # Changes ## Unreleased - 2021-xx-xx +### Removed +- `RedisSession` has been removed. Check out `RedisActorSessionStore` in `actix-session` for a session store backed by Redis using `actix-redis`. [#212] + +### Changed +- Update `redis-async` dependency to `0.12`. [#212] + +[#212]: https://github.com/actix/actix-extras/pull/212 ## 0.10.0 - 2022-03-01 diff --git a/actix-redis/Cargo.toml b/actix-redis/Cargo.toml index c82960b62..e3bdc6914 100644 --- a/actix-redis/Cargo.toml +++ b/actix-redis/Cargo.toml @@ -2,7 +2,7 @@ name = "actix-redis" version = "0.10.0" authors = ["Nikolay Kim "] -description = "Redis integration for Actix and session store for Actix Web" +description = "Redis integration for Actix" license = "MIT OR Apache-2.0" keywords = ["actix", "redis", "async", "session"] homepage = "https://actix.rs" @@ -19,14 +19,7 @@ path = "src/lib.rs" default = ["web"] # actix-web integration -web = [ - "actix-web/cookies", - "actix-web/secure-cookies", - "actix-session/cookie-session", - "rand", - "serde", - "serde_json" -] +web = ["actix-web"] [dependencies] actix = { version = "0.12", default-features = false } @@ -35,32 +28,17 @@ actix-service = "2" actix-tls = { version = "3", default-features = false, features = ["connect"] } log = "0.4.6" -backoff = "0.2.1" +backoff = "0.4.0" derive_more = "0.99.5" futures-core = { version = "0.3.7", default-features = false } -redis2 = { package = "redis", version = "0.19", features = ["tokio-comp", "tokio-native-tls-comp"] } -redis-async = { version = "0.8", default-features = false, features = ["tokio10"] } +redis-async = { version = "0.12", default-features = false, features = ["tokio10"] } time = "0.3" tokio = { version = "1.13.1", features = ["sync"] } -tokio-util = "0.6" - -# web +tokio-util = "0.6.1" actix-web = { version = "4", default_features = false, optional = true } -actix-session = { version = "0.5", optional = true } -rand = { version = "0.8", optional = true } -serde = { version = "1.0.101", optional = true } -serde_json = { version = "1.0.40", optional = true } [dev-dependencies] actix-test = "0.1.0-beta.12" actix-web = { version = "4", default_features = false, features = ["macros"] } env_logger = "0.9" serde = { version = "1.0.101", features = ["derive"] } - -[[example]] -name = "basic" -required-features = ["web"] - -[[example]] -name = "authentication" -required-features = ["web"] diff --git a/actix-redis/README.md b/actix-redis/README.md index 202ad590c..3270d0752 100644 --- a/actix-redis/README.md +++ b/actix-redis/README.md @@ -1,6 +1,6 @@ # actix-redis -> Redis integration for Actix and session store for Actix Web. +> Redis integration for Actix. [![crates.io](https://img.shields.io/crates/v/actix-redis?label=latest)](https://crates.io/crates/actix-redis) [![Documentation](https://docs.rs/actix-redis/badge.svg?version=0.10.0)](https://docs.rs/actix-redis/0.10.0) @@ -12,36 +12,3 @@ - [API Documentation](https://docs.rs/actix-redis) - [Example Project](https://github.com/actix/examples/tree/master/auth/redis-session) - Minimum Supported Rust Version (MSRV): 1.54 - -## Redis Session Backend - -Use redis as session storage. - -You need to pass an address of the redis server and random value to the -constructor of `RedisSession`. This is private key for cookie session, -When this value is changed, all session data is lost. - -Note that whatever you write into your session is visible by the user (but not modifiable). - -Constructor panics if key length is less than 32 bytes. - -```rust -use actix_web::{App, HttpServer, middleware::Logger}; -use actix_web::web::{resource, get} -use actix_redis::RedisSession; - -#[actix_web::main] -async fn main() -> std::io::Result<()> { - HttpServer::new(move || App::new() - // cookie session middleware - .wrap(RedisSession::new("127.0.0.1:6379", &[0; 32])) - // enable logger - .wrap(Logger::default()) - // register simple route, handle all methods - .service(resource("/").route(get().to(index))) - ) - .bind("127.0.0.1:8080")? - .run() - .await -} -``` diff --git a/actix-redis/src/lib.rs b/actix-redis/src/lib.rs index c2e7187f6..94e350c23 100644 --- a/actix-redis/src/lib.rs +++ b/actix-redis/src/lib.rs @@ -1,4 +1,4 @@ -//! Redis integration for Actix and session store for Actix Web. +//! Redis integration for `actix`. #![deny(rust_2018_idioms, nonstandard_style)] #![warn(future_incompatible)] @@ -8,14 +8,7 @@ pub use redis::{Command, RedisActor}; use derive_more::{Display, Error, From}; -#[cfg(feature = "web")] -mod session; -#[cfg(feature = "web")] -pub use actix_web::cookie::SameSite; -#[cfg(feature = "web")] -pub use session::RedisSession; - -/// General purpose actix redis error +/// General purpose `actix-redis` error. #[derive(Debug, Display, Error, From)] pub enum Error { #[display(fmt = "Redis error {}", _0)] @@ -34,3 +27,4 @@ impl actix_web::ResponseError for Error {} // re-export pub use redis_async::error::Error as RespError; pub use redis_async::resp::RespValue; +pub use redis_async::resp_array; diff --git a/actix-redis/src/session.rs b/actix-redis/src/session.rs deleted file mode 100644 index d25f91006..000000000 --- a/actix-redis/src/session.rs +++ /dev/null @@ -1,684 +0,0 @@ -use std::{collections::HashMap, iter, rc::Rc}; - -use actix::prelude::*; -use actix_service::{Service, Transform}; -use actix_session::{Session, SessionStatus}; -use actix_web::{ - cookie::{Cookie, CookieJar, Key, SameSite}, - dev::{ServiceRequest, ServiceResponse}, - error, - http::header::{self, HeaderValue}, - Error, -}; -use futures_core::future::LocalBoxFuture; -use rand::{distributions::Alphanumeric, rngs::OsRng, Rng}; -use redis_async::{resp::RespValue, resp_array}; -use time::{self, Duration, OffsetDateTime}; - -use crate::redis::{Command, RedisActor}; - -/// Use redis as session storage. -/// -/// You need to pass an address of the redis server and random value to the -/// constructor of `RedisSession`. This is private key for cookie -/// session, When this value is changed, all session data is lost. -/// -/// Constructor panics if key length is less than 32 bytes. -pub struct RedisSession(Rc); - -impl RedisSession { - /// Create new redis session backend - /// - /// * `addr` - address of the redis server - pub fn new>(addr: S, key: &[u8]) -> RedisSession { - RedisSession(Rc::new(Inner { - key: Key::derive_from(key), - cache_keygen: Box::new(|key: &str| format!("session:{}", &key)), - ttl: "7200".to_owned(), - addr: RedisActor::start(addr), - name: "actix-session".to_owned(), - path: "/".to_owned(), - domain: None, - secure: false, - max_age: Some(Duration::days(7)), - same_site: None, - http_only: true, - })) - } - - /// Set time to live in seconds for session value. - pub fn ttl(mut self, ttl: u32) -> Self { - Rc::get_mut(&mut self.0).unwrap().ttl = format!("{}", ttl); - self - } - - /// Set custom cookie name for session ID. - pub fn cookie_name(mut self, name: &str) -> Self { - Rc::get_mut(&mut self.0).unwrap().name = name.to_owned(); - self - } - - /// Set custom cookie path. - pub fn cookie_path(mut self, path: &str) -> Self { - Rc::get_mut(&mut self.0).unwrap().path = path.to_owned(); - self - } - - /// Set custom cookie domain. - pub fn cookie_domain(mut self, domain: &str) -> Self { - Rc::get_mut(&mut self.0).unwrap().domain = Some(domain.to_owned()); - self - } - - /// Set custom cookie secure. - /// - /// If the `secure` field is set, a cookie will only be transmitted when the - /// connection is secure - i.e. `https`. - /// - /// Default is false. - pub fn cookie_secure(mut self, secure: bool) -> Self { - Rc::get_mut(&mut self.0).unwrap().secure = secure; - self - } - - /// Set custom cookie max-age. - /// - /// Use `None` for session-only cookies. - pub fn cookie_max_age(mut self, max_age: impl Into>) -> Self { - Rc::get_mut(&mut self.0).unwrap().max_age = max_age.into(); - self - } - - /// Set custom cookie `SameSite` attribute. - /// - /// By default, the attribute is omitted. - pub fn cookie_same_site(mut self, same_site: SameSite) -> Self { - Rc::get_mut(&mut self.0).unwrap().same_site = Some(same_site); - self - } - - /// Set custom cookie `HttpOnly` policy. - /// - /// Default is true. - pub fn cookie_http_only(mut self, http_only: bool) -> Self { - Rc::get_mut(&mut self.0).unwrap().http_only = http_only; - self - } - - /// Set a custom cache key generation strategy, expecting session key as input. - pub fn cache_keygen(mut self, keygen: Box String>) -> Self { - Rc::get_mut(&mut self.0).unwrap().cache_keygen = keygen; - self - } -} - -impl Transform for RedisSession -where - S: Service, Error = Error> + 'static, - S::Future: 'static, - B: 'static, -{ - type Response = ServiceResponse; - type Error = S::Error; - type Transform = RedisSessionMiddleware; - type InitError = (); - type Future = LocalBoxFuture<'static, Result>; - - fn new_transform(&self, service: S) -> Self::Future { - let inner = self.0.clone(); - Box::pin(async { - Ok(RedisSessionMiddleware { - service: Rc::new(service), - inner, - }) - }) - } -} - -/// Cookie session middleware -pub struct RedisSessionMiddleware { - service: Rc, - inner: Rc, -} - -impl Service for RedisSessionMiddleware -where - S: Service, Error = Error> + 'static, - S::Future: 'static, - B: 'static, -{ - type Response = ServiceResponse; - type Error = Error; - type Future = LocalBoxFuture<'static, Result>; - - actix_service::forward_ready!(service); - - fn call(&self, mut req: ServiceRequest) -> Self::Future { - let srv = Rc::clone(&self.service); - let inner = Rc::clone(&self.inner); - - Box::pin(async move { - let state = inner.load(&req).await?; - - let value = if let Some((state, value)) = state { - Session::set_session(&mut req, state); - Some(value) - } else { - None - }; - - let mut res = srv.call(req).await?; - - match Session::get_changes(&mut res) { - (SessionStatus::Unchanged, _) => { - // If the session already exists, we don't need to update the state stored in Redis - // If the session is new, creating an empty session in Redis is unnecessary overhead - Ok(res) - } - - (SessionStatus::Changed, state) => inner.update(res, state, value).await, - - (SessionStatus::Purged, _) => { - if let Some(val) = value { - inner.clear_cache(val).await?; - match inner.remove_cookie(&mut res) { - Ok(_) => Ok(res), - Err(_err) => Err(error::ErrorInternalServerError(_err)), - } - } else { - Err(error::ErrorInternalServerError("unexpected")) - } - } - - (SessionStatus::Renewed, state) => { - if let Some(val) = value { - inner.clear_cache(val).await?; - inner.update(res, state, None).await - } else { - inner.update(res, state, None).await - } - } - } - }) - } -} - -struct Inner { - key: Key, - cache_keygen: Box String>, - ttl: String, - addr: Addr, - name: String, - path: String, - domain: Option, - secure: bool, - max_age: Option, - same_site: Option, - http_only: bool, -} - -impl Inner { - async fn load( - &self, - req: &ServiceRequest, - ) -> Result, String)>, Error> { - // wrapped in block to avoid holding `Ref` (from `req.cookies`) across await point - let (value, cache_key) = { - let cookies = if let Ok(cookies) = req.cookies() { - cookies - } else { - return Ok(None); - }; - - if let Some(cookie) = cookies.iter().find(|&cookie| cookie.name() == self.name) { - let mut jar = CookieJar::new(); - jar.add_original(cookie.clone()); - - if let Some(cookie) = jar.signed(&self.key).get(&self.name) { - let value = cookie.value().to_owned(); - let cache_key = (self.cache_keygen)(cookie.value()); - (value, cache_key) - } else { - return Ok(None); - } - } else { - return Ok(None); - } - }; - - let val = self - .addr - .send(Command(resp_array!["GET", cache_key])) - .await - .map_err(error::ErrorInternalServerError)? - .map_err(error::ErrorInternalServerError)?; - - match val { - RespValue::Error(err) => { - return Err(error::ErrorInternalServerError(err)); - } - RespValue::SimpleString(s) => { - if let Ok(val) = serde_json::from_str(&s) { - return Ok(Some((val, value))); - } - } - RespValue::BulkString(s) => { - if let Ok(val) = serde_json::from_slice(&s) { - return Ok(Some((val, value))); - } - } - _ => {} - } - - Ok(None) - } - - async fn update( - &self, - mut res: ServiceResponse, - state: impl Iterator, - value: Option, - ) -> Result, Error> { - let (value, jar) = if let Some(value) = value { - (value, None) - } else { - let value = iter::repeat(()) - .map(|()| OsRng.sample(Alphanumeric)) - .take(32) - .collect::>(); - let value = String::from_utf8(value).unwrap_or_default(); - - // prepare session id cookie - let mut cookie = Cookie::new(self.name.clone(), value.clone()); - cookie.set_path(self.path.clone()); - cookie.set_secure(self.secure); - cookie.set_http_only(self.http_only); - - if let Some(ref domain) = self.domain { - cookie.set_domain(domain.clone()); - } - - if let Some(max_age) = self.max_age { - cookie.set_max_age(max_age); - } - - if let Some(same_site) = self.same_site { - cookie.set_same_site(same_site); - } - - // set cookie - let mut jar = CookieJar::new(); - jar.signed_mut(&self.key).add(cookie); - - (value, Some(jar)) - }; - - let cache_key = (self.cache_keygen)(&value); - - let state: HashMap<_, _> = state.collect(); - - let body = match serde_json::to_string(&state) { - Err(err) => return Err(err.into()), - Ok(body) => body, - }; - - let cmd = Command(resp_array!["SET", cache_key, body, "EX", &self.ttl]); - - self.addr - .send(cmd) - .await - .map_err(error::ErrorInternalServerError)? - .map_err(error::ErrorInternalServerError)?; - - if let Some(jar) = jar { - for cookie in jar.delta() { - let val = HeaderValue::from_str(&cookie.to_string())?; - res.headers_mut().append(header::SET_COOKIE, val); - } - } - - Ok(res) - } - - /// Removes cache entry. - async fn clear_cache(&self, key: String) -> Result<(), Error> { - let cache_key = (self.cache_keygen)(&key); - - let res = self - .addr - .send(Command(resp_array!["DEL", cache_key])) - .await - .map_err(error::ErrorInternalServerError)?; - - match res { - // redis responds with number of deleted records - Ok(RespValue::Integer(x)) if x > 0 => Ok(()), - _ => Err(error::ErrorInternalServerError( - "failed to remove session from cache", - )), - } - } - - /// Invalidates session cookie. - fn remove_cookie(&self, res: &mut ServiceResponse) -> Result<(), Error> { - let mut cookie = Cookie::named(self.name.clone()); - cookie.set_value(""); - cookie.set_max_age(Duration::ZERO); - cookie.set_expires(OffsetDateTime::now_utc() - Duration::days(365)); - - let val = - HeaderValue::from_str(&cookie.to_string()).map_err(error::ErrorInternalServerError)?; - res.headers_mut().append(header::SET_COOKIE, val); - - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - use actix_session::Session; - use actix_web::{ - middleware, web, - web::{get, post, resource}, - App, HttpResponse, Result, - }; - use serde::{Deserialize, Serialize}; - use serde_json::json; - - #[derive(Serialize, Deserialize, Debug, PartialEq)] - pub struct IndexResponse { - user_id: Option, - counter: i32, - } - - async fn index(session: Session) -> Result { - let user_id: Option = session.get::("user_id").unwrap(); - let counter: i32 = session - .get::("counter") - .unwrap_or(Some(0)) - .unwrap_or(0); - - Ok(HttpResponse::Ok().json(&IndexResponse { user_id, counter })) - } - - async fn do_something(session: Session) -> Result { - let user_id: Option = session.get::("user_id").unwrap(); - let counter: i32 = session - .get::("counter") - .unwrap_or(Some(0)) - .map_or(1, |inner| inner + 1); - session.insert("counter", &counter)?; - - Ok(HttpResponse::Ok().json(&IndexResponse { user_id, counter })) - } - - #[derive(Deserialize)] - struct Identity { - user_id: String, - } - - async fn login(user_id: web::Json, session: Session) -> Result { - let id = user_id.into_inner().user_id; - session.insert("user_id", &id)?; - session.renew(); - - let counter: i32 = session - .get::("counter") - .unwrap_or(Some(0)) - .unwrap_or(0); - - Ok(HttpResponse::Ok().json(&IndexResponse { - user_id: Some(id), - counter, - })) - } - - async fn logout(session: Session) -> Result { - let id: Option = session.get("user_id")?; - - let body = if let Some(x) = id { - session.purge(); - format!("Logged out: {}", x) - } else { - "Could not log out anonymous user".to_owned() - }; - - Ok(HttpResponse::Ok().body(body)) - } - - #[actix_web::test] - async fn test_session_workflow() { - // Step 1: GET index - // - set-cookie actix-session should NOT be in response (session data is empty) - // - response should be: {"counter": 0, "user_id": None} - // Step 2: POST to do_something - // - adds new session state in redis: {"counter": 1} - // - set-cookie actix-session should be in response (session cookie #1) - // - response should be: {"counter": 1, "user_id": None} - // Step 3: GET index, including session cookie #1 in request - // - set-cookie will *not* be in response - // - response should be: {"counter": 1, "user_id": None} - // Step 4: POST again to do_something, including session cookie #1 in request - // - updates session state in redis: {"counter": 2} - // - response should be: {"counter": 2, "user_id": None} - // Step 5: POST to login, including session cookie #1 in request - // - set-cookie actix-session will be in response (session cookie #2) - // - updates session state in redis: {"counter": 2, "user_id": "ferris"} - // Step 6: GET index, including session cookie #2 in request - // - response should be: {"counter": 2, "user_id": "ferris"} - // Step 7: POST again to do_something, including session cookie #2 in request - // - updates session state in redis: {"counter": 3, "user_id": "ferris"} - // - response should be: {"counter": 3, "user_id": "ferris"} - // Step 8: GET index, including session cookie #1 in request - // - set-cookie actix-session should NOT be in response (session data is empty) - // - response should be: {"counter": 0, "user_id": None} - // Step 9: POST to logout, including session cookie #2 - // - set-cookie actix-session will be in response with session cookie #2 - // invalidation logic - // Step 10: GET index, including session cookie #2 in request - // - set-cookie actix-session should NOT be in response (session data is empty) - // - response should be: {"counter": 0, "user_id": None} - - let srv = actix_test::start(|| { - App::new() - .wrap(RedisSession::new("127.0.0.1:6379", &[0; 32]).cookie_name("test-session")) - .wrap(middleware::Logger::default()) - .service(resource("/").route(get().to(index))) - .service(resource("/do_something").route(post().to(do_something))) - .service(resource("/login").route(post().to(login))) - .service(resource("/logout").route(post().to(logout))) - }); - - // Step 1: GET index - // - set-cookie actix-session should NOT be in response (session data is empty) - // - response should be: {"counter": 0, "user_id": None} - let req_1a = srv.get("/").send(); - let mut resp_1 = req_1a.await.unwrap(); - assert!(resp_1.cookies().unwrap().is_empty()); - let result_1 = resp_1.json::().await.unwrap(); - assert_eq!( - result_1, - IndexResponse { - user_id: None, - counter: 0 - } - ); - - // Step 2: POST to do_something - // - adds new session state in redis: {"counter": 1} - // - set-cookie actix-session should be in response (session cookie #1) - // - response should be: {"counter": 1, "user_id": None} - let req_2 = srv.post("/do_something").send(); - let resp_2 = req_2.await.unwrap(); - let cookie_1 = resp_2 - .cookies() - .unwrap() - .clone() - .into_iter() - .find(|c| c.name() == "test-session") - .unwrap(); - assert_eq!(cookie_1.max_age(), Some(Duration::days(7))); - - // Step 3: GET index, including session cookie #1 in request - // - set-cookie will *not* be in response - // - response should be: {"counter": 1, "user_id": None} - let req_3 = srv.get("/").cookie(cookie_1.clone()).send(); - let mut resp_3 = req_3.await.unwrap(); - assert!(resp_3.cookies().unwrap().is_empty()); - let result_3 = resp_3.json::().await.unwrap(); - assert_eq!( - result_3, - IndexResponse { - user_id: None, - counter: 1 - } - ); - - // Step 4: POST again to do_something, including session cookie #1 in request - // - updates session state in redis: {"counter": 2} - // - response should be: {"counter": 2, "user_id": None} - let req_4 = srv.post("/do_something").cookie(cookie_1.clone()).send(); - let mut resp_4 = req_4.await.unwrap(); - let result_4 = resp_4.json::().await.unwrap(); - assert_eq!( - result_4, - IndexResponse { - user_id: None, - counter: 2 - } - ); - - // Step 5: POST to login, including session cookie #1 in request - // - set-cookie actix-session will be in response (session cookie #2) - // - updates session state in redis: {"counter": 2, "user_id": "ferris"} - let req_5 = srv - .post("/login") - .cookie(cookie_1.clone()) - .send_json(&json!({"user_id": "ferris"})); - let mut resp_5 = req_5.await.unwrap(); - let cookie_2 = resp_5 - .cookies() - .unwrap() - .clone() - .into_iter() - .find(|c| c.name() == "test-session") - .unwrap(); - assert_ne!(cookie_1.value(), cookie_2.value()); - - let result_5 = resp_5.json::().await.unwrap(); - assert_eq!( - result_5, - IndexResponse { - user_id: Some("ferris".into()), - counter: 2 - } - ); - - // Step 6: GET index, including session cookie #2 in request - // - response should be: {"counter": 2, "user_id": "ferris"} - let req_6 = srv.get("/").cookie(cookie_2.clone()).send(); - let mut resp_6 = req_6.await.unwrap(); - let result_6 = resp_6.json::().await.unwrap(); - assert_eq!( - result_6, - IndexResponse { - user_id: Some("ferris".into()), - counter: 2 - } - ); - - // Step 7: POST again to do_something, including session cookie #2 in request - // - updates session state in redis: {"counter": 3, "user_id": "ferris"} - // - response should be: {"counter": 3, "user_id": "ferris"} - let req_7 = srv.post("/do_something").cookie(cookie_2.clone()).send(); - let mut resp_7 = req_7.await.unwrap(); - let result_7 = resp_7.json::().await.unwrap(); - assert_eq!( - result_7, - IndexResponse { - user_id: Some("ferris".into()), - counter: 3 - } - ); - - // Step 8: GET index, including session cookie #1 in request - // - set-cookie actix-session should NOT be in response (session data is empty) - // - response should be: {"counter": 0, "user_id": None} - let req_8 = srv.get("/").cookie(cookie_1.clone()).send(); - let mut resp_8 = req_8.await.unwrap(); - assert!(resp_8.cookies().unwrap().is_empty()); - let result_8 = resp_8.json::().await.unwrap(); - assert_eq!( - result_8, - IndexResponse { - user_id: None, - counter: 0 - } - ); - - // Step 9: POST to logout, including session cookie #2 - // - set-cookie actix-session will be in response with session cookie #2 - // invalidation logic - let req_9 = srv.post("/logout").cookie(cookie_2.clone()).send(); - let resp_9 = req_9.await.unwrap(); - let cookie_3 = resp_9 - .cookies() - .unwrap() - .clone() - .into_iter() - .find(|c| c.name() == "test-session") - .unwrap(); - assert_ne!( - OffsetDateTime::now_utc().year(), - cookie_3 - .expires() - .map(|t| t.datetime().expect("Expiration is a datetime").year()) - .unwrap() - ); - - // Step 10: GET index, including session cookie #2 in request - // - set-cookie actix-session should NOT be in response (session data is empty) - // - response should be: {"counter": 0, "user_id": None} - let req_10 = srv.get("/").cookie(cookie_2.clone()).send(); - let mut resp_10 = req_10.await.unwrap(); - assert!(resp_10.cookies().unwrap().is_empty()); - let result_10 = resp_10.json::().await.unwrap(); - assert_eq!( - result_10, - IndexResponse { - user_id: None, - counter: 0 - } - ); - } - - #[actix_web::test] - async fn test_max_age_session_only() { - // - // Test that removing max_age results in a session-only cookie - // - let srv = actix_test::start(|| { - App::new() - .wrap( - RedisSession::new("127.0.0.1:6379", &[0; 32]) - .cookie_name("test-session") - .cookie_max_age(None), - ) - .wrap(middleware::Logger::default()) - .service(resource("/do_something").route(post().to(do_something))) - }); - - let req = srv.post("/do_something").send(); - let resp = req.await.unwrap(); - let cookie = resp - .cookies() - .unwrap() - .clone() - .into_iter() - .find(|c| c.name() == "test-session") - .unwrap(); - - assert_eq!(cookie.max_age(), None); - } -} diff --git a/actix-session/CHANGES.md b/actix-session/CHANGES.md index a5704a20c..76a4bdd2f 100644 --- a/actix-session/CHANGES.md +++ b/actix-session/CHANGES.md @@ -2,6 +2,23 @@ ## Unreleased - 2021-xx-xx +### Added +- `SessionMiddleware`, a middleware to provide support for saving/updating/deleting session state against a pluggable storage backend (see `SessionStore` trait) [#212] +- `CookieSessionStore`, a cookie-based backend to store session state [#212] +- `RedisActorSessionStore`, a Redis-based backend to store session state powered by `actix-redis` [#212] +- `RedisSessionStore`, a Redis-based backend to store session state powered by `redis-rs` [#212] +- Add TLS support for Redis via `RedisSessionStore` [#212] +- Implement `SessionExt` for `ServiceResponse` [#212] + +### Changed +- Rename `UserSession` to `SessionExt` [#212] + +### Removed +- `CookieSession` has been removed in favour of `CookieSessionStore`, a storage backend for `SessionMiddleware` [#212] +- `Session::set_session` has been removed. Use `Session::insert` to modify the session state. [#212] + +[#212]: https://github.com/actix/actix-extras/pull/212 + ## 0.5.0 - 2022-03-01 - Update `actix-web` dependency to `4`. @@ -27,7 +44,9 @@ ## 0.5.0-beta.5 - 2021-12-12 - Update `actix-web` dependency to `4.0.0.beta-14`. [#209] - Remove `UserSession` implementation for `RequestHead`. [#209] +- A session will be created in the storage backend if and only if there is some data inside the session state. This reduces the performance impact of `SessionMiddleware` on routes that do not leverage sessions. [#207] +[#207]: https://github.com/actix/actix-extras/pull/207 [#209]: https://github.com/actix/actix-extras/pull/209 diff --git a/actix-session/Cargo.toml b/actix-session/Cargo.toml index 0bd487e60..b6841a3aa 100644 --- a/actix-session/Cargo.toml +++ b/actix-session/Cargo.toml @@ -1,7 +1,10 @@ [package] name = "actix-session" version = "0.5.0" -authors = ["Nikolay Kim "] +authors = [ + "Nikolay Kim ", + "Luca Palmieri ", +] description = "Sessions for Actix Web" keywords = ["http", "web", "framework", "async", "session"] homepage = "https://actix.rs" @@ -9,25 +12,54 @@ repository = "https://github.com/actix/actix-extras.git" license = "MIT OR Apache-2.0" edition = "2018" +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + [lib] name = "actix_session" path = "src/lib.rs" [features] -default = ["cookie-session"] -cookie-session = ["actix-web/secure-cookies"] +default = [] +cookie-session = [] +redis-actor-session = ["actix-redis", "actix", "futures-core", "rand"] +redis-rs-session = ["redis", "rand"] +redis-rs-tls-session = ["redis-rs-session", "redis/tokio-native-tls-comp"] [dependencies] actix-service = "2" actix-utils = "3" -actix-web = { version = "4", default_features = false, features = ["cookies"] } +actix-web = { version = "4", default_features = false, features = ["cookies", "secure-cookies"] } +anyhow = "1" +async-trait = "0.1" derive_more = "0.99.5" -futures-util = { version = "0.3.7", default-features = false } -log = "0.4" -serde = "1.0" -serde_json = "1.0" +rand = { version = "0.8", optional = true } +serde = { version = "1" } +serde_json = { version = "1" } time = "0.3" +tracing = { version = "0.1.30", default-features = false, features = ["log"] } + +# redis-actor-session +actix = { version = "0.12.0", default-features = false, optional = true } +actix-redis = { version = "0.10", optional = true } +futures-core = { version = "0.3.7", default-features = false, optional = true } + +# redis-rs-session +redis = { version = "0.21", default-features = false, features = ["aio", "tokio-comp", "connection-manager"], optional = true } [dev-dependencies] -actix-web = { version = "4", default_features = false, features = ["macros", "cookies"] } +actix-session = { path = ".", features = ["cookie-session", "redis-actor-session", "redis-rs-session"] } +actix-test = "0.1.0-beta.10" +actix-web = { version = "4", default_features = false, features = ["cookies", "secure-cookies", "macros"] } +env_logger = "0.9" +log = "0.4" + +[[example]] +name = "basic" +required-features = ["redis-actor-session"] + +[[example]] +name = "authentication" +required-features = ["redis-actor-session"] diff --git a/actix-session/README.md b/actix-session/README.md index 89a623d6b..d21f9f623 100644 --- a/actix-session/README.md +++ b/actix-session/README.md @@ -1,6 +1,6 @@ # actix-session -> Sessions for Actix Web. +> Session management for Actix Web applications. [![crates.io](https://img.shields.io/crates/v/actix-session?label=latest)](https://crates.io/crates/actix-session) [![Documentation](https://docs.rs/actix-session/badge.svg?version=0.5.0)](https://docs.rs/actix-session/0.5.0) diff --git a/actix-redis/examples/authentication.rs b/actix-session/examples/authentication.rs similarity index 68% rename from actix-redis/examples/authentication.rs rename to actix-session/examples/authentication.rs index bc709e6ec..0dc2274b5 100644 --- a/actix-redis/examples/authentication.rs +++ b/actix-session/examples/authentication.rs @@ -1,7 +1,8 @@ -use actix_redis::RedisSession; -use actix_session::Session; +use actix_session::{storage::RedisActorSessionStore, Session, SessionMiddleware}; use actix_web::{ - cookie, error::InternalError, middleware, web, App, Error, HttpResponse, HttpServer, Responder, + cookie::{Key, SameSite}, + error::InternalError, + middleware, web, App, Error, HttpResponse, HttpServer, Responder, }; use serde::{Deserialize, Serialize}; @@ -70,25 +71,33 @@ async fn secret(session: Session) -> Result { #[actix_web::main] async fn main() -> std::io::Result<()> { - std::env::set_var("RUST_LOG", "actix_web=info,actix_redis=info"); - env_logger::init(); + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); - HttpServer::new(|| { + // The signing key would usually be read from a configuration file/environment variables. + let signing_key = Key::generate(); + + log::info!("starting HTTP server at http://localhost:8080"); + + HttpServer::new(move || { App::new() // enable logger .wrap(middleware::Logger::default()) // cookie session middleware .wrap( - RedisSession::new("127.0.0.1:6379", &[0; 32]) - // allow the cookie to be accessed from javascript - .cookie_http_only(false) - // allow the cookie only from the current domain - .cookie_same_site(cookie::SameSite::Strict), + SessionMiddleware::builder( + RedisActorSessionStore::new("127.0.0.1:6379"), + signing_key.clone(), + ) + // allow the cookie to be accessed from javascript + .cookie_http_only(false) + // allow the cookie only from the current domain + .cookie_same_site(SameSite::Strict) + .build(), ) .route("/login", web::post().to(login)) .route("/secret", web::get().to(secret)) }) - .bind("0.0.0.0:8080")? + .bind(("127.0.0.1", 8080))? .run() .await } diff --git a/actix-redis/examples/basic.rs b/actix-session/examples/basic.rs similarity index 51% rename from actix-redis/examples/basic.rs rename to actix-session/examples/basic.rs index bdcd7f241..3f41c68dc 100644 --- a/actix-redis/examples/basic.rs +++ b/actix-session/examples/basic.rs @@ -1,6 +1,5 @@ -use actix_redis::RedisSession; -use actix_session::Session; -use actix_web::{middleware, web, App, Error, HttpRequest, HttpServer, Responder}; +use actix_session::{storage::RedisActorSessionStore, Session, SessionMiddleware}; +use actix_web::{cookie::Key, middleware, web, App, Error, HttpRequest, HttpServer, Responder}; /// simple handler async fn index(req: HttpRequest, session: Session) -> Result { @@ -19,19 +18,26 @@ async fn index(req: HttpRequest, session: Session) -> Result std::io::Result<()> { - std::env::set_var("RUST_LOG", "actix_web=info,actix_redis=info"); - env_logger::init(); + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); - HttpServer::new(|| { + // The signing key would usually be read from a configuration file/environment variables. + let signing_key = Key::generate(); + + log::info!("starting HTTP server at http://localhost:8080"); + + HttpServer::new(move || { App::new() // enable logger .wrap(middleware::Logger::default()) // cookie session middleware - .wrap(RedisSession::new("127.0.0.1:6379", &[0; 32])) + .wrap(SessionMiddleware::new( + RedisActorSessionStore::new("127.0.0.1:6379"), + signing_key.clone(), + )) // register simple route, handle all methods .service(web::resource("/").to(index)) }) - .bind("0.0.0.0:8080")? + .bind(("127.0.0.1", 8080))? .run() .await } diff --git a/actix-session/src/_error.rs b/actix-session/src/_error.rs deleted file mode 100644 index b82e4cd3e..000000000 --- a/actix-session/src/_error.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::{error::Error as StdError, fmt}; - -use actix_web::ResponseError; -use derive_more::Display; - -#[derive(Debug, Display)] -pub(crate) enum InsertErrorKind { - #[display(fmt = "{}", _0)] - Json(serde_json::Error), -} - -impl Into for InsertErrorKind { - fn into(self) -> actix_web::Error { - match self { - InsertErrorKind::Json(err) => err.into(), - } - } -} - -/// Error returned by [`Session::insert`][crate::Session::insert]. Allows access to value that -/// failed to be inserted. -pub struct InsertError { - pub(crate) value: Option, - pub(crate) error: InsertErrorKind, -} - -impl InsertError { - /// Takes value out of error. - /// - /// # Panics - /// Panics if called more than once. - pub fn take_value(&mut self) -> T { - self.value - .take() - .expect("take_value should only be called once") - } -} - -impl fmt::Debug for InsertError { - fn fmt<'a>(&'a self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut dbg = f.debug_struct("SessionInsertError"); - - match &self.value { - Some(_) => dbg.field("value", &"Some([value])" as _), - None => dbg.field("value", &None::<()> as _), - }; - - dbg.field("error", &self.error).finish() - } -} - -impl fmt::Display for InsertError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Display::fmt(&self.error, f) - } -} - -impl StdError for InsertError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - Some(match &self.error { - InsertErrorKind::Json(err) => err, - }) - } -} - -impl ResponseError for InsertError {} diff --git a/actix-session/src/cookie.rs b/actix-session/src/cookie.rs deleted file mode 100644 index 01778600e..000000000 --- a/actix-session/src/cookie.rs +++ /dev/null @@ -1,562 +0,0 @@ -//! Cookie based sessions. See docs for [`CookieSession`]. - -use std::{collections::HashMap, rc::Rc}; - -use actix_utils::future::{ok, Ready}; -use actix_web::{ - body::{EitherBody, MessageBody}, - cookie::{Cookie, CookieJar, Key, SameSite}, - dev::{Service, ServiceRequest, ServiceResponse, Transform}, - http::header::{HeaderValue, SET_COOKIE}, - Error, ResponseError, -}; -use derive_more::Display; -use futures_util::future::{FutureExt as _, LocalBoxFuture}; -use serde_json::error::Error as JsonError; -use time::{Duration, OffsetDateTime}; - -use crate::{Session, SessionStatus}; - -/// Errors that can occur during handling cookie session -#[derive(Debug, Display)] -pub enum CookieSessionError { - /// Size of the serialized session is greater than 4000 bytes. - #[display(fmt = "Size of the serialized session is greater than 4000 bytes.")] - Overflow, - - /// Fail to serialize session. - #[display(fmt = "Fail to serialize session")] - Serialize(JsonError), -} - -impl ResponseError for CookieSessionError {} - -#[derive(Copy, Clone)] -enum CookieSecurity { - Signed, - Private, -} - -#[derive(Clone)] -struct CookieSessionInner { - key: Key, - security: CookieSecurity, - name: String, - path: String, - domain: Option, - lazy: bool, - secure: bool, - http_only: bool, - max_age: Option, - expires_in: Option, - same_site: Option, -} - -impl CookieSessionInner { - fn new(key: &[u8], security: CookieSecurity) -> CookieSessionInner { - CookieSessionInner { - security, - key: Key::derive_from(key), - name: "actix-session".to_owned(), - path: "/".to_owned(), - domain: None, - lazy: false, - secure: true, - http_only: true, - max_age: None, - expires_in: None, - same_site: None, - } - } - - fn set_cookie( - &self, - res: &mut ServiceResponse, - state: impl Iterator, - ) -> Result<(), Error> { - let state: HashMap = state.collect(); - - if self.lazy && state.is_empty() { - return Ok(()); - } - - let value = serde_json::to_string(&state).map_err(CookieSessionError::Serialize)?; - - if value.len() > 4064 { - return Err(CookieSessionError::Overflow.into()); - } - - let mut cookie = Cookie::new(self.name.clone(), value); - cookie.set_path(self.path.clone()); - cookie.set_secure(self.secure); - cookie.set_http_only(self.http_only); - - if let Some(ref domain) = self.domain { - cookie.set_domain(domain.clone()); - } - - if let Some(expires_in) = self.expires_in { - cookie.set_expires(OffsetDateTime::now_utc() + expires_in); - } - - if let Some(max_age) = self.max_age { - cookie.set_max_age(max_age); - } - - if let Some(same_site) = self.same_site { - cookie.set_same_site(same_site); - } - - let mut jar = CookieJar::new(); - - match self.security { - CookieSecurity::Signed => jar.signed_mut(&self.key).add(cookie), - CookieSecurity::Private => jar.private_mut(&self.key).add(cookie), - } - - for cookie in jar.delta() { - let val = HeaderValue::from_str(&cookie.encoded().to_string())?; - res.headers_mut().append(SET_COOKIE, val); - } - - Ok(()) - } - - /// invalidates session cookie - fn remove_cookie(&self, res: &mut ServiceResponse) -> Result<(), Error> { - let mut cookie = Cookie::named(self.name.clone()); - cookie.set_path(self.path.clone()); - cookie.set_value(""); - cookie.set_max_age(Duration::ZERO); - cookie.set_expires(OffsetDateTime::now_utc() - Duration::days(365)); - - let val = HeaderValue::from_str(&cookie.to_string())?; - res.headers_mut().append(SET_COOKIE, val); - - Ok(()) - } - - fn load(&self, req: &ServiceRequest) -> (bool, HashMap) { - if let Ok(cookies) = req.cookies() { - for cookie in cookies.iter() { - if cookie.name() == self.name { - let mut jar = CookieJar::new(); - jar.add_original(cookie.clone()); - - let cookie_opt = match self.security { - CookieSecurity::Signed => jar.signed(&self.key).get(&self.name), - CookieSecurity::Private => jar.private(&self.key).get(&self.name), - }; - - if let Some(cookie) = cookie_opt { - if let Ok(val) = serde_json::from_str(cookie.value()) { - return (false, val); - } - } - } - } - } - - (true, HashMap::new()) - } -} - -/// Use cookies for session storage. -/// -/// `CookieSession` creates sessions which are limited to storing -/// fewer than 4000 bytes of data (as the payload must fit into a single -/// cookie). An Internal Server Error is generated if the session contains more -/// than 4000 bytes. -/// -/// A cookie may have a security policy of *signed* or *private*. Each has a -/// respective `CookieSession` constructor. -/// -/// A *signed* cookie is stored on the client as plaintext alongside -/// a signature such that the cookie may be viewed but not modified by the -/// client. -/// -/// A *private* cookie is stored on the client as encrypted text -/// such that it may neither be viewed nor modified by the client. -/// -/// The constructors take a key as an argument. -/// This is the private key for cookie session - when this value is changed, -/// all session data is lost. The constructors will panic if the key is less -/// than 32 bytes in length. -/// -/// The backend relies on `cookie` crate to create and read cookies. -/// By default all cookies are percent encoded, but certain symbols may -/// cause troubles when reading cookie, if they are not properly percent encoded. -/// -/// # Examples -/// ``` -/// use actix_session::CookieSession; -/// use actix_web::{web, App, HttpResponse, HttpServer}; -/// -/// let app = App::new().wrap( -/// CookieSession::signed(&[0; 32]) -/// .domain("www.rust-lang.org") -/// .name("actix_session") -/// .path("/") -/// .secure(true)) -/// .service(web::resource("/").to(|| HttpResponse::Ok())); -/// ``` -#[derive(Clone)] -pub struct CookieSession(Rc); - -impl CookieSession { - /// Construct new *signed* `CookieSession` instance. - /// - /// Panics if key length is less than 32 bytes. - pub fn signed(key: &[u8]) -> CookieSession { - CookieSession(Rc::new(CookieSessionInner::new( - key, - CookieSecurity::Signed, - ))) - } - - /// Construct new *private* `CookieSession` instance. - /// - /// Panics if key length is less than 32 bytes. - pub fn private(key: &[u8]) -> CookieSession { - CookieSession(Rc::new(CookieSessionInner::new( - key, - CookieSecurity::Private, - ))) - } - - /// Sets the `path` field in the session cookie being built. - pub fn path>(mut self, value: S) -> CookieSession { - Rc::get_mut(&mut self.0).unwrap().path = value.into(); - self - } - - /// Sets the `name` field in the session cookie being built. - pub fn name>(mut self, value: S) -> CookieSession { - Rc::get_mut(&mut self.0).unwrap().name = value.into(); - self - } - - /// Sets the `domain` field in the session cookie being built. - pub fn domain>(mut self, value: S) -> CookieSession { - Rc::get_mut(&mut self.0).unwrap().domain = Some(value.into()); - self - } - - /// When true, prevents adding session cookies to responses until - /// the session contains data. Default is `false`. - /// - /// Useful when trying to comply with laws that require consent for setting cookies. - pub fn lazy(mut self, value: bool) -> CookieSession { - Rc::get_mut(&mut self.0).unwrap().lazy = value; - self - } - - /// Sets the `secure` field in the session cookie being built. - /// - /// If the `secure` field is set, a cookie will only be transmitted when the - /// connection is secure - i.e. `https` - pub fn secure(mut self, value: bool) -> CookieSession { - Rc::get_mut(&mut self.0).unwrap().secure = value; - self - } - - /// Sets the `http_only` field in the session cookie being built. - pub fn http_only(mut self, value: bool) -> CookieSession { - Rc::get_mut(&mut self.0).unwrap().http_only = value; - self - } - - /// Sets the `same_site` field in the session cookie being built. - pub fn same_site(mut self, value: SameSite) -> CookieSession { - Rc::get_mut(&mut self.0).unwrap().same_site = Some(value); - self - } - - /// Sets the `max-age` field in the session cookie being built. - pub fn max_age(self, seconds: i64) -> CookieSession { - self.max_age_time(Duration::seconds(seconds)) - } - - /// Sets the `max-age` field in the session cookie being built. - pub fn max_age_time(mut self, value: time::Duration) -> CookieSession { - Rc::get_mut(&mut self.0).unwrap().max_age = Some(value); - self - } - - /// Sets the `expires` field in the session cookie being built. - pub fn expires_in(self, seconds: i64) -> CookieSession { - self.expires_in_time(Duration::seconds(seconds)) - } - - /// Sets the `expires` field in the session cookie being built. - pub fn expires_in_time(mut self, value: Duration) -> CookieSession { - Rc::get_mut(&mut self.0).unwrap().expires_in = Some(value); - self - } -} - -impl Transform for CookieSession -where - S: Service>, - S::Future: 'static, - S::Error: 'static, - B: MessageBody + 'static, -{ - type Response = ServiceResponse>; - type Error = S::Error; - type InitError = (); - type Transform = CookieSessionMiddleware; - type Future = Ready>; - - fn new_transform(&self, service: S) -> Self::Future { - ok(CookieSessionMiddleware { - service, - inner: self.0.clone(), - }) - } -} - -/// Cookie based session middleware. -pub struct CookieSessionMiddleware { - service: S, - inner: Rc, -} - -impl Service for CookieSessionMiddleware -where - S: Service>, - S::Future: 'static, - S::Error: 'static, - B: MessageBody + 'static, -{ - type Response = ServiceResponse>; - type Error = S::Error; - type Future = LocalBoxFuture<'static, Result>; - - actix_service::forward_ready!(service); - - /// On first request, a new session cookie is returned in response, regardless - /// of whether any session state is set. With subsequent requests, if the - /// session state changes, then set-cookie is returned in response. As - /// a user logs out, call session.purge() to set SessionStatus accordingly - /// and this will trigger removal of the session cookie in the response. - fn call(&self, mut req: ServiceRequest) -> Self::Future { - let inner = self.inner.clone(); - let (is_new, state) = self.inner.load(&req); - let prolong_expiration = self.inner.expires_in.is_some(); - Session::set_session(&mut req, state); - - let fut = self.service.call(req); - - async move { - let mut res = fut.await?; - - let result = match Session::get_changes(&mut res) { - (SessionStatus::Changed, state) | (SessionStatus::Renewed, state) => { - inner.set_cookie(&mut res, state) - } - - (SessionStatus::Unchanged, state) if prolong_expiration => { - inner.set_cookie(&mut res, state) - } - - // set a new session cookie upon first request (new client) - (SessionStatus::Unchanged, _) => { - if is_new { - let state: HashMap = HashMap::new(); - inner.set_cookie(&mut res, state.into_iter()) - } else { - Ok(()) - } - } - - (SessionStatus::Purged, _) => { - let _ = inner.remove_cookie(&mut res); - Ok(()) - } - }; - - match result { - Ok(()) => Ok(res.map_into_left_body()), - Err(error) => Ok(res.error_response(error).map_into_right_body()), - } - } - .boxed_local() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use actix_web::web::Bytes; - use actix_web::{test, web, App}; - - #[actix_web::test] - async fn cookie_session() { - let app = test::init_service( - App::new() - .wrap(CookieSession::signed(&[0; 32]).secure(false)) - .service(web::resource("/").to(|ses: Session| async move { - let _ = ses.insert("counter", 100); - "test" - })), - ) - .await; - - let request = test::TestRequest::get().to_request(); - let response = app.call(request).await.unwrap(); - assert!(response - .response() - .cookies() - .any(|c| c.name() == "actix-session")); - } - - #[actix_web::test] - async fn private_cookie() { - let app = test::init_service( - App::new() - .wrap(CookieSession::private(&[0; 32]).secure(false)) - .service(web::resource("/").to(|ses: Session| async move { - let _ = ses.insert("counter", 100); - "test" - })), - ) - .await; - - let request = test::TestRequest::get().to_request(); - let response = app.call(request).await.unwrap(); - assert!(response - .response() - .cookies() - .any(|c| c.name() == "actix-session")); - } - - #[actix_web::test] - async fn lazy_cookie() { - let app = test::init_service( - App::new() - .wrap(CookieSession::signed(&[0; 32]).secure(false).lazy(true)) - .service(web::resource("/count").to(|ses: Session| async move { - let _ = ses.insert("counter", 100); - "counting" - })) - .service(web::resource("/").to(|_ses: Session| async move { "test" })), - ) - .await; - - let request = test::TestRequest::get().to_request(); - let response = app.call(request).await.unwrap(); - assert!(response.response().cookies().count() == 0); - - let request = test::TestRequest::with_uri("/count").to_request(); - let response = app.call(request).await.unwrap(); - - assert!(response - .response() - .cookies() - .any(|c| c.name() == "actix-session")); - } - - #[actix_web::test] - async fn cookie_session_extractor() { - let app = test::init_service( - App::new() - .wrap(CookieSession::signed(&[0; 32]).secure(false)) - .service(web::resource("/").to(|ses: Session| async move { - let _ = ses.insert("counter", 100); - "test" - })), - ) - .await; - - let request = test::TestRequest::get().to_request(); - let response = app.call(request).await.unwrap(); - assert!(response - .response() - .cookies() - .any(|c| c.name() == "actix-session")); - } - - #[actix_web::test] - async fn basics() { - let app = test::init_service( - App::new() - .wrap( - CookieSession::signed(&[0; 32]) - .path("/test/") - .name("actix-test") - .domain("localhost") - .http_only(true) - .same_site(SameSite::Lax) - .max_age(100), - ) - .service(web::resource("/").to(|ses: Session| async move { - let _ = ses.insert("counter", 100); - "test" - })) - .service(web::resource("/test/").to(|ses: Session| async move { - let val: usize = ses.get("counter").unwrap().unwrap(); - format!("counter: {}", val) - })), - ) - .await; - - let request = test::TestRequest::get().to_request(); - let response = app.call(request).await.unwrap(); - let cookie = response - .response() - .cookies() - .find(|c| c.name() == "actix-test") - .unwrap() - .clone(); - assert_eq!(cookie.path().unwrap(), "/test/"); - - let request = test::TestRequest::with_uri("/test/") - .cookie(cookie) - .to_request(); - let body = test::call_and_read_body(&app, request).await; - assert_eq!(body, Bytes::from_static(b"counter: 100")); - } - - #[actix_web::test] - async fn prolong_expiration() { - let app = test::init_service( - App::new() - .wrap(CookieSession::signed(&[0; 32]).secure(false).expires_in(60)) - .service(web::resource("/").to(|ses: Session| async move { - let _ = ses.insert("counter", 100); - "test" - })) - .service(web::resource("/test/").to(|| async move { "no-changes-in-session" })), - ) - .await; - - let request = test::TestRequest::get().to_request(); - let response = app.call(request).await.unwrap(); - let expires_1 = response - .response() - .cookies() - .find(|c| c.name() == "actix-session") - .expect("Cookie is set") - .expires() - .expect("Expiration is set") - .datetime() - .expect("Expiration is a datetime"); - - actix_web::rt::time::sleep(std::time::Duration::from_secs(1)).await; - - let request = test::TestRequest::with_uri("/test/").to_request(); - let response = app.call(request).await.unwrap(); - let expires_2 = response - .response() - .cookies() - .find(|c| c.name() == "actix-session") - .expect("Cookie is set") - .expires() - .expect("Expiration is set") - .datetime() - .expect("Expiration is a datetime"); - - assert!(expires_2 - expires_1 >= Duration::seconds(1)); - } -} diff --git a/actix-session/src/lib.rs b/actix-session/src/lib.rs index 37b3fdc63..6f42f9841 100644 --- a/actix-session/src/lib.rs +++ b/actix-session/src/lib.rs @@ -1,23 +1,81 @@ -//! Sessions for Actix Web. +//! Session management for Actix Web //! -//! Provides a general solution for session management. Session middleware could provide different -//! implementations which could be accessed via general session API. +//! The HTTP protocol, at a first glance, is stateless: the client sends a request, the server +//! parses its content, performs some processing and returns a response. The outcome is only +//! influenced by the provided inputs (i.e. the request content) and whatever state the server +//! queries while performing its processing. //! -//! This crate provides a general solution for session management and includes a cookie backend. -//! Other backend implementations can be built to use persistent or key-value stores, for example. +//! Stateless systems are easier to reason about, but they are not quite as powerful as we need to +//! be - e.g. how do you authenticate a user? The user would be forced to authenticate **for every +//! single request**. That is, for example, how 'Basic' Authentication works. While it may work for +//! a machine user (i.e. an API client), it is impractical for a person—you do not want a login +//! prompt on every single page you navigate to! //! -//! In general, some session middleware, such as a [`CookieSession`] is initialized and applied. -//! To access session data, the [`Session`] extractor must be used. This extractor allows reading -//! modifying session data. +//! There is a solution - **sessions**. Using sessions the server can attach state to a set of +//! requests coming from the same client. They are built on top of cookies - the server sets a +//! cookie in the HTTP response (`Set-Cookie` header), the client (e.g. the browser) will store the +//! cookie and play it back to the server when sending new requests (using the `Cookie` header). +//! +//! We refer to the cookie used for sessions as a **session cookie**. Its content is called +//! **session key** (or **session ID**), while the state attached to the session is referred to as +//! **session state**. +//! +//! `actix-session` provides an easy-to-use framework to manage sessions in applications built on +//! top of Actix Web. [`SessionMiddleware`] is the middleware underpinning the functionality +//! provided by `actix-session`; it takes care of all the session cookie handling and instructs the +//! **storage backend** to create/delete/update the session state based on the operations performed +//! against the active [`Session`]. +//! +//! `actix-session` provides some built-in storage backends: ([`storage::CookieSessionStore`], +//! [`storage::RedisSessionStore`], and [`storage::RedisActorSessionStore`]) - you can create a +//! custom storage backend by implementing the [`SessionStore`](storage::SessionStore) trait. +//! +//! Further reading on sessions: +//! - [RFC6265](https://datatracker.ietf.org/doc/html/rfc6265); +//! - [OWASP's session management cheat-sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html). +//! +//! # Getting started +//! To start using sessions in your Actix Web application you must register [`SessionMiddleware`] +//! as a middleware on your `App`: //! //! ```no_run //! use actix_web::{web, App, HttpServer, HttpResponse, Error}; -//! use actix_session::{Session, CookieSession}; +//! use actix_session::{Session, SessionMiddleware, storage::RedisActorSessionStore}; +//! use actix_web::cookie::Key; +//! +//! #[actix_web::main] +//! async fn main() -> std::io::Result<()> { +//! // The secret key would usually be read from a configuration file/environment variables. +//! let secret_key = Key::generate(); +//! let redis_connection_string = "127.0.0.1:6379"; +//! HttpServer::new(move || +//! App::new() +//! // Add session management to your application using Redis for session state storage +//! .wrap( +//! SessionMiddleware::new( +//! RedisActorSessionStore::new(redis_connection_string), +//! secret_key.clone() +//! ) +//! ) +//! .default_service(web::to(|| HttpResponse::Ok()))) +//! .bind(("127.0.0.1", 8080))? +//! .run() +//! .await +//! } +//! ``` +//! +//! The session state can be accessed and modified by your request handlers using the [`Session`] +//! extractor. +//! +//! ```no_run +//! use actix_web::Error; +//! use actix_session::Session; //! //! fn index(session: Session) -> Result<&'static str, Error> { -//! // access session data +//! // Access the session state //! if let Some(count) = session.get::("counter")? { //! println!("SESSION value: {}", count); +//! // Modify the session state //! session.insert("counter", count + 1)?; //! } else { //! session.insert("counter", 1)?; @@ -25,356 +83,499 @@ //! //! Ok("Welcome!") //! } -//! -//! #[actix_web::main] -//! async fn main() -> std::io::Result<()> { -//! HttpServer::new( -//! || App::new() -//! // create cookie based session middleware -//! .wrap(CookieSession::signed(&[0; 32]).secure(false)) -//! .default_service(web::to(|| HttpResponse::Ok()))) -//! .bind(("127.0.0.1", 8080))? -//! .run() -//! .await -//! } //! ``` +//! +//! # Choosing A Backend +//! +//! By default, `actix-session` does not provide any storage backend to retrieve and save the state +//! attached to your sessions. You can enable: +//! +//! - a purely cookie-based "backend", [`storage::CookieSessionStore`], using the `cookie-session` +//! feature flag. +//! +//! ```toml +//! [dependencies] +//! # ... +//! actix-session = { version = "...", features = ["cookie-session"] } +//! ``` +//! +//! - a Redis-based backend via `actix-redis`, [`storage::RedisActorSessionStore`], using the +//! `redis-actor-session` feature flag. +//! +//! ```toml +//! [dependencies] +//! # ... +//! actix-session = { version = "...", features = ["redis-actor-session"] } +//! ``` +//! +//! - a Redis-based backend via [`redis-rs`](https://github.com/mitsuhiko/redis-rs), +//! [`storage::RedisSessionStore`], using the `redis-rs-session` feature flag. +//! +//! ```toml +//! [dependencies] +//! # ... +//! actix-session = { version = "...", features = ["redis-rs-session"] } +//! ``` +//! +//! Add the `redis-rs-tls-session` feature flag if you want to connect to Redis using a secured +//! connection: +//! +//! ```toml +//! [dependencies] +//! # ... +//! actix-session = { version = "...", features = ["redis-rs-session", "redis-rs-tls-session"] } +//! ``` +//! +//! You can provide a different session store by implementing the [`storage::SessionStore`] trait. #![deny(rust_2018_idioms, nonstandard_style)] #![warn(future_incompatible, missing_docs)] +#![doc(html_logo_url = "https://actix.rs/img/logo.png")] +#![doc(html_favicon_url = "https://actix.rs/favicon.ico")] +#![cfg_attr(docsrs, feature(doc_cfg))] -use std::{ - cell::{Ref, RefCell}, - collections::HashMap, - mem, - rc::Rc, +mod middleware; +mod session; +mod session_ext; +pub mod storage; + +pub use self::middleware::{ + CookieContentSecurity, SessionLength, SessionMiddleware, SessionMiddlewareBuilder, }; - -use actix_utils::future::{ok, Ready}; -use actix_web::{ - dev::{Extensions, Payload, ServiceRequest, ServiceResponse}, - Error, FromRequest, HttpMessage, HttpRequest, -}; -use serde::{de::DeserializeOwned, Serialize}; - -#[cfg(feature = "cookie-session")] -mod cookie; -#[cfg(feature = "cookie-session")] -pub use self::cookie::CookieSession; - -/// The high-level interface you use to modify session data. -/// -/// Session object is obtained with [`UserSession::get_session`]. The [`UserSession`] trait is -/// implemented for `HttpRequest`, `ServiceRequest`, and `RequestHead`. -/// -/// ``` -/// use actix_session::Session; -/// use actix_web::Result; -/// -/// async fn index(session: Session) -> Result<&'static str> { -/// // access session data -/// if let Some(count) = session.get::("counter")? { -/// session.insert("counter", count + 1)?; -/// } else { -/// session.insert("counter", 1)?; -/// } -/// -/// Ok("Welcome!") -/// } -/// ``` -pub struct Session(Rc>); - -/// Extraction of a [`Session`] object. -pub trait UserSession { - /// Extract the [`Session`] object - fn get_session(&self) -> Session; -} - -impl UserSession for HttpRequest { - fn get_session(&self) -> Session { - Session::get_session(&mut *self.extensions_mut()) - } -} - -impl UserSession for ServiceRequest { - fn get_session(&self) -> Session { - Session::get_session(&mut *self.extensions_mut()) - } -} - -/// Status of a [`Session`]. -#[derive(PartialEq, Clone, Debug)] -pub enum SessionStatus { - /// Session has been updated and requires a new persist operation. - Changed, - - /// Session is flagged for deletion and should be removed from client and server. - /// - /// Most operations on the session after purge flag is set should have no effect. - Purged, - - /// Session is flagged for refresh. - /// - /// For example, when using a backend that has a TTL (time-to-live) expiry on the session entry, - /// the session will be refreshed even if no data inside it has changed. The client may also - /// be notified of the refresh. - Renewed, - - /// Session is unchanged from when last seen (if exists). - /// - /// This state also captures new (previously unissued) sessions such as a user's first - /// site visit. - Unchanged, -} - -impl Default for SessionStatus { - fn default() -> SessionStatus { - SessionStatus::Unchanged - } -} - -#[derive(Default)] -struct SessionInner { - state: HashMap, - status: SessionStatus, -} - -impl Session { - /// Get a `value` from the session. - pub fn get(&self, key: &str) -> Result, Error> { - if let Some(s) = self.0.borrow().state.get(key) { - Ok(Some(serde_json::from_str(s)?)) - } else { - Ok(None) - } - } - - /// Get all raw key-value data from the session. - /// - /// Note that values are JSON encoded. - pub fn entries(&self) -> Ref<'_, HashMap> { - Ref::map(self.0.borrow(), |inner| &inner.state) - } - - /// Inserts a key-value pair into the session. - /// - /// Any serializable value can be used and will be encoded as JSON in session data, hence why - /// only a reference to the value is taken. - pub fn insert(&self, key: impl Into, value: impl Serialize) -> Result<(), Error> { - let mut inner = self.0.borrow_mut(); - - if inner.status != SessionStatus::Purged { - inner.status = SessionStatus::Changed; - let val = serde_json::to_string(&value)?; - inner.state.insert(key.into(), val); - } - - Ok(()) - } - - /// Remove value from the session. - /// - /// If present, the JSON encoded value is returned. - pub fn remove(&self, key: &str) -> Option { - let mut inner = self.0.borrow_mut(); - - if inner.status != SessionStatus::Purged { - inner.status = SessionStatus::Changed; - return inner.state.remove(key); - } - - None - } - - /// Remove value from the session and deserialize. - /// - /// Returns None if key was not present in session. Returns T if deserialization succeeds, - /// otherwise returns un-deserialized JSON string. - pub fn remove_as(&self, key: &str) -> Option> { - self.remove(key) - .map(|val_str| match serde_json::from_str(&val_str) { - Ok(val) => Ok(val), - Err(_err) => { - log::debug!( - "removed value (key: {}) could not be deserialized as {}", - key, - std::any::type_name::() - ); - Err(val_str) - } - }) - } - - /// Clear the session. - pub fn clear(&self) { - let mut inner = self.0.borrow_mut(); - - if inner.status != SessionStatus::Purged { - inner.status = SessionStatus::Changed; - inner.state.clear() - } - } - - /// Removes session both client and server side. - pub fn purge(&self) { - let mut inner = self.0.borrow_mut(); - inner.status = SessionStatus::Purged; - inner.state.clear(); - } - - /// Renews the session key, assigning existing session state to new key. - pub fn renew(&self) { - let mut inner = self.0.borrow_mut(); - - if inner.status != SessionStatus::Purged { - inner.status = SessionStatus::Renewed; - } - } - - /// Adds the given key-value pairs to the session on the request. - /// - /// Values that match keys already existing on the session will be overwritten. Values should - /// already be JSON serialized. - /// - /// # Examples - /// ``` - /// # use actix_session::Session; - /// # use actix_web::test; - /// let mut req = test::TestRequest::default().to_srv_request(); - /// - /// Session::set_session( - /// &mut req, - /// vec![("counter".to_string(), serde_json::to_string(&0).unwrap())], - /// ); - /// ``` - pub fn set_session(req: &mut ServiceRequest, data: impl IntoIterator) { - let session = Session::get_session(&mut *req.extensions_mut()); - let mut inner = session.0.borrow_mut(); - inner.state.extend(data); - } - - /// Returns session status and iterator of key-value pairs of changes. - pub fn get_changes( - res: &mut ServiceResponse, - ) -> (SessionStatus, impl Iterator) { - if let Some(s_impl) = res - .request() - .extensions() - .get::>>() - { - let state = mem::take(&mut s_impl.borrow_mut().state); - (s_impl.borrow().status.clone(), state.into_iter()) - } else { - (SessionStatus::Unchanged, HashMap::new().into_iter()) - } - } - - fn get_session(extensions: &mut Extensions) -> Session { - if let Some(s_impl) = extensions.get::>>() { - return Session(Rc::clone(s_impl)); - } - let inner = Rc::new(RefCell::new(SessionInner::default())); - extensions.insert(inner.clone()); - Session(inner) - } -} - -/// Extractor implementation for Session type. -/// -/// # Examples -/// ``` -/// # use actix_web::*; -/// use actix_session::Session; -/// -/// #[get("/")] -/// async fn index(session: Session) -> Result { -/// // access session data -/// if let Some(count) = session.get::("counter")? { -/// session.insert("counter", count + 1)?; -/// } else { -/// session.insert("counter", 1)?; -/// } -/// -/// let count = session.get::("counter")?.unwrap(); -/// Ok(format!("Counter: {}", count)) -/// } -/// ``` -impl FromRequest for Session { - type Error = Error; - type Future = Ready>; - - #[inline] - fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { - ok(Session::get_session(&mut *req.extensions_mut())) - } -} +pub use self::session::{Session, SessionStatus}; +pub use self::session_ext::SessionExt; #[cfg(test)] -mod tests { - use actix_web::{test, HttpResponse}; +pub mod test_helpers { + use actix_web::cookie::Key; + use rand::{distributions::Alphanumeric, thread_rng, Rng}; - use super::*; + use crate::{storage::SessionStore, CookieContentSecurity}; - #[actix_web::test] - async fn session() { - let mut req = test::TestRequest::default().to_srv_request(); - - Session::set_session( - &mut req, - vec![("key".to_string(), serde_json::to_string("value").unwrap())], - ); - let session = Session::get_session(&mut *req.extensions_mut()); - let res = session.get::("key").unwrap(); - assert_eq!(res, Some("value".to_string())); - - session.insert("key2", "value2").unwrap(); - session.remove("key"); - - let mut res = req.into_response(HttpResponse::Ok().finish()); - let (_status, state) = Session::get_changes(&mut res); - let changes: Vec<_> = state.collect(); - assert_eq!(changes, [("key2".to_string(), "\"value2\"".to_string())]); + /// Generate a random cookie signing/encryption key. + pub fn key() -> Key { + let signing_key: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(64) + .map(char::from) + .collect(); + Key::from(signing_key.as_bytes()) } - #[actix_web::test] - async fn get_session() { - let mut req = test::TestRequest::default().to_srv_request(); - - Session::set_session( - &mut req, - vec![("key".to_string(), serde_json::to_string(&true).unwrap())], - ); - - let session = req.get_session(); - let res = session.get("key").unwrap(); - assert_eq!(res, Some(true)); + /// A ready-to-go acceptance test suite to verify that sessions behave as expected + /// regardless of the underlying session store. + /// + /// `is_invalidation_supported` must be set to `true` if the backend supports + /// "remembering" that a session has been invalidated (e.g. by logging out). + /// It should be to `false` if the backend allows multiple cookies to be active + /// at the same time (e.g. cookie store backend). + pub async fn acceptance_test_suite(store_builder: F, is_invalidation_supported: bool) + where + Store: SessionStore + 'static, + F: Fn() -> Store + Clone + Send + 'static, + { + for policy in &[ + CookieContentSecurity::Signed, + CookieContentSecurity::Private, + ] { + println!("Using {:?} as cookie content security policy.", policy); + acceptance_tests::basic_workflow(store_builder.clone(), *policy).await; + acceptance_tests::expiration_is_refreshed_on_changes(store_builder.clone(), *policy) + .await; + acceptance_tests::complex_workflow( + store_builder.clone(), + is_invalidation_supported, + *policy, + ) + .await; + } } - #[actix_web::test] - async fn purge_session() { - let req = test::TestRequest::default().to_srv_request(); - let session = Session::get_session(&mut *req.extensions_mut()); - assert_eq!(session.0.borrow().status, SessionStatus::Unchanged); - session.purge(); - assert_eq!(session.0.borrow().status, SessionStatus::Purged); - } + mod acceptance_tests { + use actix_web::{ + dev::Service, + middleware, test, + web::{self, get, post, resource, Bytes}, + App, HttpResponse, Result, + }; + use serde::{Deserialize, Serialize}; + use serde_json::json; + use time::Duration; - #[actix_web::test] - async fn renew_session() { - let req = test::TestRequest::default().to_srv_request(); - let session = Session::get_session(&mut *req.extensions_mut()); - assert_eq!(session.0.borrow().status, SessionStatus::Unchanged); - session.renew(); - assert_eq!(session.0.borrow().status, SessionStatus::Renewed); - } + use crate::{ + middleware::SessionLength, storage::SessionStore, test_helpers::key, + CookieContentSecurity, Session, SessionMiddleware, + }; - #[actix_web::test] - async fn session_entries() { - let session = Session(Rc::new(RefCell::new(SessionInner::default()))); - session.insert("test_str", "val").unwrap(); - session.insert("test_num", 1).unwrap(); + pub(super) async fn basic_workflow( + store_builder: F, + policy: CookieContentSecurity, + ) where + Store: SessionStore + 'static, + F: Fn() -> Store + Clone + Send + 'static, + { + let app = test::init_service( + App::new() + .wrap( + SessionMiddleware::builder(store_builder(), key()) + .cookie_path("/test/".into()) + .cookie_name("actix-test".into()) + .cookie_domain(Some("localhost".into())) + .cookie_content_security(policy) + .session_length(SessionLength::Predetermined { + max_session_length: Some(time::Duration::seconds(100)), + }) + .build(), + ) + .service(web::resource("/").to(|ses: Session| async move { + let _ = ses.insert("counter", 100); + "test" + })) + .service(web::resource("/test/").to(|ses: Session| async move { + let val: usize = ses.get("counter").unwrap().unwrap(); + format!("counter: {}", val) + })), + ) + .await; - let map = session.entries(); - map.contains_key("test_str"); - map.contains_key("test_num"); + let request = test::TestRequest::get().to_request(); + let response = app.call(request).await.unwrap(); + let cookie = response + .response() + .cookies() + .find(|c| c.name() == "actix-test") + .unwrap() + .clone(); + assert_eq!(cookie.path().unwrap(), "/test/"); + + let request = test::TestRequest::with_uri("/test/") + .cookie(cookie) + .to_request(); + let body = test::call_and_read_body(&app, request).await; + assert_eq!(body, Bytes::from_static(b"counter: 100")); + } + + pub(super) async fn expiration_is_refreshed_on_changes( + store_builder: F, + policy: CookieContentSecurity, + ) where + Store: SessionStore + 'static, + F: Fn() -> Store + Clone + Send + 'static, + { + let app = test::init_service( + App::new() + .wrap( + SessionMiddleware::builder(store_builder(), key()) + .cookie_content_security(policy) + .session_length(SessionLength::Predetermined { + max_session_length: Some(time::Duration::seconds(60)), + }) + .build(), + ) + .service(web::resource("/").to(|ses: Session| async move { + let _ = ses.insert("counter", 100); + "test" + })) + .service(web::resource("/test/").to(|| async move { "no-changes-in-session" })), + ) + .await; + + let request = test::TestRequest::get().to_request(); + let response = app.call(request).await.unwrap(); + let cookie_1 = response + .response() + .cookies() + .find(|c| c.name() == "id") + .expect("Cookie is set"); + assert_eq!(cookie_1.max_age(), Some(Duration::seconds(60))); + + let request = test::TestRequest::with_uri("/test/").to_request(); + let response = app.call(request).await.unwrap(); + assert!(response.response().cookies().next().is_none()); + + let request = test::TestRequest::get().to_request(); + let response = app.call(request).await.unwrap(); + let cookie_2 = response + .response() + .cookies() + .find(|c| c.name() == "id") + .expect("Cookie is set"); + assert_eq!(cookie_2.max_age(), Some(Duration::seconds(60))); + } + + pub(super) async fn complex_workflow( + store_builder: F, + is_invalidation_supported: bool, + policy: CookieContentSecurity, + ) where + Store: SessionStore + 'static, + F: Fn() -> Store + Clone + Send + 'static, + { + let srv = actix_test::start(move || { + App::new() + .wrap( + SessionMiddleware::builder(store_builder(), key()) + .cookie_name("test-session".into()) + .cookie_content_security(policy) + .session_length(SessionLength::Predetermined { + max_session_length: Some(time::Duration::days(7)), + }) + .build(), + ) + .wrap(middleware::Logger::default()) + .service(resource("/").route(get().to(index))) + .service(resource("/do_something").route(post().to(do_something))) + .service(resource("/login").route(post().to(login))) + .service(resource("/logout").route(post().to(logout))) + }); + + // Step 1: GET index + // - set-cookie actix-session should NOT be in response (session data is empty) + // - response should be: {"counter": 0, "user_id": None} + let req_1a = srv.get("/").send(); + let mut resp_1 = req_1a.await.unwrap(); + assert!(resp_1.cookies().unwrap().is_empty()); + let result_1 = resp_1.json::().await.unwrap(); + assert_eq!( + result_1, + IndexResponse { + user_id: None, + counter: 0 + } + ); + + // Step 2: POST to do_something + // - adds new session state in redis: {"counter": 1} + // - set-cookie actix-session should be in response (session cookie #1) + // - response should be: {"counter": 1, "user_id": None} + let req_2 = srv.post("/do_something").send(); + let mut resp_2 = req_2.await.unwrap(); + let result_2 = resp_2.json::().await.unwrap(); + assert_eq!( + result_2, + IndexResponse { + user_id: None, + counter: 1 + } + ); + let cookie_1 = resp_2 + .cookies() + .unwrap() + .clone() + .into_iter() + .find(|c| c.name() == "test-session") + .unwrap(); + assert_eq!(cookie_1.max_age(), Some(Duration::days(7))); + + // Step 3: GET index, including session cookie #1 in request + // - set-cookie will *not* be in response + // - response should be: {"counter": 1, "user_id": None} + let req_3 = srv.get("/").cookie(cookie_1.clone()).send(); + let mut resp_3 = req_3.await.unwrap(); + assert!(resp_3.cookies().unwrap().is_empty()); + let result_3 = resp_3.json::().await.unwrap(); + assert_eq!( + result_3, + IndexResponse { + user_id: None, + counter: 1 + } + ); + + // Step 4: POST again to do_something, including session cookie #1 in request + // - set-cookie will be in response (session cookie #2) + // - updates session state: {"counter": 2} + // - response should be: {"counter": 2, "user_id": None} + let req_4 = srv.post("/do_something").cookie(cookie_1.clone()).send(); + let mut resp_4 = req_4.await.unwrap(); + let result_4 = resp_4.json::().await.unwrap(); + assert_eq!( + result_4, + IndexResponse { + user_id: None, + counter: 2 + } + ); + let cookie_2 = resp_4 + .cookies() + .unwrap() + .clone() + .into_iter() + .find(|c| c.name() == "test-session") + .unwrap(); + assert_eq!(cookie_2.max_age(), Some(Duration::days(7))); + + // Step 5: POST to login, including session cookie #2 in request + // - set-cookie actix-session will be in response (session cookie #3) + // - updates session state: {"counter": 2, "user_id": "ferris"} + let req_5 = srv + .post("/login") + .cookie(cookie_2.clone()) + .send_json(&json!({"user_id": "ferris"})); + let mut resp_5 = req_5.await.unwrap(); + let cookie_3 = resp_5 + .cookies() + .unwrap() + .clone() + .into_iter() + .find(|c| c.name() == "test-session") + .unwrap(); + assert_ne!(cookie_2.value(), cookie_3.value()); + + let result_5 = resp_5.json::().await.unwrap(); + assert_eq!( + result_5, + IndexResponse { + user_id: Some("ferris".into()), + counter: 2 + } + ); + + // Step 6: GET index, including session cookie #3 in request + // - response should be: {"counter": 2, "user_id": "ferris"} + let req_6 = srv.get("/").cookie(cookie_3.clone()).send(); + let mut resp_6 = req_6.await.unwrap(); + let result_6 = resp_6.json::().await.unwrap(); + assert_eq!( + result_6, + IndexResponse { + user_id: Some("ferris".into()), + counter: 2 + } + ); + + // Step 7: POST again to do_something, including session cookie #3 in request + // - updates session state: {"counter": 3, "user_id": "ferris"} + // - response should be: {"counter": 3, "user_id": "ferris"} + let req_7 = srv.post("/do_something").cookie(cookie_3.clone()).send(); + let mut resp_7 = req_7.await.unwrap(); + let result_7 = resp_7.json::().await.unwrap(); + assert_eq!( + result_7, + IndexResponse { + user_id: Some("ferris".into()), + counter: 3 + } + ); + + // Step 8: GET index, including session cookie #2 in request + // If invalidation is supported, no state will be found associated to this session. + // If invalidation is not supported, the old state will still be retrieved. + let req_8 = srv.get("/").cookie(cookie_2.clone()).send(); + let mut resp_8 = req_8.await.unwrap(); + if is_invalidation_supported { + assert!(resp_8.cookies().unwrap().is_empty()); + let result_8 = resp_8.json::().await.unwrap(); + assert_eq!( + result_8, + IndexResponse { + user_id: None, + counter: 0 + } + ); + } else { + let result_8 = resp_8.json::().await.unwrap(); + assert_eq!( + result_8, + IndexResponse { + user_id: None, + counter: 2 + } + ); + } + + // Step 9: POST to logout, including session cookie #3 + // - set-cookie actix-session will be in response with session cookie #3 + // invalidation logic + let req_9 = srv.post("/logout").cookie(cookie_3.clone()).send(); + let resp_9 = req_9.await.unwrap(); + let cookie_3 = resp_9 + .cookies() + .unwrap() + .clone() + .into_iter() + .find(|c| c.name() == "test-session") + .unwrap(); + assert_eq!(0, cookie_3.max_age().map(|t| t.whole_seconds()).unwrap()); + assert_eq!("/", cookie_3.path().unwrap()); + + // Step 10: GET index, including session cookie #3 in request + // - set-cookie actix-session should NOT be in response if invalidation is supported + // - response should be: {"counter": 0, "user_id": None} + let req_10 = srv.get("/").cookie(cookie_3.clone()).send(); + let mut resp_10 = req_10.await.unwrap(); + if is_invalidation_supported { + assert!(resp_10.cookies().unwrap().is_empty()); + } + let result_10 = resp_10.json::().await.unwrap(); + assert_eq!( + result_10, + IndexResponse { + user_id: None, + counter: 0 + } + ); + } + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + pub struct IndexResponse { + user_id: Option, + counter: i32, + } + + async fn index(session: Session) -> Result { + let user_id: Option = session.get::("user_id").unwrap(); + let counter: i32 = session + .get::("counter") + .unwrap_or(Some(0)) + .unwrap_or(0); + + Ok(HttpResponse::Ok().json(&IndexResponse { user_id, counter })) + } + + async fn do_something(session: Session) -> Result { + let user_id: Option = session.get::("user_id").unwrap(); + let counter: i32 = session + .get::("counter") + .unwrap_or(Some(0)) + .map_or(1, |inner| inner + 1); + session.insert("counter", &counter)?; + + Ok(HttpResponse::Ok().json(&IndexResponse { user_id, counter })) + } + + #[derive(Deserialize)] + struct Identity { + user_id: String, + } + + async fn login(user_id: web::Json, session: Session) -> Result { + let id = user_id.into_inner().user_id; + session.insert("user_id", &id)?; + session.renew(); + + let counter: i32 = session + .get::("counter") + .unwrap_or(Some(0)) + .unwrap_or(0); + + Ok(HttpResponse::Ok().json(&IndexResponse { + user_id: Some(id), + counter, + })) + } + + async fn logout(session: Session) -> Result { + let id: Option = session.get("user_id")?; + + let body = if let Some(x) = id { + session.purge(); + format!("Logged out: {}", x) + } else { + "Could not log out anonymous user".to_owned() + }; + + Ok(HttpResponse::Ok().body(body)) + } } } diff --git a/actix-session/src/middleware.rs b/actix-session/src/middleware.rs new file mode 100644 index 000000000..d2aaf4f07 --- /dev/null +++ b/actix-session/src/middleware.rs @@ -0,0 +1,646 @@ +use std::{collections::HashMap, convert::TryInto, fmt, future::Future, pin::Pin, rc::Rc}; + +use actix_utils::future::{ready, Ready}; +use actix_web::{ + body::MessageBody, + cookie::{Cookie, CookieJar, Key, SameSite}, + dev::{forward_ready, ResponseHead, Service, ServiceRequest, ServiceResponse, Transform}, + http::header::{HeaderValue, SET_COOKIE}, +}; +use anyhow::Context; +use time::Duration; + +use crate::{ + storage::{LoadError, SessionKey, SessionStore}, + Session, SessionStatus, +}; + +/// A middleware for session management in Actix Web applications. +/// +/// [`SessionMiddleware`] takes care of a few jobs: +/// +/// - Instructs the session storage backend to create/update/delete/retrieve the state attached to +/// a session according to its status and the operations that have been performed against it; +/// - Set/remove a cookie, on the client side, to enable a user to be consistently associated with +/// the same session across multiple HTTP requests. +/// +/// Use [`SessionMiddleware::new`] to initialize the session framework using the default parameters. +/// To create a new instance of [`SessionMiddleware`] you need to provide: +/// +/// - an instance of the session storage backend you wish to use (i.e. an implementation of +/// [`SessionStore]); +/// - a secret key, to sign or encrypt the content of client-side session cookie. +/// +/// ```no_run +/// use actix_web::{web, App, HttpServer, HttpResponse, Error}; +/// use actix_session::{Session, SessionMiddleware, storage::RedisActorSessionStore}; +/// use actix_web::cookie::Key; +/// +/// // The secret key would usually be read from a configuration file/environment variables. +/// fn get_secret_key() -> Key { +/// # todo!() +/// // [...] +/// } +/// +/// #[actix_web::main] +/// async fn main() -> std::io::Result<()> { +/// let secret_key = get_secret_key(); +/// let redis_connection_string = "127.0.0.1:6379"; +/// HttpServer::new(move || +/// App::new() +/// // Add session management to your application using Redis for session state storage +/// .wrap( +/// SessionMiddleware::new( +/// RedisActorSessionStore::new(redis_connection_string), +/// secret_key.clone() +/// ) +/// ) +/// .default_service(web::to(|| HttpResponse::Ok()))) +/// .bind(("127.0.0.1", 8080))? +/// .run() +/// .await +/// } +/// ``` +/// +/// If you want to customise use [`builder`](Self::builder) instead of [`new`](Self::new): +/// +/// ```no_run +/// use actix_web::{cookie::Key, web, App, HttpServer, HttpResponse, Error}; +/// use actix_session::{Session, SessionMiddleware, storage::RedisActorSessionStore, SessionLength}; +/// +/// // The secret key would usually be read from a configuration file/environment variables. +/// fn get_secret_key() -> Key { +/// # todo!() +/// // [...] +/// } +/// +/// #[actix_web::main] +/// async fn main() -> std::io::Result<()> { +/// let secret_key = get_secret_key(); +/// let redis_connection_string = "127.0.0.1:6379"; +/// HttpServer::new(move || +/// App::new() +/// // Customise session length! +/// .wrap( +/// SessionMiddleware::builder( +/// RedisActorSessionStore::new(redis_connection_string), +/// secret_key.clone() +/// ) +/// .session_length(SessionLength::Predetermined { +/// max_session_length: Some(time::Duration::days(5)), +/// }) +/// .build(), +/// ) +/// .default_service(web::to(|| HttpResponse::Ok()))) +/// .bind(("127.0.0.1", 8080))? +/// .run() +/// .await +/// } +/// ``` +/// +/// ## How did we choose defaults? +/// +/// You should not regret adding `actix-session` to your dependencies and going to production using +/// the default configuration. That is why, when in doubt, we opt to use the most secure option for +/// each configuration parameter. +/// +/// We expose knobs to change the default to suit your needs—i.e., if you know what you are doing, +/// we will not stop you. But being a subject-matter expert should not be a requirement to deploy +/// reasonably secure implementation of sessions. +#[derive(Clone)] +pub struct SessionMiddleware { + storage_backend: Rc, + configuration: Rc, +} + +#[derive(Clone)] +struct Configuration { + cookie: CookieConfiguration, + session: SessionConfiguration, +} + +#[derive(Clone)] +struct SessionConfiguration { + state_ttl: Duration, +} + +#[derive(Clone)] +struct CookieConfiguration { + secure: bool, + http_only: bool, + name: String, + same_site: SameSite, + path: String, + domain: Option, + max_age: Option, + content_security: CookieContentSecurity, + key: Key, +} + +/// Describes how long a session should last. +/// +/// Used by [`SessionMiddlewareBuilder::session_length`]. +#[derive(Clone, Debug)] +pub enum SessionLength { + /// The session cookie will expire when the current browser session ends. + /// + /// When does a browser session end? It depends on the browser! Chrome, for example, will often + /// continue running in the background when the browser is closed—session cookies are not + /// deleted and they will still be available when the browser is opened again. Check the + /// documentation of the browser you are targeting for up-to-date information. + BrowserSession { + /// We must provide a time-to-live (TTL) when storing the session state in the storage + /// backend—we do not want to store session states indefinitely, otherwise we will + /// inevitably run out of storage by holding on to the state of countless abandoned or + /// expired sessions! + /// + /// We are dealing with the lifecycle of two uncorrelated object here: the session cookie + /// and the session state. It is not a big issue if the session state outlives the cookie— + /// we are wasting some space in the backend storage, but it will be cleaned up eventually. + /// What happens, instead, if the cookie outlives the session state? A new session starts— + /// e.g. if sessions are being used for authentication, the user is de-facto logged out. + /// + /// It is not possible to predict with certainty how long a browser session is going to + /// last—you need to provide a reasonable upper bound. You do so via `state_ttl`—it dictates + /// what TTL should be used for session state when the lifecycle of the session cookie is + /// tied to the browser session length. [`SessionMiddleware`] will default to 1 day if + /// `state_ttl` is left unspecified. + state_ttl: Option, + }, + + /// The session cookie will be a [persistent cookie]. + /// + /// Persistent cookies have a pre-determined lifetime, specified via the `Max-Age` or `Expires` + /// attribute. They do not disappear when the current browser session ends. + /// + /// [persistent cookie]: https://www.whitehatsec.com/glossary/content/persistent-session-cookie + Predetermined { + /// Set `max_session_length` to specify how long the session cookie should live. + /// [`SessionMiddleware`] will default to 1 day if `max_session_length` is set to `None`. + /// + /// `max_session_length` is also used as the TTL for the session state in the + /// storage backend. + max_session_length: Option, + }, +} + +/// Used by [`SessionMiddlewareBuilder::cookie_content_security`] to determine how to secure +/// the content of the session cookie. +#[derive(Debug, Clone, Copy)] +pub enum CookieContentSecurity { + /// `CookieContentSecurity::Private` translates into an encrypted cookie content. The end-user/ + /// JavaScript cannot tamper with its content nor decode it (i.e., it preserves confidentiality, + /// as long the as the encryption key is not breached). + Private, + + /// `CookieContentSecurity::Signed` translates into a signed cookie content. The end-user/ + /// JavaScript cannot tamper with its content, but they can read it (i.e., no confidentiality). + Signed, +} + +fn default_configuration(key: Key) -> Configuration { + Configuration { + cookie: CookieConfiguration { + secure: true, + http_only: true, + name: "id".into(), + same_site: SameSite::Lax, + path: "/".into(), + domain: None, + max_age: None, + content_security: CookieContentSecurity::Private, + key, + }, + session: SessionConfiguration { + state_ttl: default_ttl(), + }, + } +} + +fn default_ttl() -> Duration { + Duration::days(1) +} + +impl SessionMiddleware { + /// Use [`SessionMiddleware::new`] to initialize the session framework using the default + /// parameters. + /// + /// To create a new instance of [`SessionMiddleware`] you need to provide: + /// - an instance of the session storage backend you wish to use (i.e. an implementation of + /// [`SessionStore]); + /// - a secret key, to sign or encrypt the content of client-side session cookie. + pub fn new(store: Store, key: Key) -> Self { + Self { + storage_backend: Rc::new(store), + configuration: Rc::new(default_configuration(key)), + } + } + + /// A fluent API to configure [`SessionMiddleware`]. + /// + /// It takes as input the two required inputs to create a new instance of [`SessionMiddleware`]: + /// - an instance of the session storage backend you wish to use (i.e. an implementation of + /// [`SessionStore]); + /// - a secret key, to sign or encrypt the content of client-side session cookie. + pub fn builder(store: Store, key: Key) -> SessionMiddlewareBuilder { + SessionMiddlewareBuilder { + storage_backend: Rc::new(store), + configuration: default_configuration(key), + } + } +} + +/// A fluent builder to construct a [`SessionMiddleware`] instance with custom configuration +/// parameters. +#[must_use] +pub struct SessionMiddlewareBuilder { + storage_backend: Rc, + configuration: Configuration, +} + +impl SessionMiddlewareBuilder { + /// Set the name of the cookie used to store the session ID. + /// + /// Defaults to `id`. + pub fn cookie_name(mut self, name: String) -> Self { + self.configuration.cookie.name = name; + self + } + + /// Set the `Secure` attribute for the cookie used to store the session ID. + /// + /// If the cookie is set as secure, it will only be transmitted when the connection is secure + /// (using `https`). + /// + /// Default is `true`. + pub fn cookie_secure(mut self, secure: bool) -> Self { + self.configuration.cookie.secure = secure; + self + } + + /// Determine how long a session should last - check out [`SessionLength`]'s documentation for + /// more details on the available options. + /// + /// Default is [`SessionLength::BrowserSession`]. + pub fn session_length(mut self, session_length: SessionLength) -> Self { + match session_length { + SessionLength::BrowserSession { state_ttl } => { + self.configuration.cookie.max_age = None; + self.configuration.session.state_ttl = state_ttl.unwrap_or_else(default_ttl); + } + SessionLength::Predetermined { max_session_length } => { + let ttl = max_session_length.unwrap_or_else(default_ttl); + self.configuration.cookie.max_age = Some(ttl); + self.configuration.session.state_ttl = ttl; + } + } + + self + } + + /// Set the `SameSite` attribute for the cookie used to store the session ID. + /// + /// By default, the attribute is set to `Lax`. + pub fn cookie_same_site(mut self, same_site: SameSite) -> Self { + self.configuration.cookie.same_site = same_site; + self + } + + /// Set the `Path` attribute for the cookie used to store the session ID. + /// + /// By default, the attribute is set to `/`. + pub fn cookie_path(mut self, path: String) -> Self { + self.configuration.cookie.path = path; + self + } + + /// Set the `Domain` attribute for the cookie used to store the session ID. + /// + /// Use `None` to leave the attribute unspecified. If unspecified, the attribute defaults + /// to the same host that set the cookie, excluding subdomains. + /// + /// By default, the attribute is left unspecified. + pub fn cookie_domain(mut self, domain: Option) -> Self { + self.configuration.cookie.domain = domain; + self + } + + /// Choose how the session cookie content should be secured. + /// + /// - `CookieContentSecurity::Private` translates into an encrypted cookie content. + /// - `CookieContentSecurity::Signed` translates into a signed cookie content. + /// + /// # Default + /// By default, the cookie content is encrypted. Encrypted was chosen instead of signed as + /// default because it reduces the chances of sensitive information being exposed in the session + /// key by accident, regardless of [`SessionStore`] implementation you chose to use. + /// + /// For example, if you are using cookie-based storage, you definitely want the cookie content + /// to be encrypted—the whole session state is embedded in the cookie! If you are using + /// Redis-based storage, signed is more than enough - the cookie content is just a unique + /// tamper-proof session key. + pub fn cookie_content_security(mut self, content_security: CookieContentSecurity) -> Self { + self.configuration.cookie.content_security = content_security; + self + } + + /// Set the `HttpOnly` attribute for the cookie used to store the session ID. + /// + /// If the cookie is set as `HttpOnly`, it will not be visible to any JavaScript snippets + /// running in the browser. + /// + /// Default is `true`. + pub fn cookie_http_only(mut self, http_only: bool) -> Self { + self.configuration.cookie.http_only = http_only; + self + } + + /// Finalise the builder and return a [`SessionMiddleware`] instance. + #[must_use] + pub fn build(self) -> SessionMiddleware { + SessionMiddleware { + storage_backend: self.storage_backend, + configuration: Rc::new(self.configuration), + } + } +} + +impl Transform for SessionMiddleware +where + S: Service, Error = actix_web::Error> + 'static, + S::Future: 'static, + B: MessageBody + 'static, + Store: SessionStore + 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Transform = InnerSessionMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(InnerSessionMiddleware { + service: Rc::new(service), + configuration: Rc::clone(&self.configuration), + storage_backend: Rc::clone(&self.storage_backend), + })) + } +} + +/// Short-hand to create an `actix_web::Error` instance that will result in an `Internal Server +/// Error` response while preserving the error root cause (e.g. in logs). +fn e500(err: E) -> actix_web::Error { + actix_web::error::ErrorInternalServerError(err) +} + +#[doc(hidden)] +#[non_exhaustive] +pub struct InnerSessionMiddleware { + service: Rc, + configuration: Rc, + storage_backend: Rc, +} + +impl Service for InnerSessionMiddleware +where + S: Service, Error = actix_web::Error> + 'static, + S::Future: 'static, + Store: SessionStore + 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + #[allow(clippy::type_complexity)] + type Future = Pin>>>; + + forward_ready!(service); + + fn call(&self, mut req: ServiceRequest) -> Self::Future { + let service = Rc::clone(&self.service); + let storage_backend = Rc::clone(&self.storage_backend); + let configuration = Rc::clone(&self.configuration); + + Box::pin(async move { + let session_key = extract_session_key(&req, &configuration.cookie); + let (session_key, session_state) = + load_session_state(session_key, storage_backend.as_ref()).await?; + + Session::set_session(&mut req, session_state); + + let mut res = service.call(req).await?; + let (status, session_state) = Session::get_changes(&mut res); + + match session_key { + None => { + // we do not create an entry in the session store if there is no state attached + // to a fresh session + if !session_state.is_empty() { + let session_key = storage_backend + .save(session_state, &configuration.session.state_ttl) + .await + .map_err(e500)?; + + set_session_cookie( + res.response_mut().head_mut(), + session_key, + &configuration.cookie, + ) + .map_err(e500)?; + } + } + + Some(session_key) => { + match status { + SessionStatus::Changed => { + let session_key = storage_backend + .update( + session_key, + session_state, + &configuration.session.state_ttl, + ) + .await + .map_err(e500)?; + + set_session_cookie( + res.response_mut().head_mut(), + session_key, + &configuration.cookie, + ) + .map_err(e500)?; + } + + SessionStatus::Purged => { + storage_backend.delete(&session_key).await.map_err(e500)?; + + delete_session_cookie( + res.response_mut().head_mut(), + &configuration.cookie, + ) + .map_err(e500)?; + } + + SessionStatus::Renewed => { + storage_backend.delete(&session_key).await.map_err(e500)?; + + let session_key = storage_backend + .save(session_state, &configuration.session.state_ttl) + .await + .map_err(e500)?; + + set_session_cookie( + res.response_mut().head_mut(), + session_key, + &configuration.cookie, + ) + .map_err(e500)?; + } + + SessionStatus::Unchanged => { + // Nothing to do; we avoid the unnecessary call to the storage. + } + } + } + }; + Ok(res) + }) + } +} + +fn extract_session_key(req: &ServiceRequest, config: &CookieConfiguration) -> Option { + let cookies = req.cookies().ok()?; + let session_cookie = cookies + .iter() + .find(|&cookie| cookie.name() == config.name)?; + + let mut jar = CookieJar::new(); + jar.add_original(session_cookie.clone()); + + let verification_result = match config.content_security { + CookieContentSecurity::Signed => jar.signed(&config.key).get(&config.name), + CookieContentSecurity::Private => jar.private(&config.key).get(&config.name), + }; + + if verification_result.is_none() { + tracing::warn!( + "The session cookie attached to the incoming request failed to pass cryptographic \ + checks (signature verification/decryption)." + ); + } + + match verification_result?.value().to_owned().try_into() { + Ok(session_key) => Some(session_key), + Err(err) => { + tracing::warn!( + error.message = %err, + error.cause_chain = ?err, + "Invalid session key, ignoring." + ); + + None + } + } +} + +async fn load_session_state( + session_key: Option, + storage_backend: &Store, +) -> Result<(Option, HashMap), actix_web::Error> { + if let Some(session_key) = session_key { + match storage_backend.load(&session_key).await { + Ok(state) => { + if let Some(state) = state { + Ok((Some(session_key), state)) + } else { + // We discard the existing session key given that the state attached to it can + // no longer be found (e.g. it expired or we suffered some data loss in the + // storage). Regenerating the session key will trigger the `save` workflow + // instead of the `update` workflow if the session state is modified during the + // lifecycle of the current request. + + tracing::info!( + "No session state has been found for a valid session key, creating a new \ + empty session." + ); + + Ok((None, HashMap::new())) + } + } + + Err(err) => match err { + LoadError::Deserialization(err) => { + tracing::warn!( + error.message = %err, + error.cause_chain = ?err, + "Invalid session state, creating a new empty session." + ); + + Ok((Some(session_key), HashMap::new())) + } + + LoadError::Other(err) => Err(e500(err)), + }, + } + } else { + Ok((None, HashMap::new())) + } +} + +fn set_session_cookie( + response: &mut ResponseHead, + session_key: SessionKey, + config: &CookieConfiguration, +) -> Result<(), anyhow::Error> { + let value: String = session_key.into(); + let mut cookie = Cookie::new(config.name.clone(), value); + + cookie.set_secure(config.secure); + cookie.set_http_only(config.http_only); + cookie.set_same_site(config.same_site); + cookie.set_path(config.path.clone()); + + if let Some(max_age) = config.max_age { + cookie.set_max_age(max_age); + } + + if let Some(ref domain) = config.domain { + cookie.set_domain(domain.clone()); + } + + let mut jar = CookieJar::new(); + match config.content_security { + CookieContentSecurity::Signed => jar.signed_mut(&config.key).add(cookie), + CookieContentSecurity::Private => jar.private_mut(&config.key).add(cookie), + } + + // set cookie + let cookie = jar.delta().next().unwrap(); + let val = HeaderValue::from_str(&cookie.encoded().to_string()) + .context("Failed to attach a session cookie to the outgoing response")?; + + response.headers_mut().append(SET_COOKIE, val); + + Ok(()) +} + +fn delete_session_cookie( + response: &mut ResponseHead, + config: &CookieConfiguration, +) -> Result<(), anyhow::Error> { + let removal_cookie = Cookie::build(config.name.clone(), "") + .path(config.path.clone()) + .http_only(config.http_only); + + let mut removal_cookie = if let Some(ref domain) = config.domain { + removal_cookie.domain(domain) + } else { + removal_cookie + } + .finish(); + + removal_cookie.make_removal(); + + let val = HeaderValue::from_str(&removal_cookie.to_string()) + .context("Failed to attach a session removal cookie to the outgoing response")?; + response.headers_mut().append(SET_COOKIE, val); + + Ok(()) +} diff --git a/actix-session/src/session.rs b/actix-session/src/session.rs new file mode 100644 index 000000000..2aa3bae68 --- /dev/null +++ b/actix-session/src/session.rs @@ -0,0 +1,256 @@ +use std::{ + cell::{Ref, RefCell}, + collections::HashMap, + mem, + rc::Rc, +}; + +use actix_utils::future::{ready, Ready}; +use actix_web::{ + dev::{Extensions, Payload, ServiceRequest, ServiceResponse}, + error::Error, + FromRequest, HttpMessage, HttpRequest, +}; +use serde::{de::DeserializeOwned, Serialize}; + +/// The primary interface to access and modify session state. +/// +/// [`Session`] is an [extractor](#impl-FromRequest)—you can specify it as an input type for your +/// request handlers and it will be automatically extracted from the incoming request. +/// +/// ``` +/// use actix_session::Session; +/// +/// async fn index(session: Session) -> actix_web::Result<&'static str> { +/// // access session data +/// if let Some(count) = session.get::("counter")? { +/// session.insert("counter", count + 1)?; +/// } else { +/// session.insert("counter", 1)?; +/// } +/// +/// Ok("Welcome!") +/// } +/// # actix_web::web::to(index); +/// ``` +/// +/// You can also retrieve a [`Session`] object from an `HttpRequest` or a `ServiceRequest` using +/// [`SessionExt`]. +/// +/// [`SessionExt`]: crate::SessionExt +pub struct Session(Rc>); + +/// Status of a [`Session`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionStatus { + /// Session state has been updated - the changes will have to be persisted to the backend. + Changed, + + /// The session has been flagged for deletion - the session cookie will be removed from + /// the client and the session state will be deleted from the session store. + /// + /// Most operations on the session after it has been marked for deletion will have no effect. + Purged, + + /// The session has been flagged for renewal. + /// + /// The session key will be regenerated and the time-to-live of the session state will be + /// extended. + Renewed, + + /// The session state has not been modified since its creation/retrieval. + Unchanged, +} + +impl Default for SessionStatus { + fn default() -> SessionStatus { + SessionStatus::Unchanged + } +} + +#[derive(Default)] +struct SessionInner { + state: HashMap, + status: SessionStatus, +} + +impl Session { + /// Get a `value` from the session. + /// + /// It returns an error if it fails to deserialize as `T` the JSON value associated with `key`. + pub fn get(&self, key: &str) -> Result, serde_json::Error> { + if let Some(val_str) = self.0.borrow().state.get(key) { + Ok(Some(serde_json::from_str(val_str)?)) + } else { + Ok(None) + } + } + + /// Get all raw key-value data from the session. + /// + /// Note that values are JSON encoded. + pub fn entries(&self) -> Ref<'_, HashMap> { + Ref::map(self.0.borrow(), |inner| &inner.state) + } + + /// Returns session status. + pub fn status(&self) -> SessionStatus { + Ref::map(self.0.borrow(), |inner| &inner.status).clone() + } + + /// Inserts a key-value pair into the session. + /// + /// Any serializable value can be used and will be encoded as JSON in session data, hence why + /// only a reference to the value is taken. + /// + /// It returns an error if it fails to serialize `value` to JSON. + pub fn insert( + &self, + key: impl Into, + value: impl Serialize, + ) -> Result<(), serde_json::Error> { + let mut inner = self.0.borrow_mut(); + + if inner.status != SessionStatus::Purged { + inner.status = SessionStatus::Changed; + let val = serde_json::to_string(&value)?; + inner.state.insert(key.into(), val); + } + + Ok(()) + } + + /// Remove value from the session. + /// + /// If present, the JSON encoded value is returned. + pub fn remove(&self, key: &str) -> Option { + let mut inner = self.0.borrow_mut(); + + if inner.status != SessionStatus::Purged { + inner.status = SessionStatus::Changed; + return inner.state.remove(key); + } + + None + } + + /// Remove value from the session and deserialize. + /// + /// Returns None if key was not present in session. Returns `T` if deserialization succeeds, + /// otherwise returns un-deserialized JSON string. + pub fn remove_as(&self, key: &str) -> Option> { + self.remove(key) + .map(|val_str| match serde_json::from_str(&val_str) { + Ok(val) => Ok(val), + Err(_err) => { + tracing::debug!( + "removed value (key: {}) could not be deserialized as {}", + key, + std::any::type_name::() + ); + + Err(val_str) + } + }) + } + + /// Clear the session. + pub fn clear(&self) { + let mut inner = self.0.borrow_mut(); + + if inner.status != SessionStatus::Purged { + inner.status = SessionStatus::Changed; + inner.state.clear() + } + } + + /// Removes session both client and server side. + pub fn purge(&self) { + let mut inner = self.0.borrow_mut(); + inner.status = SessionStatus::Purged; + inner.state.clear(); + } + + /// Renews the session key, assigning existing session state to new key. + pub fn renew(&self) { + let mut inner = self.0.borrow_mut(); + + if inner.status != SessionStatus::Purged { + inner.status = SessionStatus::Renewed; + } + } + + /// Adds the given key-value pairs to the session on the request. + /// + /// Values that match keys already existing on the session will be overwritten. Values should + /// already be JSON serialized. + pub(crate) fn set_session( + req: &mut ServiceRequest, + data: impl IntoIterator, + ) { + let session = Session::get_session(&mut *req.extensions_mut()); + let mut inner = session.0.borrow_mut(); + inner.state.extend(data); + } + + /// Returns session status and iterator of key-value pairs of changes. + /// + /// This is a destructive operation - the session state is removed from the request extensions typemap, + /// leaving behind a new empty map. It should only be used when the session is being finalised (i.e. + /// in `SessionMiddleware`). + pub(crate) fn get_changes( + res: &mut ServiceResponse, + ) -> (SessionStatus, HashMap) { + if let Some(s_impl) = res + .request() + .extensions() + .get::>>() + { + let state = mem::take(&mut s_impl.borrow_mut().state); + (s_impl.borrow().status.clone(), state) + } else { + (SessionStatus::Unchanged, HashMap::new()) + } + } + + pub(crate) fn get_session(extensions: &mut Extensions) -> Session { + if let Some(s_impl) = extensions.get::>>() { + return Session(Rc::clone(s_impl)); + } + + let inner = Rc::new(RefCell::new(SessionInner::default())); + extensions.insert(inner.clone()); + + Session(inner) + } +} + +/// Extractor implementation for [`Session`]s. +/// +/// # Examples +/// ``` +/// # use actix_web::*; +/// use actix_session::Session; +/// +/// #[get("/")] +/// async fn index(session: Session) -> Result { +/// // access session data +/// if let Some(count) = session.get::("counter")? { +/// session.insert("counter", count + 1)?; +/// } else { +/// session.insert("counter", 1)?; +/// } +/// +/// let count = session.get::("counter")?.unwrap(); +/// Ok(format!("Counter: {}", count)) +/// } +/// ``` +impl FromRequest for Session { + type Error = Error; + type Future = Ready>; + + #[inline] + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + ready(Ok(Session::get_session(&mut *req.extensions_mut()))) + } +} diff --git a/actix-session/src/session_ext.rs b/actix-session/src/session_ext.rs new file mode 100644 index 000000000..00d799c11 --- /dev/null +++ b/actix-session/src/session_ext.rs @@ -0,0 +1,31 @@ +use actix_web::{ + dev::{ServiceRequest, ServiceResponse}, + HttpMessage, HttpRequest, +}; + +use crate::Session; + +/// Extract a [`Session`] object from various `actix-web` types (e.g. `HttpRequest`, +/// `ServiceRequest`, `ServiceResponse`). +pub trait SessionExt { + /// Extract a [`Session`] object. + fn get_session(&self) -> Session; +} + +impl SessionExt for HttpRequest { + fn get_session(&self) -> Session { + Session::get_session(&mut *self.extensions_mut()) + } +} + +impl SessionExt for ServiceRequest { + fn get_session(&self) -> Session { + Session::get_session(&mut *self.extensions_mut()) + } +} + +impl SessionExt for ServiceResponse { + fn get_session(&self) -> Session { + self.request().get_session() + } +} diff --git a/actix-session/src/storage/cookie.rs b/actix-session/src/storage/cookie.rs new file mode 100644 index 000000000..34bdceae4 --- /dev/null +++ b/actix-session/src/storage/cookie.rs @@ -0,0 +1,116 @@ +use std::convert::TryInto; + +use time::Duration; + +use super::SessionKey; +use crate::storage::{ + interface::{LoadError, SaveError, SessionState, UpdateError}, + SessionStore, +}; + +/// Use the session key, stored in the session cookie, as storage backend for the session state. +/// +/// ```no_run +/// use actix_web::{cookie::Key, web, App, HttpServer, HttpResponse, Error}; +/// use actix_session::{SessionMiddleware, storage::CookieSessionStore}; +/// +/// // The secret key would usually be read from a configuration file/environment variables. +/// fn get_secret_key() -> Key { +/// # todo!() +/// // [...] +/// } +/// +/// #[actix_web::main] +/// async fn main() -> std::io::Result<()> { +/// let secret_key = get_secret_key(); +/// HttpServer::new(move || +/// App::new() +/// .wrap(SessionMiddleware::new(CookieSessionStore::default(), secret_key.clone())) +/// .default_service(web::to(|| HttpResponse::Ok()))) +/// .bind(("127.0.0.1", 8080))? +/// .run() +/// .await +/// } +/// ``` +/// +/// # Limitations +/// Cookies are subject to size limits - we require session keys to be shorter than 4096 bytes. This +/// translates into a limit on the maximum size of the session state when using cookies as storage +/// backend. +/// +/// The session cookie can always be inspected by end users via the developer tools exposed by their +/// browsers. We strongly recommend setting the policy to [`CookieContentSecurity::Private`] when +/// using cookies as storage backend. +/// +/// There is no way to invalidate a session before its natural expiry when using cookies as the +/// storage backend. +/// +/// [`CookieContentSecurity::Private`]: crate::CookieContentSecurity::Private +#[cfg_attr(docsrs, doc(cfg(feature = "cookie-session")))] +#[derive(Default)] +#[non_exhaustive] +pub struct CookieSessionStore; + +#[async_trait::async_trait(?Send)] +impl SessionStore for CookieSessionStore { + async fn load(&self, session_key: &SessionKey) -> Result, LoadError> { + serde_json::from_str(session_key.as_ref()) + .map(Some) + .map_err(anyhow::Error::new) + .map_err(LoadError::Deserialization) + } + + async fn save( + &self, + session_state: SessionState, + _ttl: &Duration, + ) -> Result { + let session_key = serde_json::to_string(&session_state) + .map_err(anyhow::Error::new) + .map_err(SaveError::Serialization)?; + + Ok(session_key + .try_into() + .map_err(Into::into) + .map_err(SaveError::Other)?) + } + + async fn update( + &self, + _session_key: SessionKey, + session_state: SessionState, + ttl: &Duration, + ) -> Result { + self.save(session_state, ttl) + .await + .map_err(|err| match err { + SaveError::Serialization(err) => UpdateError::Serialization(err), + SaveError::Other(err) => UpdateError::Other(err), + }) + } + + async fn delete(&self, _session_key: &SessionKey) -> Result<(), anyhow::Error> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{storage::utils::generate_session_key, test_helpers::acceptance_test_suite}; + + #[actix_web::test] + async fn test_session_workflow() { + acceptance_test_suite(CookieSessionStore::default, false).await; + } + + #[actix_web::test] + async fn loading_a_random_session_key_returns_deserialization_error() { + let store = CookieSessionStore::default(); + let session_key = generate_session_key(); + assert!(matches!( + store.load(&session_key).await.unwrap_err(), + LoadError::Deserialization(_), + )); + } +} diff --git a/actix-session/src/storage/interface.rs b/actix-session/src/storage/interface.rs new file mode 100644 index 000000000..0419de954 --- /dev/null +++ b/actix-session/src/storage/interface.rs @@ -0,0 +1,104 @@ +use std::collections::HashMap; + +use derive_more::Display; +use time::Duration; + +use super::SessionKey; + +pub(crate) type SessionState = HashMap; + +/// The interface to retrieve and save the current session data from/to the chosen storage backend. +/// +/// You can provide your own custom session store backend by implementing this trait. +#[async_trait::async_trait(?Send)] +pub trait SessionStore { + /// Loads the session state associated to a session key. + async fn load(&self, session_key: &SessionKey) -> Result, LoadError>; + + /// Persist the session state for a newly created session. + /// + /// Returns the corresponding session key. + async fn save( + &self, + session_state: SessionState, + ttl: &Duration, + ) -> Result; + + /// Updates the session state associated to a pre-existing session key. + async fn update( + &self, + session_key: SessionKey, + session_state: SessionState, + ttl: &Duration, + ) -> Result; + + /// Deletes a session from the store. + async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error>; +} + +// We cannot derive the `Error` implementation using `derive_more` for our custom errors: +// `derive_more`'s `#[error(source)]` attribute requires the source implement the `Error` trait, +// while it's actually enough for it to be able to produce a reference to a dyn Error. + +/// Possible failures modes for [`SessionStore::load`]. +#[derive(Debug, Display)] +pub enum LoadError { + /// Failed to deserialize session state. + #[display(fmt = "Failed to deserialize session state")] + Deserialization(anyhow::Error), + + /// Something went wrong when retrieving the session state. + #[display(fmt = "Something went wrong when retrieving the session state")] + Other(anyhow::Error), +} + +impl std::error::Error for LoadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Deserialization(err) => Some(err.as_ref()), + Self::Other(err) => Some(err.as_ref()), + } + } +} + +/// Possible failures modes for [`SessionStore::save`]. +#[derive(Debug, Display)] +pub enum SaveError { + /// Failed to serialize session state. + #[display(fmt = "Failed to serialize session state")] + Serialization(anyhow::Error), + + /// Something went wrong when persisting the session state. + #[display(fmt = "Something went wrong when persisting the session state")] + Other(anyhow::Error), +} + +impl std::error::Error for SaveError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Serialization(err) => Some(err.as_ref()), + Self::Other(err) => Some(err.as_ref()), + } + } +} + +#[derive(Debug, Display)] +/// Possible failures modes for [`SessionStore::update`]. +pub enum UpdateError { + /// Failed to serialize session state. + #[display(fmt = "Failed to serialize session state")] + Serialization(anyhow::Error), + + /// Something went wrong when updating the session state. + #[display(fmt = "Something went wrong when updating the session state.")] + Other(anyhow::Error), +} + +impl std::error::Error for UpdateError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Serialization(err) => Some(err.as_ref()), + Self::Other(err) => Some(err.as_ref()), + } + } +} diff --git a/actix-session/src/storage/mod.rs b/actix-session/src/storage/mod.rs new file mode 100644 index 000000000..d8c47a5e8 --- /dev/null +++ b/actix-session/src/storage/mod.rs @@ -0,0 +1,28 @@ +//! Pluggable storage backends for session state. + +mod interface; +mod session_key; + +pub use self::interface::{LoadError, SaveError, SessionStore, UpdateError}; +pub use self::session_key::SessionKey; + +#[cfg(feature = "cookie-session")] +mod cookie; + +#[cfg(feature = "redis-actor-session")] +mod redis_actor; + +#[cfg(feature = "redis-rs-session")] +mod redis_rs; + +#[cfg(any(feature = "redis-actor-session", feature = "redis-rs-session"))] +mod utils; + +#[cfg(feature = "cookie-session")] +pub use cookie::CookieSessionStore; + +#[cfg(feature = "redis-actor-session")] +pub use redis_actor::{RedisActorSessionStore, RedisActorSessionStoreBuilder}; + +#[cfg(feature = "redis-rs-session")] +pub use redis_rs::{RedisSessionStore, RedisSessionStoreBuilder}; diff --git a/actix-session/src/storage/redis_actor.rs b/actix-session/src/storage/redis_actor.rs new file mode 100644 index 000000000..f226dec34 --- /dev/null +++ b/actix-session/src/storage/redis_actor.rs @@ -0,0 +1,294 @@ +use actix::Addr; +use actix_redis::{resp_array, Command, RedisActor, RespValue}; +use time::{self, Duration}; + +use super::SessionKey; +use crate::storage::{ + interface::{LoadError, SaveError, SessionState, UpdateError}, + utils::generate_session_key, + SessionStore, +}; + +/// Use Redis as session storage backend. +/// +/// ```no_run +/// use actix_web::{web, App, HttpServer, HttpResponse, Error}; +/// use actix_session::{SessionMiddleware, storage::RedisActorSessionStore}; +/// use actix_web::cookie::Key; +/// +/// // The secret key would usually be read from a configuration file/environment variables. +/// fn get_secret_key() -> Key { +/// # todo!() +/// // [...] +/// } +/// +/// #[actix_web::main] +/// async fn main() -> std::io::Result<()> { +/// let secret_key = get_secret_key(); +/// let redis_connection_string = "127.0.0.1:6379"; +/// HttpServer::new(move || +/// App::new() +/// .wrap( +/// SessionMiddleware::new( +/// RedisActorSessionStore::new(redis_connection_string), +/// secret_key.clone() +/// ) +/// ) +/// .default_service(web::to(|| HttpResponse::Ok()))) +/// .bind(("127.0.0.1", 8080))? +/// .run() +/// .await +/// } +/// ``` +/// +/// # Implementation notes +/// +/// `RedisActorSessionStore` leverages `actix-redis`'s `RedisActor` implementation - each thread +/// worker gets its own connection to Redis. +/// +/// ## Limitations +/// +/// `RedisActorSessionStore` does not currently support establishing authenticated connections to +/// Redis. Use [`RedisSessionStore`] if you need TLS support. +/// +/// [`RedisSessionStore`]: crate::storage::RedisSessionStore +#[cfg_attr(docsrs, doc(cfg(feature = "redis-actor-session")))] +pub struct RedisActorSessionStore { + configuration: CacheConfiguration, + addr: Addr, +} + +impl RedisActorSessionStore { + /// A fluent API to configure [`RedisActorSessionStore`]. + /// + /// It takes as input the only required input to create a new instance of + /// [`RedisActorSessionStore`]—a connection string for Redis. + pub fn builder>(connection_string: S) -> RedisActorSessionStoreBuilder { + RedisActorSessionStoreBuilder { + configuration: CacheConfiguration::default(), + connection_string: connection_string.into(), + } + } + + /// Create a new instance of [`RedisActorSessionStore`] using the default configuration. + /// It takes as input the only required input to create a new instance of [`RedisActorSessionStore`] - a + /// connection string for Redis. + pub fn new>(connection_string: S) -> RedisActorSessionStore { + Self::builder(connection_string).build() + } +} + +struct CacheConfiguration { + cache_keygen: Box String>, +} + +impl Default for CacheConfiguration { + fn default() -> Self { + Self { + cache_keygen: Box::new(str::to_owned), + } + } +} + +/// A fluent builder to construct a [`RedisActorSessionStore`] instance with custom configuration +/// parameters. +#[cfg_attr(docsrs, doc(cfg(feature = "redis-actor-session")))] +#[must_use] +pub struct RedisActorSessionStoreBuilder { + connection_string: String, + configuration: CacheConfiguration, +} + +impl RedisActorSessionStoreBuilder { + /// Set a custom cache key generation strategy, expecting a session key as input. + pub fn cache_keygen(mut self, keygen: F) -> Self + where + F: Fn(&str) -> String + 'static, + { + self.configuration.cache_keygen = Box::new(keygen); + self + } + + /// Finalise the builder and return a [`RedisActorSessionStore`] instance. + #[must_use] + pub fn build(self) -> RedisActorSessionStore { + RedisActorSessionStore { + configuration: self.configuration, + addr: RedisActor::start(self.connection_string), + } + } +} + +#[async_trait::async_trait(?Send)] +impl SessionStore for RedisActorSessionStore { + async fn load(&self, session_key: &SessionKey) -> Result, LoadError> { + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + let val = self + .addr + .send(Command(resp_array!["GET", cache_key])) + .await + .map_err(Into::into) + .map_err(LoadError::Other)? + .map_err(Into::into) + .map_err(LoadError::Other)?; + + match val { + RespValue::Error(err) => Err(LoadError::Other(anyhow::anyhow!(err))), + + RespValue::SimpleString(s) => Ok(serde_json::from_str(&s) + .map_err(Into::into) + .map_err(LoadError::Deserialization)?), + + RespValue::BulkString(s) => Ok(serde_json::from_slice(&s) + .map_err(Into::into) + .map_err(LoadError::Deserialization)?), + + _ => Ok(None), + } + } + + async fn save( + &self, + session_state: SessionState, + ttl: &Duration, + ) -> Result { + let body = serde_json::to_string(&session_state) + .map_err(Into::into) + .map_err(SaveError::Serialization)?; + let session_key = generate_session_key(); + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + let cmd = Command(resp_array![ + "SET", + cache_key, + body, + "NX", // NX: only set the key if it does not already exist + "EX", // EX: set expiry + format!("{}", ttl.whole_seconds()) + ]); + + let result = self + .addr + .send(cmd) + .await + .map_err(Into::into) + .map_err(SaveError::Other)? + .map_err(Into::into) + .map_err(SaveError::Other)?; + + match result { + RespValue::SimpleString(_) => Ok(session_key), + RespValue::Nil => Err(SaveError::Other(anyhow::anyhow!( + "Failed to save session state. A record with the same key already existed in Redis" + ))), + err => Err(SaveError::Other(anyhow::anyhow!( + "Failed to save session state. {:?}", + err + ))), + } + } + + async fn update( + &self, + session_key: SessionKey, + session_state: SessionState, + ttl: &Duration, + ) -> Result { + let body = serde_json::to_string(&session_state) + .map_err(Into::into) + .map_err(UpdateError::Serialization)?; + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + let cmd = Command(resp_array![ + "SET", + cache_key, + body, + "XX", // XX: Only set the key if it already exist. + "EX", // EX: set expiry + format!("{}", ttl.whole_seconds()) + ]); + + let result = self + .addr + .send(cmd) + .await + .map_err(Into::into) + .map_err(UpdateError::Other)? + .map_err(Into::into) + .map_err(UpdateError::Other)?; + + match result { + RespValue::Nil => { + // The SET operation was not performed because the XX condition was not verified. + // This can happen if the session state expired between the load operation and the + // update operation. Unlucky, to say the least. We fall back to the `save` routine + // to ensure that the new key is unique. + self.save(session_state, ttl) + .await + .map_err(|err| match err { + SaveError::Serialization(err) => UpdateError::Serialization(err), + SaveError::Other(err) => UpdateError::Other(err), + }) + } + RespValue::SimpleString(_) => Ok(session_key), + val => Err(UpdateError::Other(anyhow::anyhow!( + "Failed to update session state. {:?}", + val + ))), + } + } + + async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error> { + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + let res = self + .addr + .send(Command(resp_array!["DEL", cache_key])) + .await?; + + match res { + // Redis returns the number of deleted records + Ok(RespValue::Integer(_)) => Ok(()), + val => Err(anyhow::anyhow!( + "Failed to remove session from cache. {:?}", + val + )), + } + } +} + +#[cfg(test)] +mod test { + use std::collections::HashMap; + + use super::*; + use crate::test_helpers::acceptance_test_suite; + + fn redis_actor_store() -> RedisActorSessionStore { + RedisActorSessionStore::new("127.0.0.1:6379") + } + + #[actix_web::test] + async fn test_session_workflow() { + acceptance_test_suite(redis_actor_store, true).await; + } + + #[actix_web::test] + async fn loading_a_missing_session_returns_none() { + let store = redis_actor_store(); + let session_key = generate_session_key(); + assert!(store.load(&session_key).await.unwrap().is_none()); + } + + #[actix_web::test] + async fn updating_of_an_expired_state_is_handled_gracefully() { + let store = redis_actor_store(); + let session_key = generate_session_key(); + let initial_session_key = session_key.as_ref().to_owned(); + let updated_session_key = store + .update(session_key, HashMap::new(), &time::Duration::seconds(1)) + .await + .unwrap(); + assert_ne!(initial_session_key, updated_session_key.as_ref()); + } +} diff --git a/actix-session/src/storage/redis_rs.rs b/actix-session/src/storage/redis_rs.rs new file mode 100644 index 000000000..600998bcb --- /dev/null +++ b/actix-session/src/storage/redis_rs.rs @@ -0,0 +1,297 @@ +use std::sync::Arc; + +use redis::{aio::ConnectionManager, AsyncCommands, Value}; +use time::{self, Duration}; + +use super::SessionKey; +use crate::storage::{ + interface::{LoadError, SaveError, SessionState, UpdateError}, + utils::generate_session_key, + SessionStore, +}; + +/// Use Redis as session storage backend. +/// +/// ```no_run +/// use actix_web::{web, App, HttpServer, HttpResponse, Error}; +/// use actix_session::{SessionMiddleware, storage::RedisSessionStore}; +/// use actix_web::cookie::Key; +/// +/// // The secret key would usually be read from a configuration file/environment variables. +/// fn get_secret_key() -> Key { +/// # todo!() +/// // [...] +/// } +/// +/// #[actix_web::main] +/// async fn main() -> std::io::Result<()> { +/// let secret_key = get_secret_key(); +/// let redis_connection_string = "redis://127.0.0.1:6379"; +/// let store = RedisSessionStore::new(redis_connection_string).await.unwrap(); +/// HttpServer::new(move || +/// App::new() +/// .wrap(SessionMiddleware::new( +/// store.clone(), +/// secret_key.clone() +/// )) +/// .default_service(web::to(|| HttpResponse::Ok()))) +/// .bind(("127.0.0.1", 8080))? +/// .run() +/// .await +/// } +/// ``` +/// +/// # TLS support +/// Add the `redis-rs-tls-session` feature flag to enable TLS support. You can then establish a TLS +/// connection to Redis using the `rediss://` URL scheme: +/// +/// ```no_run +/// use actix_session::{storage::RedisSessionStore}; +/// +/// # actix_web::rt::System::new().block_on(async { +/// let redis_connection_string = "rediss://127.0.0.1:6379"; +/// let store = RedisSessionStore::new(redis_connection_string).await.unwrap(); +/// # }) +/// ``` +/// +/// # Implementation notes +/// `RedisSessionStore` leverages [`redis-rs`] as Redis client. +/// +/// [`redis-rs`]: https://github.com/mitsuhiko/redis-rs +#[cfg_attr(docsrs, doc(cfg(feature = "redis-rs-session")))] +#[derive(Clone)] +pub struct RedisSessionStore { + configuration: CacheConfiguration, + client: ConnectionManager, +} + +#[derive(Clone)] +struct CacheConfiguration { + cache_keygen: Arc String + Send + Sync>, +} + +impl Default for CacheConfiguration { + fn default() -> Self { + Self { + cache_keygen: Arc::new(str::to_owned), + } + } +} + +impl RedisSessionStore { + /// A fluent API to configure [`RedisSessionStore`]. + /// It takes as input the only required input to create a new instance of [`RedisSessionStore`] - a + /// connection string for Redis. + pub fn builder>(connection_string: S) -> RedisSessionStoreBuilder { + RedisSessionStoreBuilder { + configuration: CacheConfiguration::default(), + connection_string: connection_string.into(), + } + } + + /// Create a new instance of [`RedisSessionStore`] using the default configuration. + /// It takes as input the only required input to create a new instance of [`RedisSessionStore`] - a + /// connection string for Redis. + pub async fn new>( + connection_string: S, + ) -> Result { + Self::builder(connection_string).build().await + } +} + +/// A fluent builder to construct a [`RedisSessionStore`] instance with custom configuration +/// parameters. +/// +/// [`RedisSessionStore`]: crate::storage::RedisSessionStore +#[cfg_attr(docsrs, doc(cfg(feature = "redis-rs-session")))] +#[must_use] +pub struct RedisSessionStoreBuilder { + connection_string: String, + configuration: CacheConfiguration, +} + +impl RedisSessionStoreBuilder { + /// Set a custom cache key generation strategy, expecting a session key as input. + pub fn cache_keygen(mut self, keygen: F) -> Self + where + F: Fn(&str) -> String + 'static + Send + Sync, + { + self.configuration.cache_keygen = Arc::new(keygen); + self + } + + /// Finalise the builder and return a [`RedisActorSessionStore`] instance. + /// + /// [`RedisActorSessionStore`]: crate::storage::RedisActorSessionStore + pub async fn build(self) -> Result { + let client = ConnectionManager::new(redis::Client::open(self.connection_string)?).await?; + Ok(RedisSessionStore { + configuration: self.configuration, + client, + }) + } +} + +#[async_trait::async_trait(?Send)] +impl SessionStore for RedisSessionStore { + async fn load(&self, session_key: &SessionKey) -> Result, LoadError> { + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + let value: Option = self + .client + .clone() + .get(cache_key) + .await + .map_err(Into::into) + .map_err(LoadError::Other)?; + + match value { + None => Ok(None), + Some(value) => Ok(serde_json::from_str(&value) + .map_err(Into::into) + .map_err(LoadError::Deserialization)?), + } + } + + async fn save( + &self, + session_state: SessionState, + ttl: &Duration, + ) -> Result { + let body = serde_json::to_string(&session_state) + .map_err(Into::into) + .map_err(SaveError::Serialization)?; + let session_key = generate_session_key(); + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + redis::cmd("SET") + .arg(&[ + &cache_key, + &body, + "NX", // NX: only set the key if it does not already exist + "EX", // EX: set expiry + &format!("{}", ttl.whole_seconds()), + ]) + .query_async(&mut self.client.clone()) + .await + .map_err(Into::into) + .map_err(SaveError::Other)?; + + Ok(session_key) + } + + async fn update( + &self, + session_key: SessionKey, + session_state: SessionState, + ttl: &Duration, + ) -> Result { + let body = serde_json::to_string(&session_state) + .map_err(Into::into) + .map_err(UpdateError::Serialization)?; + + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + let v: redis::Value = redis::cmd("SET") + .arg(&[ + &cache_key, + &body, + "XX", // XX: Only set the key if it already exist. + "EX", // EX: set expiry + &format!("{}", ttl.whole_seconds()), + ]) + .query_async(&mut self.client.clone()) + .await + .map_err(Into::into) + .map_err(UpdateError::Other)?; + + match v { + Value::Nil => { + // The SET operation was not performed because the XX condition was not verified. + // This can happen if the session state expired between the load operation and the + // update operation. Unlucky, to say the least. We fall back to the `save` routine + // to ensure that the new key is unique. + self.save(session_state, ttl) + .await + .map_err(|err| match err { + SaveError::Serialization(err) => UpdateError::Serialization(err), + SaveError::Other(err) => UpdateError::Other(err), + }) + } + Value::Int(_) | Value::Okay | Value::Status(_) => Ok(session_key), + val => Err(UpdateError::Other(anyhow::anyhow!( + "Failed to update session state. {:?}", + val + ))), + } + } + + async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error> { + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + self.client + .clone() + .del(&cache_key) + .await + .map_err(Into::into) + .map_err(UpdateError::Other)?; + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use std::collections::HashMap; + + use redis::AsyncCommands; + + use super::*; + use crate::test_helpers::acceptance_test_suite; + + async fn redis_store() -> RedisSessionStore { + RedisSessionStore::new("redis://127.0.0.1:6379") + .await + .unwrap() + } + + #[actix_web::test] + async fn test_session_workflow() { + let redis_store = redis_store().await; + acceptance_test_suite(move || redis_store.clone(), true).await; + } + + #[actix_web::test] + async fn loading_a_missing_session_returns_none() { + let store = redis_store().await; + let session_key = generate_session_key(); + assert!(store.load(&session_key).await.unwrap().is_none()); + } + + #[actix_web::test] + async fn loading_an_invalid_session_state_returns_deserialization_error() { + let store = redis_store().await; + let session_key = generate_session_key(); + store + .client + .clone() + .set::<_, _, ()>(session_key.as_ref(), "random-thing-which-is-not-json") + .await + .unwrap(); + assert!(matches!( + store.load(&session_key).await.unwrap_err(), + LoadError::Deserialization(_), + )); + } + + #[actix_web::test] + async fn updating_of_an_expired_state_is_handled_gracefully() { + let store = redis_store().await; + let session_key = generate_session_key(); + let initial_session_key = session_key.as_ref().to_owned(); + let updated_session_key = store + .update(session_key, HashMap::new(), &time::Duration::seconds(1)) + .await + .unwrap(); + assert_ne!(initial_session_key, updated_session_key.as_ref()); + } +} diff --git a/actix-session/src/storage/session_key.rs b/actix-session/src/storage/session_key.rs new file mode 100644 index 000000000..d82e18284 --- /dev/null +++ b/actix-session/src/storage/session_key.rs @@ -0,0 +1,59 @@ +use std::convert::TryFrom; + +use derive_more::{Display, From}; + +/// A session key, the string stored in a client-side cookie to associate a user +/// with its session state on the backend. +/// +/// ## Validation +/// +/// Session keys are stored as cookies, therefore they cannot be arbitrary long. +/// We require session keys to be smaller than 4064 bytes. +/// +/// ```rust +/// use std::convert::TryInto; +/// use actix_session::storage::SessionKey; +/// +/// let key: String = std::iter::repeat('a').take(4065).collect(); +/// let session_key: Result = key.try_into(); +/// assert!(session_key.is_err()); +/// ``` +#[derive(Debug, PartialEq, Eq)] +pub struct SessionKey(String); + +impl TryFrom for SessionKey { + type Error = InvalidSessionKeyError; + + fn try_from(v: String) -> Result { + if v.len() > 4064 { + return Err(anyhow::anyhow!( + "The session key is bigger than 4064 bytes, the upper limit on cookie content." + ) + .into()); + } + + Ok(SessionKey(v)) + } +} + +impl AsRef for SessionKey { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl From for String { + fn from(k: SessionKey) -> Self { + k.0 + } +} + +#[derive(Debug, Display, From)] +#[display(fmt = "The provided string is not a valid session key")] +pub struct InvalidSessionKeyError(anyhow::Error); + +impl std::error::Error for InvalidSessionKeyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.0.as_ref()) + } +} diff --git a/actix-session/src/storage/utils.rs b/actix-session/src/storage/utils.rs new file mode 100644 index 000000000..b50145122 --- /dev/null +++ b/actix-session/src/storage/utils.rs @@ -0,0 +1,19 @@ +use std::convert::TryInto; + +use rand::{distributions::Alphanumeric, rngs::OsRng, Rng as _}; + +use crate::storage::SessionKey; + +/// Session key generation routine that follows [OWASP recommendations]. +/// +/// [OWASP recommendations]: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-id-entropy +pub(crate) fn generate_session_key() -> SessionKey { + let value = std::iter::repeat(()) + .map(|()| OsRng.sample(Alphanumeric)) + .take(64) + .collect::>(); + + // These unwraps will never panic because pre-conditions are always verified + // (i.e. length and character set) + String::from_utf8(value).unwrap().try_into().unwrap() +} diff --git a/actix-session/tests/middleware.rs b/actix-session/tests/middleware.rs new file mode 100644 index 000000000..8b9f278c4 --- /dev/null +++ b/actix-session/tests/middleware.rs @@ -0,0 +1,56 @@ +use actix_session::{storage::CookieSessionStore, Session, SessionMiddleware}; +use actix_web::{ + cookie::{time::Duration, Key}, + test, web, App, Responder, +}; + +async fn login(session: Session) -> impl Responder { + session.insert("user_id", "id").unwrap(); + "Logged in" +} + +async fn logout(session: Session) -> impl Responder { + session.purge(); + "Logged out" +} + +#[actix_web::test] +async fn cookie_storage() -> std::io::Result<()> { + let signing_key = Key::generate(); + let app = test::init_service( + App::new() + .wrap( + SessionMiddleware::builder(CookieSessionStore::default(), signing_key.clone()) + .cookie_path("/test".to_string()) + .cookie_domain(Some("localhost".to_string())) + .build(), + ) + .route("/login", web::post().to(login)) + .route("/logout", web::post().to(logout)), + ) + .await; + + let login_request = test::TestRequest::post().uri("/login").to_request(); + let login_response = test::call_service(&app, login_request).await; + let session_cookie = login_response.response().cookies().next().unwrap(); + assert_eq!(session_cookie.name(), "id"); + assert_eq!(session_cookie.path().unwrap(), "/test"); + assert!(session_cookie.secure().unwrap()); + assert!(session_cookie.http_only().unwrap()); + assert!(session_cookie.max_age().is_none()); + assert_eq!(session_cookie.domain().unwrap(), "localhost"); + + let logout_request = test::TestRequest::post() + .cookie(session_cookie) + .uri("/logout") + .to_request(); + let logout_response = test::call_service(&app, logout_request).await; + let deletion_cookie = logout_response.response().cookies().next().unwrap(); + assert_eq!(deletion_cookie.name(), "id"); + assert_eq!(deletion_cookie.path().unwrap(), "/test"); + assert!(deletion_cookie.secure().is_none()); + assert!(deletion_cookie.http_only().unwrap()); + assert_eq!(deletion_cookie.max_age().unwrap(), Duration::ZERO); + assert_eq!(deletion_cookie.domain().unwrap(), "localhost"); + Ok(()) +} diff --git a/actix-session/tests/session.rs b/actix-session/tests/session.rs new file mode 100644 index 000000000..720749ad1 --- /dev/null +++ b/actix-session/tests/session.rs @@ -0,0 +1,70 @@ +use actix_session::{SessionExt, SessionStatus}; +use actix_web::{test, HttpResponse}; + +#[actix_web::test] +async fn session() { + let req = test::TestRequest::default().to_srv_request(); + let session = req.get_session(); + session.insert("key", "value").unwrap(); + let res = session.get::("key").unwrap(); + assert_eq!(res, Some("value".to_string())); + + session.insert("key2", "value2").unwrap(); + session.remove("key"); + + let res = req.into_response(HttpResponse::Ok().finish()); + let state: Vec<_> = res.get_session().entries().clone().into_iter().collect(); + assert_eq!( + state.as_slice(), + [("key2".to_string(), "\"value2\"".to_string())] + ); +} + +#[actix_web::test] +async fn get_session() { + let req = test::TestRequest::default().to_srv_request(); + + let session = req.get_session(); + session.insert("key", true).unwrap(); + let res = session.get("key").unwrap(); + assert_eq!(res, Some(true)); +} + +#[actix_web::test] +async fn get_session_from_request_head() { + let req = test::TestRequest::default().to_srv_request(); + + let session = req.get_session(); + session.insert("key", 10).unwrap(); + let res = session.get::("key").unwrap(); + assert_eq!(res, Some(10)); +} + +#[actix_web::test] +async fn purge_session() { + let req = test::TestRequest::default().to_srv_request(); + let session = req.get_session(); + assert_eq!(session.status(), SessionStatus::Unchanged); + session.purge(); + assert_eq!(session.status(), SessionStatus::Purged); +} + +#[actix_web::test] +async fn renew_session() { + let req = test::TestRequest::default().to_srv_request(); + let session = req.get_session(); + assert_eq!(session.status(), SessionStatus::Unchanged); + session.renew(); + assert_eq!(session.status(), SessionStatus::Renewed); +} + +#[actix_web::test] +async fn session_entries() { + let req = test::TestRequest::default().to_srv_request(); + let session = req.get_session(); + session.insert("test_str", "val").unwrap(); + session.insert("test_str", 1).unwrap(); + let map = session.entries(); + map.contains_key("test_str"); + map.contains_key("test_num"); +}