From b1cea6479538e5c66b577f13ce686511817f5745 Mon Sep 17 00:00:00 2001 From: Luca Palmieri Date: Sat, 9 Jul 2022 19:00:15 +0100 Subject: [PATCH] Rebuild `actix-identity` on top of `actix-session` (#246) Co-authored-by: Rob Ede --- actix-identity/Cargo.toml | 16 +- actix-identity/examples/identity.rs | 84 ++ actix-identity/src/config.rs | 101 +++ actix-identity/src/cookie.rs | 828 ------------------ actix-identity/src/identity.rs | 274 ++++-- actix-identity/src/identity_ext.rs | 27 + actix-identity/src/lib.rs | 212 ++--- actix-identity/src/middleware.rs | 300 ++++--- actix-identity/tests/integration/fixtures.rs | 17 + .../tests/integration/integration.rs | 168 ++++ actix-identity/tests/integration/main.rs | 3 + actix-identity/tests/integration/test_app.rs | 186 ++++ actix-session/CHANGES.md | 2 +- actix-session/src/lib.rs | 2 +- actix-session/src/session.rs | 88 +- 15 files changed, 1155 insertions(+), 1153 deletions(-) create mode 100644 actix-identity/examples/identity.rs create mode 100644 actix-identity/src/config.rs delete mode 100644 actix-identity/src/cookie.rs create mode 100644 actix-identity/src/identity_ext.rs create mode 100644 actix-identity/tests/integration/fixtures.rs create mode 100644 actix-identity/tests/integration/integration.rs create mode 100644 actix-identity/tests/integration/main.rs create mode 100644 actix-identity/tests/integration/test_app.rs diff --git a/actix-identity/Cargo.toml b/actix-identity/Cargo.toml index 7011da01f..a6ca7690e 100644 --- a/actix-identity/Cargo.toml +++ b/actix-identity/Cargo.toml @@ -1,7 +1,10 @@ [package] name = "actix-identity" version = "0.4.0" -authors = ["Nikolay Kim "] +authors = [ + "Nikolay Kim ", + "Luca Palmieri ", +] description = "Identity service for Actix Web" keywords = ["actix", "auth", "identity", "web", "security"] homepage = "https://actix.rs" @@ -15,14 +18,21 @@ path = "src/lib.rs" [dependencies] actix-service = "2" +actix-session = "0.6.2" # update to 0.7 with release of -session actix-utils = "3" actix-web = { version = "4", default-features = false, features = ["cookies", "secure-cookies"] } -futures-util = { version = "0.3.7", default-features = false } +anyhow = "1" +env_logger = "0.9" +futures-core = "0.3.7" serde = { version = "1", features = ["derive"] } serde_json = "1" time = "0.3" +tracing = { version = "0.1.30", default-features = false, features = ["log"] } [dev-dependencies] -actix-http = "3.0.0-rc.1" +actix-http = "3" actix-web = { version = "4", default_features = false, features = ["macros", "cookies", "secure-cookies"] } +actix-session = { version = "0.6.2", features = ["redis-rs-session", "cookie-session"] } # update to 0.7 with release of -session +uuid = { version = "1", features = ["v4"] } +reqwest = { version = "0.11", default_features = false, features = ["cookies", "json"] } diff --git a/actix-identity/examples/identity.rs b/actix-identity/examples/identity.rs new file mode 100644 index 000000000..e80b69396 --- /dev/null +++ b/actix-identity/examples/identity.rs @@ -0,0 +1,84 @@ +//! A rudimentary example of how to set up and use `actix-identity`. +//! +//! ```bash +//! # using HTTPie (https://httpie.io/cli) +//! +//! # outputs "Welcome Anonymous!" message +//! http -v --session=identity GET localhost:8080/ +//! +//! # log in using fake details, ensuring that --session is used to persist cookies +//! http -v --session=identity POST localhost:8080/login user_id=foo +//! +//! # outputs "Welcome User1" message +//! http -v --session=identity GET localhost:8080/ +//! ``` + +use std::io; + +use actix_identity::{Identity, IdentityMiddleware}; +use actix_session::{storage::CookieSessionStore, SessionMiddleware}; +use actix_web::{ + cookie::Key, get, middleware::Logger, post, App, HttpMessage, HttpRequest, HttpResponse, + HttpServer, Responder, +}; + +#[actix_web::main] +async fn main() -> io::Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + let secret_key = Key::generate(); + + HttpServer::new(move || { + let session_mw = + SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone()) + // disable secure cookie for local testing + .cookie_secure(false) + .build(); + + App::new() + // Install the identity framework first. + .wrap(IdentityMiddleware::default()) + // The identity system is built on top of sessions. You must install the session + // middleware to leverage `actix-identity`. The session middleware must be mounted + // AFTER the identity middleware: `actix-web` invokes middleware in the OPPOSITE + // order of registration when it receives an incoming request. + .wrap(session_mw) + .wrap(Logger::default()) + .service(index) + .service(login) + .service(logout) + }) + .bind(("127.0.0.1", 8080)) + .unwrap() + .workers(2) + .run() + .await +} + +#[get("/")] +async fn index(user: Option) -> impl Responder { + if let Some(user) = user { + format!("Welcome! {}", user.id().unwrap()) + } else { + "Welcome Anonymous!".to_owned() + } +} + +#[post("/login")] +async fn login(request: HttpRequest) -> impl Responder { + // Some kind of authentication should happen here - + // e.g. password-based, biometric, etc. + // [...] + + // Attached a verified user identity to the active + // session. + Identity::login(&request.extensions(), "User1".into()).unwrap(); + + HttpResponse::Ok() +} + +#[post("/logout")] +async fn logout(user: Identity) -> impl Responder { + user.logout(); + HttpResponse::NoContent() +} diff --git a/actix-identity/src/config.rs b/actix-identity/src/config.rs new file mode 100644 index 000000000..a10514e9c --- /dev/null +++ b/actix-identity/src/config.rs @@ -0,0 +1,101 @@ +//! Configuration options to tune the behaviour of [`IdentityMiddleware`]. + +use std::time::Duration; + +use crate::IdentityMiddleware; + +#[derive(Debug, Clone)] +pub(crate) struct Configuration { + pub(crate) on_logout: LogoutBehaviour, + pub(crate) login_deadline: Option, + pub(crate) visit_deadline: Option, +} + +impl Default for Configuration { + fn default() -> Self { + Self { + on_logout: LogoutBehaviour::PurgeSession, + login_deadline: None, + visit_deadline: None, + } + } +} + +/// `LogoutBehaviour` controls what actions are going to be performed when [`Identity::logout`] is +/// invoked. +/// +/// [`Identity::logout`]: crate::Identity::logout +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum LogoutBehaviour { + /// When [`Identity::logout`](crate::Identity::logout) is called, purge the current session. + /// + /// This behaviour might be desirable when you have stored additional information in the + /// session state that are tied to the user's identity and should not be retained after logout. + PurgeSession, + + /// When [`Identity::logout`](crate::Identity::logout) is called, remove the identity + /// information from the current session state. The session itself is not destroyed. + /// + /// This behaviour might be desirable when you have stored information in the session state that + /// is not tied to the user's identity and should be retained after logout. + DeleteIdentityKeys, +} + +/// A fluent builder to construct an [`IdentityMiddleware`] instance with custom configuration +/// parameters. +/// +/// Use [`IdentityMiddleware::builder`] to get started! +#[derive(Debug, Clone)] +pub struct IdentityMiddlewareBuilder { + configuration: Configuration, +} + +impl IdentityMiddlewareBuilder { + pub(crate) fn new() -> Self { + Self { + configuration: Configuration::default(), + } + } + + /// Determines how [`Identity::logout`](crate::Identity::logout) affects the current session. + /// + /// By default, the current session is purged ([`LogoutBehaviour::PurgeSession`]). + pub fn logout_behaviour(mut self, logout_behaviour: LogoutBehaviour) -> Self { + self.configuration.on_logout = logout_behaviour; + self + } + + /// Automatically logs out users after a certain amount of time has passed since they logged in, + /// regardless of their activity pattern. + /// + /// If set to: + /// - `None`: login deadline is disabled. + /// - `Some(duration)`: login deadline is enabled and users will be logged out after `duration` + /// has passed since their login. + /// + /// By default, login deadline is disabled. + pub fn login_deadline(mut self, deadline: Option) -> Self { + self.configuration.login_deadline = deadline; + self + } + + /// Automatically logs out users after a certain amount of time has passed since their last + /// visit. + /// + /// If set to: + /// - `None`: visit deadline is disabled. + /// - `Some(duration)`: visit deadline is enabled and users will be logged out after `duration` + /// has passed since their last visit. + /// + /// By default, visit deadline is disabled. + pub fn visit_deadline(mut self, deadline: Option) -> Self { + self.configuration.visit_deadline = deadline; + self + } + + /// Finalises the builder and returns an [`IdentityMiddleware`] instance. + pub fn build(self) -> IdentityMiddleware { + IdentityMiddleware::new(self.configuration) + } +} diff --git a/actix-identity/src/cookie.rs b/actix-identity/src/cookie.rs deleted file mode 100644 index 0e9733688..000000000 --- a/actix-identity/src/cookie.rs +++ /dev/null @@ -1,828 +0,0 @@ -use std::{rc::Rc, time::SystemTime}; - -use actix_utils::future::{ready, Ready}; -use actix_web::{ - cookie::{Cookie, CookieJar, Key, SameSite}, - dev::{ServiceRequest, ServiceResponse}, - error::{Error, Result}, - http::header::{self, HeaderValue}, - HttpMessage, -}; -use serde::{Deserialize, Serialize}; -use time::Duration; - -use crate::IdentityPolicy; - -struct CookieIdentityInner { - key: Key, - key_v2: Key, - name: String, - path: String, - domain: Option, - secure: bool, - max_age: Option, - http_only: Option, - same_site: Option, - visit_deadline: Option, - login_deadline: Option, -} - -#[derive(Debug, Deserialize, Serialize)] -struct CookieValue { - identity: String, - - #[serde(skip_serializing_if = "Option::is_none")] - login_timestamp: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - visit_timestamp: Option, -} - -#[derive(Debug)] -struct CookieIdentityExtension { - login_timestamp: Option, -} - -impl CookieIdentityInner { - fn new(key: &[u8]) -> CookieIdentityInner { - let key_v2: Vec = [key, &[1, 0, 0, 0]].concat(); - - CookieIdentityInner { - key: Key::derive_from(key), - key_v2: Key::derive_from(&key_v2), - name: "actix-identity".to_owned(), - path: "/".to_owned(), - domain: None, - secure: true, - max_age: None, - http_only: None, - same_site: None, - visit_deadline: None, - login_deadline: None, - } - } - - fn set_cookie( - &self, - resp: &mut ServiceResponse, - value: Option, - ) -> Result<()> { - let add_cookie = value.is_some(); - let val = value - .map(|val| { - if !self.legacy_supported() { - serde_json::to_string(&val) - } else { - Ok(val.identity) - } - }) - .transpose()?; - - let mut cookie = Cookie::new(self.name.clone(), val.unwrap_or_default()); - cookie.set_path(self.path.clone()); - cookie.set_secure(self.secure); - cookie.set_http_only(true); - - 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(http_only) = self.http_only { - cookie.set_http_only(http_only); - } - - if let Some(same_site) = self.same_site { - cookie.set_same_site(same_site); - } - - let mut jar = CookieJar::new(); - - let key = if self.legacy_supported() { - &self.key - } else { - &self.key_v2 - }; - - if add_cookie { - jar.private_mut(key).add(cookie); - } else { - jar.add_original(cookie.clone()); - jar.private_mut(key).remove(cookie); - } - - for cookie in jar.delta() { - let val = HeaderValue::from_str(&cookie.to_string())?; - resp.headers_mut().append(header::SET_COOKIE, val); - } - - Ok(()) - } - - fn load(&self, req: &ServiceRequest) -> Option { - let cookie = req.cookie(&self.name)?; - let mut jar = CookieJar::new(); - jar.add_original(cookie.clone()); - - let res = if self.legacy_supported() { - jar.private_mut(&self.key) - .get(&self.name) - .map(|n| CookieValue { - identity: n.value().to_string(), - login_timestamp: None, - visit_timestamp: None, - }) - } else { - None - }; - - res.or_else(|| { - jar.private_mut(&self.key_v2) - .get(&self.name) - .and_then(|c| self.parse(c)) - }) - } - - fn parse(&self, cookie: Cookie<'_>) -> Option { - let value: CookieValue = serde_json::from_str(cookie.value()).ok()?; - let now = SystemTime::now(); - - if let Some(visit_deadline) = self.visit_deadline { - let inactivity = now.duration_since(value.visit_timestamp?).ok()?; - - if inactivity > visit_deadline { - return None; - } - } - - if let Some(login_deadline) = self.login_deadline { - let logged_in_dur = now.duration_since(value.login_timestamp?).ok()?; - - if logged_in_dur > login_deadline { - return None; - } - } - - Some(value) - } - - fn legacy_supported(&self) -> bool { - self.visit_deadline.is_none() && self.login_deadline.is_none() - } - - fn always_update_cookie(&self) -> bool { - self.visit_deadline.is_some() - } - - fn requires_oob_data(&self) -> bool { - self.login_deadline.is_some() - } -} - -/// Use cookies for request identity storage. -/// -/// [See this page on MDN](mdn-cookies) for details on cookie attributes. -/// -/// # Examples -/// ``` -/// use actix_web::App; -/// use actix_identity::{CookieIdentityPolicy, IdentityService}; -/// -/// // create cookie identity backend -/// let policy = CookieIdentityPolicy::new(&[0; 32]) -/// .domain("www.rust-lang.org") -/// .name("actix_auth") -/// .path("/") -/// .secure(true); -/// -/// let app = App::new() -/// // wrap policy into identity middleware -/// .wrap(IdentityService::new(policy)); -/// ``` -/// -/// [mdn-cookies]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies -pub struct CookieIdentityPolicy(Rc); - -impl CookieIdentityPolicy { - /// Create new `CookieIdentityPolicy` instance. - /// - /// Key argument is the private key for issued cookies. If this value is changed, all issued - /// cookie identities are invalidated. - /// - /// # Panics - /// Panics if `key` is less than 32 bytes in length.. - pub fn new(key: &[u8]) -> CookieIdentityPolicy { - CookieIdentityPolicy(Rc::new(CookieIdentityInner::new(key))) - } - - /// Sets the name of issued cookies. - pub fn name(mut self, value: impl Into) -> CookieIdentityPolicy { - self.inner_mut().name = value.into(); - self - } - - /// Sets the `Path` attribute of issued cookies. - pub fn path(mut self, value: impl Into) -> CookieIdentityPolicy { - self.inner_mut().path = value.into(); - self - } - - /// Sets the `Domain` attribute of issued cookies. - pub fn domain(mut self, value: impl Into) -> CookieIdentityPolicy { - self.inner_mut().domain = Some(value.into()); - self - } - - /// Sets the `Secure` attribute of issued cookies. - pub fn secure(mut self, value: bool) -> CookieIdentityPolicy { - self.inner_mut().secure = value; - self - } - - /// Sets the `Max-Age` attribute of issued cookies. - pub fn max_age(mut self, value: Duration) -> CookieIdentityPolicy { - self.inner_mut().max_age = Some(value); - self - } - - /// Sets the `Max-Age` attribute of issued cookies with given number of seconds. - pub fn max_age_secs(self, seconds: i64) -> CookieIdentityPolicy { - self.max_age(Duration::seconds(seconds)) - } - - /// Sets the `HttpOnly` attribute of issued cookies. - /// - /// By default, the `HttpOnly` attribute is omitted from issued cookies. - pub fn http_only(mut self, http_only: bool) -> Self { - self.inner_mut().http_only = Some(http_only); - self - } - - /// Sets the `SameSite` attribute of issued cookies. - /// - /// By default, the `SameSite` attribute is omitted from issued cookies. - pub fn same_site(mut self, same_site: SameSite) -> Self { - self.inner_mut().same_site = Some(same_site); - self - } - - /// Accepts only users who have visited within given deadline. - /// - /// In other words, invalidate a login after some amount of inactivity. Using this feature - /// causes updated cookies to be issued on each response in order to record the user's last - /// visitation timestamp. - /// - /// By default, visit deadline is disabled. - pub fn visit_deadline(mut self, deadline: Duration) -> CookieIdentityPolicy { - self.inner_mut().visit_deadline = Some(deadline); - self - } - - /// Accepts only users who authenticated within the given deadline. - /// - /// In other words, invalidate a login after some amount of time, regardless of activity. - /// While [`Max-Age`](CookieIdentityPolicy::max_age) is useful in constraining the cookie - /// lifetime, it could be extended manually; using this feature encodes the deadline directly - /// into the issued cookies, making it immutable to users. - /// - /// By default, login deadline is disabled. - pub fn login_deadline(mut self, deadline: Duration) -> CookieIdentityPolicy { - self.inner_mut().login_deadline = Some(deadline); - self - } - - fn inner_mut(&mut self) -> &mut CookieIdentityInner { - Rc::get_mut(&mut self.0).unwrap() - } -} - -impl IdentityPolicy for CookieIdentityPolicy { - type Future = Ready, Error>>; - type ResponseFuture = Ready>; - - fn from_request(&self, req: &mut ServiceRequest) -> Self::Future { - ready(Ok(self.0.load(req).map(|value| { - let CookieValue { - identity, - login_timestamp, - .. - } = value; - - if self.0.requires_oob_data() { - req.extensions_mut() - .insert(CookieIdentityExtension { login_timestamp }); - } - - identity - }))) - } - - fn to_response( - &self, - id: Option, - changed: bool, - res: &mut ServiceResponse, - ) -> Self::ResponseFuture { - let _ = if changed { - let login_timestamp = SystemTime::now(); - - self.0.set_cookie( - res, - id.map(|identity| CookieValue { - identity, - login_timestamp: self.0.login_deadline.map(|_| login_timestamp), - visit_timestamp: self.0.visit_deadline.map(|_| login_timestamp), - }), - ) - } else if self.0.always_update_cookie() && id.is_some() { - let visit_timestamp = SystemTime::now(); - - let login_timestamp = if self.0.requires_oob_data() { - let CookieIdentityExtension { login_timestamp } = - res.request().extensions_mut().remove().unwrap(); - - login_timestamp - } else { - None - }; - - self.0.set_cookie( - res, - Some(CookieValue { - identity: id.unwrap(), - login_timestamp, - visit_timestamp: self.0.visit_deadline.map(|_| visit_timestamp), - }), - ) - } else { - Ok(()) - }; - - ready(Ok(())) - } -} - -#[cfg(test)] -mod tests { - use std::{borrow::Borrow, time::SystemTime}; - - use actix_web::{ - body::{BoxBody, EitherBody}, - cookie::{Cookie, CookieJar, Key, SameSite}, - dev::ServiceResponse, - http::{header, StatusCode}, - test::{self, TestRequest}, - web, App, HttpResponse, - }; - use time::Duration; - - use super::*; - use crate::{tests::*, Identity, IdentityService}; - - fn login_cookie( - identity: &'static str, - login_timestamp: Option, - visit_timestamp: Option, - ) -> Cookie<'static> { - let mut jar = CookieJar::new(); - let key: Vec = COOKIE_KEY_MASTER - .iter() - .chain([1, 0, 0, 0].iter()) - .copied() - .collect(); - - jar.private_mut(&Key::derive_from(&key)).add(Cookie::new( - COOKIE_NAME, - serde_json::to_string(&CookieValue { - identity: identity.to_string(), - login_timestamp, - visit_timestamp, - }) - .unwrap(), - )); - - jar.get(COOKIE_NAME).unwrap().clone() - } - - fn assert_login_cookie( - response: &mut ServiceResponse>, - identity: &str, - login_timestamp: LoginTimestampCheck, - visit_timestamp: VisitTimeStampCheck, - ) { - let mut cookies = CookieJar::new(); - - for cookie in response.headers().get_all(header::SET_COOKIE) { - cookies.add(Cookie::parse(cookie.to_str().unwrap().to_string()).unwrap()); - } - - let key: Vec = COOKIE_KEY_MASTER - .iter() - .chain([1, 0, 0, 0].iter()) - .copied() - .collect(); - - let cookie = cookies - .private(&Key::derive_from(&key)) - .get(COOKIE_NAME) - .unwrap(); - - let cv: CookieValue = serde_json::from_str(cookie.value()).unwrap(); - assert_eq!(cv.identity, identity); - - let now = SystemTime::now(); - let t30sec_ago = now - Duration::seconds(30); - - match login_timestamp { - LoginTimestampCheck::NoTimestamp => assert_eq!(cv.login_timestamp, None), - LoginTimestampCheck::NewTimestamp => assert!( - t30sec_ago <= cv.login_timestamp.unwrap() && cv.login_timestamp.unwrap() <= now - ), - LoginTimestampCheck::OldTimestamp(old_timestamp) => { - assert_eq!(cv.login_timestamp, Some(old_timestamp)) - } - } - - match visit_timestamp { - VisitTimeStampCheck::NoTimestamp => assert_eq!(cv.visit_timestamp, None), - VisitTimeStampCheck::NewTimestamp => assert!( - t30sec_ago <= cv.visit_timestamp.unwrap() && cv.visit_timestamp.unwrap() <= now - ), - } - } - - #[actix_web::test] - async fn test_identity_flow() { - let srv = test::init_service( - App::new() - .wrap(IdentityService::new( - CookieIdentityPolicy::new(&COOKIE_KEY_MASTER) - .domain("www.rust-lang.org") - .name(COOKIE_NAME) - .path("/") - .secure(true), - )) - .service(web::resource("/index").to(|id: Identity| { - if id.identity().is_some() { - HttpResponse::Created() - } else { - HttpResponse::Ok() - } - })) - .service(web::resource("/login").to(|id: Identity| { - id.remember(COOKIE_LOGIN.to_string()); - HttpResponse::Ok() - })) - .service(web::resource("/logout").to(|id: Identity| { - if id.identity().is_some() { - id.forget(); - HttpResponse::Ok() - } else { - HttpResponse::BadRequest() - } - })), - ) - .await; - let resp = test::call_service(&srv, TestRequest::with_uri("/index").to_request()).await; - assert_eq!(resp.status(), StatusCode::OK); - - let resp = test::call_service(&srv, TestRequest::with_uri("/login").to_request()).await; - assert_eq!(resp.status(), StatusCode::OK); - let c = resp.response().cookies().next().unwrap().to_owned(); - - let resp = test::call_service( - &srv, - TestRequest::with_uri("/index") - .cookie(c.clone()) - .to_request(), - ) - .await; - assert_eq!(resp.status(), StatusCode::CREATED); - - let resp = test::call_service( - &srv, - TestRequest::with_uri("/logout") - .cookie(c.clone()) - .to_request(), - ) - .await; - assert_eq!(resp.status(), StatusCode::OK); - assert!(resp.headers().contains_key(header::SET_COOKIE)) - } - - #[actix_web::test] - async fn test_identity_max_age_time() { - let duration = Duration::days(1); - - let srv = test::init_service( - App::new() - .wrap(IdentityService::new( - CookieIdentityPolicy::new(&COOKIE_KEY_MASTER) - .domain("www.rust-lang.org") - .name(COOKIE_NAME) - .path("/") - .max_age(duration) - .secure(true), - )) - .service(web::resource("/login").to(|id: Identity| { - id.remember("test".to_string()); - HttpResponse::Ok() - })), - ) - .await; - - let resp = test::call_service(&srv, TestRequest::with_uri("/login").to_request()).await; - assert_eq!(resp.status(), StatusCode::OK); - assert!(resp.headers().contains_key(header::SET_COOKIE)); - let c = resp.response().cookies().next().unwrap().to_owned(); - assert_eq!(duration, c.max_age().unwrap()); - } - - #[actix_web::test] - async fn test_http_only_same_site() { - let srv = test::init_service( - App::new() - .wrap(IdentityService::new( - CookieIdentityPolicy::new(&COOKIE_KEY_MASTER) - .domain("www.rust-lang.org") - .name(COOKIE_NAME) - .path("/") - .http_only(true) - .same_site(SameSite::None), - )) - .service(web::resource("/login").to(|id: Identity| { - id.remember("test".to_string()); - HttpResponse::Ok() - })), - ) - .await; - - let resp = test::call_service(&srv, TestRequest::with_uri("/login").to_request()).await; - - assert_eq!(resp.status(), StatusCode::OK); - assert!(resp.headers().contains_key(header::SET_COOKIE)); - - let c = resp.response().cookies().next().unwrap().to_owned(); - assert!(c.http_only().unwrap()); - assert_eq!(SameSite::None, c.same_site().unwrap()); - } - - fn legacy_login_cookie(identity: &'static str) -> Cookie<'static> { - let mut jar = CookieJar::new(); - jar.private_mut(&Key::derive_from(&COOKIE_KEY_MASTER)) - .add(Cookie::new(COOKIE_NAME, identity)); - jar.get(COOKIE_NAME).unwrap().clone() - } - - async fn assert_logged_in( - response: ServiceResponse>, - identity: Option<&str>, - ) { - let bytes = test::read_body(response).await; - let resp: Option = serde_json::from_slice(&bytes[..]).unwrap(); - assert_eq!(resp.as_ref().map(|s| s.borrow()), identity); - } - - fn assert_legacy_login_cookie( - response: &mut ServiceResponse>, - identity: &str, - ) { - let mut cookies = CookieJar::new(); - for cookie in response.headers().get_all(header::SET_COOKIE) { - cookies.add(Cookie::parse(cookie.to_str().unwrap().to_string()).unwrap()); - } - let cookie = cookies - .private_mut(&Key::derive_from(&COOKIE_KEY_MASTER)) - .get(COOKIE_NAME) - .unwrap(); - assert_eq!(cookie.value(), identity); - } - - fn assert_no_login_cookie(response: &mut ServiceResponse>) { - let mut cookies = CookieJar::new(); - for cookie in response.headers().get_all(header::SET_COOKIE) { - cookies.add(Cookie::parse(cookie.to_str().unwrap().to_string()).unwrap()); - } - assert!(cookies.get(COOKIE_NAME).is_none()); - } - - #[actix_web::test] - async fn test_identity_max_age() { - let seconds = 60; - let srv = test::init_service( - App::new() - .wrap(IdentityService::new( - CookieIdentityPolicy::new(&COOKIE_KEY_MASTER) - .domain("www.rust-lang.org") - .name(COOKIE_NAME) - .path("/") - .max_age_secs(seconds) - .secure(true), - )) - .service(web::resource("/login").to(|id: Identity| { - id.remember("test".to_string()); - HttpResponse::Ok() - })), - ) - .await; - let resp = test::call_service(&srv, TestRequest::with_uri("/login").to_request()).await; - assert_eq!(resp.status(), StatusCode::OK); - assert!(resp.headers().contains_key(header::SET_COOKIE)); - let c = resp.response().cookies().next().unwrap().to_owned(); - assert_eq!(Duration::seconds(seconds as i64), c.max_age().unwrap()); - } - - #[actix_web::test] - async fn test_identity_legacy_cookie_is_set() { - let srv = create_identity_server(|c| c).await; - let mut resp = test::call_service(&srv, TestRequest::with_uri("/").to_request()).await; - assert_legacy_login_cookie(&mut resp, COOKIE_LOGIN); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_legacy_cookie_works() { - let srv = create_identity_server(|c| c).await; - let cookie = legacy_login_cookie(COOKIE_LOGIN); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_no_login_cookie(&mut resp); - assert_logged_in(resp, Some(COOKIE_LOGIN)).await; - } - - #[actix_web::test] - async fn test_identity_legacy_cookie_rejected_if_visit_timestamp_needed() { - let srv = create_identity_server(|c| c.visit_deadline(Duration::days(90))).await; - let cookie = legacy_login_cookie(COOKIE_LOGIN); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::NoTimestamp, - VisitTimeStampCheck::NewTimestamp, - ); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_legacy_cookie_rejected_if_login_timestamp_needed() { - let srv = create_identity_server(|c| c.login_deadline(Duration::days(90))).await; - let cookie = legacy_login_cookie(COOKIE_LOGIN); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::NewTimestamp, - VisitTimeStampCheck::NoTimestamp, - ); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_cookie_rejected_if_login_timestamp_needed() { - let srv = create_identity_server(|c| c.login_deadline(Duration::days(90))).await; - let cookie = login_cookie(COOKIE_LOGIN, None, Some(SystemTime::now())); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::NewTimestamp, - VisitTimeStampCheck::NoTimestamp, - ); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_cookie_rejected_if_visit_timestamp_needed() { - let srv = create_identity_server(|c| c.visit_deadline(Duration::days(90))).await; - let cookie = login_cookie(COOKIE_LOGIN, Some(SystemTime::now()), None); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::NoTimestamp, - VisitTimeStampCheck::NewTimestamp, - ); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_cookie_rejected_if_login_timestamp_too_old() { - let srv = create_identity_server(|c| c.login_deadline(Duration::days(90))).await; - let cookie = login_cookie( - COOKIE_LOGIN, - Some(SystemTime::now() - Duration::days(180)), - None, - ); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::NewTimestamp, - VisitTimeStampCheck::NoTimestamp, - ); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_cookie_rejected_if_visit_timestamp_too_old() { - let srv = create_identity_server(|c| c.visit_deadline(Duration::days(90))).await; - let cookie = login_cookie( - COOKIE_LOGIN, - None, - Some(SystemTime::now() - Duration::days(180)), - ); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::NoTimestamp, - VisitTimeStampCheck::NewTimestamp, - ); - assert_logged_in(resp, None).await; - } - - #[actix_web::test] - async fn test_identity_cookie_not_updated_on_login_deadline() { - let srv = create_identity_server(|c| c.login_deadline(Duration::days(90))).await; - let cookie = login_cookie(COOKIE_LOGIN, Some(SystemTime::now()), None); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_no_login_cookie(&mut resp); - assert_logged_in(resp, Some(COOKIE_LOGIN)).await; - } - - #[actix_web::test] - async fn test_identity_cookie_updated_on_visit_deadline() { - let srv = create_identity_server(|c| { - c.visit_deadline(Duration::days(90)) - .login_deadline(Duration::days(90)) - }) - .await; - let timestamp = SystemTime::now() - Duration::days(1); - let cookie = login_cookie(COOKIE_LOGIN, Some(timestamp), Some(timestamp)); - let mut resp = test::call_service( - &srv, - TestRequest::with_uri("/") - .cookie(cookie.clone()) - .to_request(), - ) - .await; - assert_login_cookie( - &mut resp, - COOKIE_LOGIN, - LoginTimestampCheck::OldTimestamp(timestamp), - VisitTimeStampCheck::NewTimestamp, - ); - assert_logged_in(resp, Some(COOKIE_LOGIN)).await; - } -} diff --git a/actix-identity/src/identity.rs b/actix-identity/src/identity.rs index 366e8303d..7a95a8d0b 100644 --- a/actix-identity/src/identity.rs +++ b/actix-identity/src/identity.rs @@ -1,89 +1,238 @@ +use actix_session::Session; use actix_utils::future::{ready, Ready}; use actix_web::{ + cookie::time::OffsetDateTime, dev::{Extensions, Payload}, - Error, FromRequest, HttpMessage as _, HttpRequest, + http::StatusCode, + Error, FromRequest, HttpMessage, HttpRequest, HttpResponse, }; +use anyhow::{anyhow, Context}; -pub(crate) struct IdentityItem { - pub(crate) id: Option, - pub(crate) changed: bool, -} +use crate::config::LogoutBehaviour; -/// The extractor type to obtain your identity from a request. +/// A verified user identity. It can be used as a request extractor. /// +/// The lifecycle of a user identity is tied to the lifecycle of the underlying session. If the +/// session is destroyed (e.g. the session expired), the user identity will be forgotten, de-facto +/// forcing a user log out. +/// +/// # Examples /// ``` -/// use actix_web::*; +/// use actix_web::{ +/// get, post, Responder, HttpRequest, HttpMessage, HttpResponse +/// }; /// use actix_identity::Identity; /// /// #[get("/")] -/// async fn index(id: Identity) -> impl Responder { -/// // access request identity -/// if let Some(id) = id.identity() { -/// format!("Welcome! {}", id) +/// async fn index(user: Option) -> impl Responder { +/// if let Some(user) = user { +/// format!("Welcome! {}", user.id().unwrap()) /// } else { /// "Welcome Anonymous!".to_owned() /// } /// } /// /// #[post("/login")] -/// async fn login(id: Identity) -> impl Responder { -/// // remember identity -/// id.remember("User1".to_owned()); -/// +/// async fn login(request: HttpRequest) -> impl Responder { +/// Identity::login(&request.extensions(), "User1".into()); /// HttpResponse::Ok() /// } /// /// #[post("/logout")] -/// async fn logout(id: Identity) -> impl Responder { -/// // remove identity -/// id.forget(); -/// +/// async fn logout(user: Identity) -> impl Responder { +/// user.logout(); /// HttpResponse::Ok() /// } /// ``` -#[derive(Clone)] -pub struct Identity(HttpRequest); - -impl Identity { - /// Return the claimed identity of the user associated request or `None` if no identity can be - /// found associated with the request. - pub fn identity(&self) -> Option { - Identity::get_identity(&self.0.extensions()) - } - - /// Remember identity. - pub fn remember(&self, identity: String) { - if let Some(id) = self.0.extensions_mut().get_mut::() { - id.id = Some(identity); - id.changed = true; - } - } - - /// This method is used to 'forget' the current identity on subsequent requests. - pub fn forget(&self) { - if let Some(id) = self.0.extensions_mut().get_mut::() { - id.id = None; - id.changed = true; - } - } - - pub(crate) fn get_identity(extensions: &Extensions) -> Option { - let id = extensions.get::()?; - id.id.clone() - } -} - -/// Extractor implementation for Identity type. /// +/// # Extractor Behaviour +/// What happens if you try to extract an `Identity` out of a request that does not have a valid +/// identity attached? The API will return a `401 UNAUTHORIZED` to the caller. +/// +/// If you want to customise this behaviour, consider extracting `Option` or +/// `Result` instead of a bare `Identity`: you will then be fully in +/// control of the error path. +/// +/// ## Examples /// ``` -/// # use actix_web::*; +/// use actix_web::{http::header::LOCATION, get, HttpResponse, Responder}; /// use actix_identity::Identity; /// /// #[get("/")] -/// async fn index(id: Identity) -> impl Responder { -/// // access request identity -/// if let Some(id) = id.identity() { -/// format!("Welcome! {}", id) +/// async fn index(user: Option) -> impl Responder { +/// if let Some(user) = user { +/// HttpResponse::Ok().finish() +/// } else { +/// // Redirect to login page if unauthenticated +/// HttpResponse::TemporaryRedirect() +/// .insert_header((LOCATION, "/login")) +/// .finish() +/// } +/// } +/// ``` +pub struct Identity(IdentityInner); + +#[derive(Clone)] +pub(crate) struct IdentityInner { + pub(crate) session: Session, + pub(crate) logout_behaviour: LogoutBehaviour, + pub(crate) is_login_deadline_enabled: bool, + pub(crate) is_visit_deadline_enabled: bool, +} + +impl IdentityInner { + fn extract(ext: &Extensions) -> Self { + ext.get::() + .expect( + "No `IdentityInner` instance was found in the extensions attached to the \ + incoming request. This usually means that `IdentityMiddleware` has not been \ + registered as an application middleware via `App::wrap`. `Identity` cannot be used \ + unless the identity machine is properly mounted: register `IdentityMiddleware` as \ + a middleware for your application to fix this panic. If the problem persists, \ + please file an issue on GitHub.", + ) + .to_owned() + } + + /// Retrieve the user id attached to the current session. + fn get_identity(&self) -> Result { + self.session + .get::(ID_KEY) + .context("Failed to deserialize the user identifier attached to the current session")? + .ok_or_else(|| { + anyhow!("There is no identity information attached to the current session") + }) + } +} + +pub(crate) const ID_KEY: &str = "actix_identity.user_id"; +pub(crate) const LAST_VISIT_UNIX_TIMESTAMP_KEY: &str = "actix_identity.last_visited_at"; +pub(crate) const LOGIN_UNIX_TIMESTAMP_KEY: &str = "actix_identity.logged_in_at"; + +impl Identity { + /// Return the user id associated to the current session. + /// + /// # Examples + /// ``` + /// use actix_web::{get, Responder}; + /// use actix_identity::Identity; + /// + /// #[get("/")] + /// async fn index(user: Option) -> impl Responder { + /// if let Some(user) = user { + /// format!("Welcome! {}", user.id().unwrap()) + /// } else { + /// "Welcome Anonymous!".to_owned() + /// } + /// } + /// ``` + pub fn id(&self) -> Result { + self.0.session.get(ID_KEY)?.ok_or_else(|| { + anyhow!("Bug: the identity information attached to the current session has disappeared") + }) + } + + /// Attach a valid user identity to the current session. + /// + /// This method should be called after you have successfully authenticated the user. After + /// `login` has been called, the user will be able to access all routes that require a valid + /// [`Identity`]. + /// + /// # Examples + /// ``` + /// use actix_web::{post, Responder, HttpRequest, HttpMessage, HttpResponse}; + /// use actix_identity::Identity; + /// + /// #[post("/login")] + /// async fn login(request: HttpRequest) -> impl Responder { + /// Identity::login(&request.extensions(), "User1".into()); + /// HttpResponse::Ok() + /// } + /// ``` + pub fn login(ext: &Extensions, id: String) -> Result { + let inner = IdentityInner::extract(ext); + inner.session.insert(ID_KEY, id)?; + inner.session.insert( + LOGIN_UNIX_TIMESTAMP_KEY, + OffsetDateTime::now_utc().unix_timestamp(), + )?; + inner.session.renew(); + Ok(Self(inner)) + } + + /// Remove the user identity from the current session. + /// + /// After `logout` has been called, the user will no longer be able to access routes that + /// require a valid [`Identity`]. + /// + /// The behaviour on logout is determined by [`IdentityMiddlewareBuilder::logout_behaviour`]. + /// + /// # Examples + /// ``` + /// use actix_web::{post, Responder, HttpResponse}; + /// use actix_identity::Identity; + /// + /// #[post("/logout")] + /// async fn logout(user: Identity) -> impl Responder { + /// user.logout(); + /// HttpResponse::Ok() + /// } + /// ``` + /// + /// [`IdentityMiddlewareBuilder::logout_behaviour`]: crate::config::IdentityMiddlewareBuilder::logout_behaviour + pub fn logout(self) { + match self.0.logout_behaviour { + LogoutBehaviour::PurgeSession => { + self.0.session.purge(); + } + LogoutBehaviour::DeleteIdentityKeys => { + self.0.session.remove(ID_KEY); + if self.0.is_login_deadline_enabled { + self.0.session.remove(LOGIN_UNIX_TIMESTAMP_KEY); + } + if self.0.is_visit_deadline_enabled { + self.0.session.remove(LAST_VISIT_UNIX_TIMESTAMP_KEY); + } + } + } + } + + pub(crate) fn extract(ext: &Extensions) -> Result { + let inner = IdentityInner::extract(ext); + inner.get_identity()?; + Ok(Self(inner)) + } + + pub(crate) fn logged_at(&self) -> Result, anyhow::Error> { + self.0 + .session + .get(LOGIN_UNIX_TIMESTAMP_KEY)? + .map(OffsetDateTime::from_unix_timestamp) + .transpose() + .map_err(anyhow::Error::from) + } + + pub(crate) fn last_visited_at(&self) -> Result, anyhow::Error> { + self.0 + .session + .get(LAST_VISIT_UNIX_TIMESTAMP_KEY)? + .map(OffsetDateTime::from_unix_timestamp) + .transpose() + .map_err(anyhow::Error::from) + } +} + +/// Extractor implementation for [`Identity`]. +/// +/// # Examples +/// ``` +/// use actix_web::{get, Responder}; +/// use actix_identity::Identity; +/// +/// #[get("/")] +/// async fn index(user: Option) -> impl Responder { +/// if let Some(user) = user { +/// format!("Welcome! {}", user.id().unwrap()) /// } else { /// "Welcome Anonymous!".to_owned() /// } @@ -91,10 +240,17 @@ impl Identity { /// ``` impl FromRequest for Identity { type Error = Error; - type Future = Ready>; + type Future = Ready>; #[inline] fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { - ready(Ok(Identity(req.clone()))) + ready(Identity::extract(&req.extensions()).map_err(|err| { + let res = actix_web::error::InternalError::from_response( + err, + HttpResponse::new(StatusCode::UNAUTHORIZED), + ); + + actix_web::Error::from(res) + })) } } diff --git a/actix-identity/src/identity_ext.rs b/actix-identity/src/identity_ext.rs new file mode 100644 index 000000000..431539a86 --- /dev/null +++ b/actix-identity/src/identity_ext.rs @@ -0,0 +1,27 @@ +use actix_web::{dev::ServiceRequest, guard::GuardContext, HttpMessage, HttpRequest}; + +use crate::Identity; + +/// Helper trait to retrieve an [`Identity`] instance from various `actix-web`'s types. +pub trait IdentityExt { + /// Retrieve the identity attached to the current session, if available. + fn get_identity(&self) -> Result; +} + +impl IdentityExt for HttpRequest { + fn get_identity(&self) -> Result { + Identity::extract(&self.extensions()) + } +} + +impl IdentityExt for ServiceRequest { + fn get_identity(&self) -> Result { + Identity::extract(&self.extensions()) + } +} + +impl<'a> IdentityExt for GuardContext<'a> { + fn get_identity(&self) -> Result { + Identity::extract(&self.req_data()) + } +} diff --git a/actix-identity/src/lib.rs b/actix-identity/src/lib.rs index 2e51fd365..5c172a64e 100644 --- a/actix-identity/src/lib.rs +++ b/actix-identity/src/lib.rs @@ -1,163 +1,99 @@ -//! Opinionated request identity service for Actix Web apps. +//! Identity management for Actix Web. //! -//! [`IdentityService`] middleware can be used with different policies types to store -//! identity information. +//! `actix-identity` can be used to track identity of a user across multiple requests. It is built +//! on top of HTTP sessions, via [`actix-session`](https://docs.rs/actix-session). //! -//! A cookie based policy is provided. [`CookieIdentityPolicy`] uses cookies as identity storage. +//! # Getting started +//! To start using identity management in your Actix Web application you must register +//! [`IdentityMiddleware`] and `SessionMiddleware` as middleware on your `App`: //! -//! To access current request identity, use the [`Identity`] extractor. +//! ```no_run +//! # use actix_web::web; +//! use actix_web::{cookie::Key, App, HttpServer, HttpResponse}; +//! use actix_identity::IdentityMiddleware; +//! use actix_session::{storage::RedisSessionStore, SessionMiddleware}; //! +//! #[actix_web::main] +//! async fn main() { +//! let secret_key = Key::generate(); +//! let redis_store = RedisSessionStore::new("redis://127.0.0.1:6379") +//! .await +//! .unwrap(); +//! +//! HttpServer::new(move || { +//! App::new() +//! // Install the identity framework first. +//! .wrap(IdentityMiddleware::default()) +//! // The identity system is built on top of sessions. You must install the session +//! // middleware to leverage `actix-identity`. The session middleware must be mounted +//! // AFTER the identity middleware: `actix-web` invokes middleware in the OPPOSITE +//! // order of registration when it receives an incoming request. +//! .wrap(SessionMiddleware::new( +//! redis_store.clone(), +//! secret_key.clone() +//! )) +//! // Your request handlers [...] +//! # .default_service(web::to(|| HttpResponse::Ok())) +//! }) +//! # ; +//! } //! ``` -//! use actix_web::*; -//! use actix_identity::{Identity, CookieIdentityPolicy, IdentityService}; +//! +//! User identities can be created, accessed and destroyed using the [`Identity`] extractor in your +//! request handlers: +//! +//! ```no_run +//! use actix_web::{get, post, HttpResponse, Responder, HttpRequest, HttpMessage}; +//! use actix_identity::Identity; +//! use actix_session::storage::RedisSessionStore; //! //! #[get("/")] -//! async fn index(id: Identity) -> String { -//! // access request identity -//! if let Some(id) = id.identity() { -//! format!("Welcome! {}", id) +//! async fn index(user: Option) -> impl Responder { +//! if let Some(user) = user { +//! format!("Welcome! {}", user.id().unwrap()) //! } else { //! "Welcome Anonymous!".to_owned() //! } //! } //! //! #[post("/login")] -//! async fn login(id: Identity) -> HttpResponse { -//! // remember identity -//! id.remember("User1".to_owned()); -//! HttpResponse::Ok().finish() +//! async fn login(request: HttpRequest) -> impl Responder { +//! // Some kind of authentication should happen here +//! // e.g. password-based, biometric, etc. +//! // [...] +//! +//! // attach a verified user identity to the active session +//! Identity::login(&request.extensions(), "User1".into()).unwrap(); +//! +//! HttpResponse::Ok() //! } //! //! #[post("/logout")] -//! async fn logout(id: Identity) -> HttpResponse { -//! // remove identity -//! id.forget(); -//! HttpResponse::Ok().finish() +//! async fn logout(user: Identity) -> impl Responder { +//! user.logout(); +//! HttpResponse::Ok() //! } -//! -//! HttpServer::new(move || { -//! // create cookie identity backend (inside closure, since policy is not Clone) -//! let policy = CookieIdentityPolicy::new(&[0; 32]) -//! .name("auth-cookie") -//! .secure(false); -//! -//! App::new() -//! // wrap policy into middleware identity middleware -//! .wrap(IdentityService::new(policy)) -//! .service(services![index, login, logout]) -//! }) -//! # ; //! ``` +//! +//! # Advanced configuration +//! By default, `actix-identity` does not automatically log out users. You can change this behaviour +//! by customising the configuration for [`IdentityMiddleware`] via [`IdentityMiddleware::builder`]. +//! +//! In particular, you can automatically log out users who: +//! - have been inactive for a while (see [`IdentityMiddlewareBuilder::visit_deadline`]; +//! - logged in too long ago (see [`IdentityMiddlewareBuilder::login_deadline`]). +//! +//! [`IdentityMiddlewareBuilder::visit_deadline`]: config::IdentityMiddlewareBuilder::visit_deadline +//! [`IdentityMiddlewareBuilder::login_deadline`]: config::IdentityMiddlewareBuilder::login_deadline -#![deny(rust_2018_idioms, nonstandard_style)] +#![deny(rust_2018_idioms, nonstandard_style, missing_docs)] #![warn(future_incompatible)] -use std::future::Future; - -use actix_web::{ - dev::{ServiceRequest, ServiceResponse}, - Error, HttpMessage, Result, -}; - -mod cookie; +pub mod config; mod identity; +mod identity_ext; mod middleware; -pub use self::cookie::CookieIdentityPolicy; pub use self::identity::Identity; -pub use self::middleware::IdentityService; - -/// Identity policy. -pub trait IdentityPolicy: Sized + 'static { - /// The return type of the middleware - type Future: Future, Error>>; - - /// The return type of the middleware - type ResponseFuture: Future>; - - /// Parse the session from request and load data from a service identity. - #[allow(clippy::wrong_self_convention)] - fn from_request(&self, req: &mut ServiceRequest) -> Self::Future; - - /// Write changes to response - fn to_response( - &self, - identity: Option, - changed: bool, - response: &mut ServiceResponse, - ) -> Self::ResponseFuture; -} - -/// Helper trait that allows to get Identity. -/// -/// It could be used in middleware but identity policy must be set before any other middleware that -/// needs identity. RequestIdentity is implemented both for `ServiceRequest` and `HttpRequest`. -pub trait RequestIdentity { - fn get_identity(&self) -> Option; -} - -impl RequestIdentity for T -where - T: HttpMessage, -{ - fn get_identity(&self) -> Option { - Identity::get_identity(&self.extensions()) - } -} - -#[cfg(test)] -mod tests { - use std::time::SystemTime; - - use actix_web::{ - body::{BoxBody, EitherBody}, - dev::ServiceResponse, - test, web, App, Error, - }; - - use super::*; - - pub(crate) const COOKIE_KEY_MASTER: [u8; 32] = [0; 32]; - pub(crate) const COOKIE_NAME: &str = "actix_auth"; - pub(crate) const COOKIE_LOGIN: &str = "test"; - - #[allow(clippy::enum_variant_names)] - pub(crate) enum LoginTimestampCheck { - NoTimestamp, - NewTimestamp, - OldTimestamp(SystemTime), - } - - #[allow(clippy::enum_variant_names)] - pub(crate) enum VisitTimeStampCheck { - NoTimestamp, - NewTimestamp, - } - - pub(crate) async fn create_identity_server< - F: Fn(CookieIdentityPolicy) -> CookieIdentityPolicy + Sync + Send + Clone + 'static, - >( - f: F, - ) -> impl actix_service::Service< - actix_http::Request, - Response = ServiceResponse>, - Error = Error, - > { - test::init_service( - App::new() - .wrap(IdentityService::new(f(CookieIdentityPolicy::new( - &COOKIE_KEY_MASTER, - ) - .secure(false) - .name(COOKIE_NAME)))) - .service(web::resource("/").to(|id: Identity| async move { - let identity = id.identity(); - if identity.is_none() { - id.remember(COOKIE_LOGIN.to_string()) - } - web::Json(identity) - })), - ) - .await - } -} +pub use self::identity_ext::IdentityExt; +pub use self::middleware::IdentityMiddleware; diff --git a/actix-identity/src/middleware.rs b/actix-identity/src/middleware.rs index 9e5deb2b2..f65f48faa 100644 --- a/actix-identity/src/middleware.rs +++ b/actix-identity/src/middleware.rs @@ -1,171 +1,251 @@ use std::rc::Rc; +use actix_session::SessionExt; use actix_utils::future::{ready, Ready}; use actix_web::{ - body::{EitherBody, MessageBody}, + body::MessageBody, + cookie::time::format_description::well_known::Rfc3339, dev::{Service, ServiceRequest, ServiceResponse, Transform}, - Error, HttpMessage, Result, + Error, HttpMessage as _, Result, }; -use futures_util::future::{FutureExt as _, LocalBoxFuture}; +use futures_core::future::LocalBoxFuture; +use time::OffsetDateTime; -use crate::{identity::IdentityItem, IdentityPolicy}; +use crate::{ + config::{Configuration, IdentityMiddlewareBuilder}, + identity::IdentityInner, + Identity, +}; -/// Request identity middleware +/// Identity management middleware. /// +/// ```no_run +/// use actix_web::{cookie::Key, App, HttpServer}; +/// use actix_session::storage::RedisSessionStore; +/// use actix_identity::{Identity, IdentityMiddleware}; +/// use actix_session::{Session, SessionMiddleware}; +/// +/// #[actix_web::main] +/// async fn main() { +/// let secret_key = Key::generate(); +/// let redis_store = RedisSessionStore::new("redis://127.0.0.1:6379").await.unwrap(); +/// +/// HttpServer::new(move || { +/// App::new() +/// // Install the identity framework first. +/// .wrap(IdentityMiddleware::default()) +/// // The identity system is built on top of sessions. +/// // You must install the session middleware to leverage `actix-identity`. +/// .wrap(SessionMiddleware::new(redis_store.clone(), secret_key.clone())) +/// }) +/// # ; +/// } /// ``` -/// use actix_web::App; -/// use actix_identity::{CookieIdentityPolicy, IdentityService}; -/// -/// // create cookie identity backend -/// let policy = CookieIdentityPolicy::new(&[0; 32]) -/// .name("auth-cookie") -/// .secure(false); -/// -/// let app = App::new() -/// // wrap policy into identity middleware -/// .wrap(IdentityService::new(policy)); -/// ``` -pub struct IdentityService { - backend: Rc, +#[derive(Default, Clone)] +pub struct IdentityMiddleware { + configuration: Rc, } -impl IdentityService { - /// Create new identity service with specified backend. - pub fn new(backend: T) -> Self { - IdentityService { - backend: Rc::new(backend), +impl IdentityMiddleware { + pub(crate) fn new(configuration: Configuration) -> Self { + Self { + configuration: Rc::new(configuration), } } + + /// A fluent API to configure [`IdentityMiddleware`]. + pub fn builder() -> IdentityMiddlewareBuilder { + IdentityMiddlewareBuilder::new() + } } -impl Transform for IdentityService +impl Transform for IdentityMiddleware where S: Service, Error = Error> + 'static, S::Future: 'static, - T: IdentityPolicy, B: MessageBody + 'static, { - type Response = ServiceResponse>; + type Response = ServiceResponse; type Error = Error; + type Transform = InnerIdentityMiddleware; type InitError = (); - type Transform = IdentityServiceMiddleware; type Future = Ready>; fn new_transform(&self, service: S) -> Self::Future { - ready(Ok(IdentityServiceMiddleware { - backend: self.backend.clone(), + ready(Ok(InnerIdentityMiddleware { service: Rc::new(service), + configuration: Rc::clone(&self.configuration), })) } } -pub struct IdentityServiceMiddleware { - pub(crate) service: Rc, - pub(crate) backend: Rc, +#[doc(hidden)] +pub struct InnerIdentityMiddleware { + service: Rc, + configuration: Rc, } -impl Clone for IdentityServiceMiddleware { +impl Clone for InnerIdentityMiddleware { fn clone(&self) -> Self { Self { - backend: Rc::clone(&self.backend), service: Rc::clone(&self.service), + configuration: Rc::clone(&self.configuration), } } } -impl Service for IdentityServiceMiddleware +impl Service for InnerIdentityMiddleware where S: Service, Error = Error> + 'static, S::Future: 'static, - T: IdentityPolicy, B: MessageBody + 'static, { - type Response = ServiceResponse>; + type Response = ServiceResponse; type Error = Error; type Future = LocalBoxFuture<'static, Result>; actix_service::forward_ready!(service); - fn call(&self, mut req: ServiceRequest) -> Self::Future { + fn call(&self, req: ServiceRequest) -> Self::Future { let srv = Rc::clone(&self.service); - let backend = Rc::clone(&self.backend); - let fut = self.backend.from_request(&mut req); - - async move { - match fut.await { - Ok(id) => { - req.extensions_mut() - .insert(IdentityItem { id, changed: false }); - - let mut res = srv.call(req).await?; - let id = res.request().extensions_mut().remove::(); - - if let Some(id) = id { - match backend.to_response(id.id, id.changed, &mut res).await { - Ok(_) => Ok(res.map_into_left_body()), - Err(err) => Ok(res.error_response(err).map_into_right_body()), - } - } else { - Ok(res.map_into_left_body()) - } - } - Err(err) => Ok(req.error_response(err).map_into_right_body()), - } - } - .boxed_local() + let configuration = Rc::clone(&self.configuration); + Box::pin(async move { + let identity_inner = IdentityInner { + session: req.get_session(), + logout_behaviour: configuration.on_logout.clone(), + is_login_deadline_enabled: configuration.login_deadline.is_some(), + is_visit_deadline_enabled: configuration.visit_deadline.is_some(), + }; + req.extensions_mut().insert(identity_inner); + enforce_policies(&req, &configuration); + srv.call(req).await + }) } } -#[cfg(test)] -mod tests { - use std::{rc::Rc, time::Duration}; +// easier to scan with returns where they are +// especially if the function body were to evolve in the future +#[allow(clippy::needless_return)] +fn enforce_policies(req: &ServiceRequest, configuration: &Configuration) { + let must_extract_identity = + configuration.login_deadline.is_some() || configuration.visit_deadline.is_some(); - use actix_service::into_service; - use actix_web::{dev, error, test, Error, Result}; + if !must_extract_identity { + return; + } - use super::*; - - #[actix_web::test] - async fn test_borrowed_mut_error() { - use actix_utils::future::{ok, Ready}; - use futures_util::future::lazy; - - struct Ident; - impl IdentityPolicy for Ident { - type Future = Ready, Error>>; - type ResponseFuture = Ready>; - - fn from_request(&self, _: &mut dev::ServiceRequest) -> Self::Future { - ok(Some("test".to_string())) - } - - fn to_response( - &self, - _: Option, - _: bool, - _: &mut dev::ServiceResponse, - ) -> Self::ResponseFuture { - ok(()) - } + let identity = match Identity::extract(&req.extensions()) { + Ok(identity) => identity, + Err(err) => { + tracing::debug!( + error.display = %err, + error.debug = ?err, + "Failed to extract an `Identity` from the incoming request." + ); + return; } + }; - let srv = crate::middleware::IdentityServiceMiddleware { - backend: Rc::new(Ident), - service: Rc::new(into_service(|_: dev::ServiceRequest| async move { - actix_web::rt::time::sleep(Duration::from_secs(100)).await; - Err::(error::ErrorBadRequest("error")) - })), - }; + if let Some(login_deadline) = configuration.login_deadline { + if matches!( + enforce_login_deadline(&identity, login_deadline), + PolicyDecision::LogOut + ) { + identity.logout(); + return; + } + } - let srv2 = srv.clone(); - let req = test::TestRequest::default().to_srv_request(); - - actix_web::rt::spawn(async move { - let _ = srv2.call(req).await; - }); - - actix_web::rt::time::sleep(Duration::from_millis(50)).await; - - let _ = lazy(|cx| srv.poll_ready(cx)).await; + if let Some(visit_deadline) = configuration.visit_deadline { + if matches!( + enforce_visit_deadline(&identity, visit_deadline), + PolicyDecision::LogOut + ) { + identity.logout(); + return; + } } } + +fn enforce_login_deadline( + identity: &Identity, + login_deadline: std::time::Duration, +) -> PolicyDecision { + match identity.logged_at() { + Ok(None) => { + tracing::info!( + "Login deadline is enabled, but there is no login timestamp in the session \ + state attached to the incoming request. Logging the user out." + ); + PolicyDecision::LogOut + } + Err(err) => { + tracing::info!( + error.display = %err, + error.debug = ?err, + "Login deadline is enabled but we failed to extract the login timestamp from the \ + session state attached to the incoming request. Logging the user out." + ); + PolicyDecision::LogOut + } + Ok(Some(logged_in_at)) => { + let elapsed = OffsetDateTime::now_utc() - logged_in_at; + if elapsed > login_deadline { + tracing::info!( + user.logged_in_at = %logged_in_at.format(&Rfc3339).unwrap_or_default(), + identity.login_deadline_seconds = login_deadline.as_secs(), + identity.elapsed_since_login_seconds = elapsed.whole_seconds(), + "Login deadline is enabled and too much time has passed since the user logged \ + in. Logging the user out." + ); + PolicyDecision::LogOut + } else { + PolicyDecision::StayLoggedIn + } + } + } +} + +fn enforce_visit_deadline( + identity: &Identity, + visit_deadline: std::time::Duration, +) -> PolicyDecision { + match identity.last_visited_at() { + Ok(None) => { + tracing::info!( + "Last visit deadline is enabled, but there is no last visit timestamp in the \ + session state attached to the incoming request. Logging the user out." + ); + PolicyDecision::LogOut + } + Err(err) => { + tracing::info!( + error.display = %err, + error.debug = ?err, + "Last visit deadline is enabled but we failed to extract the last visit timestamp \ + from the session state attached to the incoming request. Logging the user out." + ); + PolicyDecision::LogOut + } + Ok(Some(last_visited_at)) => { + let elapsed = OffsetDateTime::now_utc() - last_visited_at; + if elapsed > visit_deadline { + tracing::info!( + user.last_visited_at = %last_visited_at.format(&Rfc3339).unwrap_or_default(), + identity.visit_deadline_seconds = visit_deadline.as_secs(), + identity.elapsed_since_last_visit_seconds = elapsed.whole_seconds(), + "Last visit deadline is enabled and too much time has passed since the last \ + time the user visited. Logging the user out." + ); + PolicyDecision::LogOut + } else { + PolicyDecision::StayLoggedIn + } + } + } +} + +enum PolicyDecision { + StayLoggedIn, + LogOut, +} diff --git a/actix-identity/tests/integration/fixtures.rs b/actix-identity/tests/integration/fixtures.rs new file mode 100644 index 000000000..f3fce595b --- /dev/null +++ b/actix-identity/tests/integration/fixtures.rs @@ -0,0 +1,17 @@ +use actix_session::{storage::CookieSessionStore, SessionMiddleware}; +use actix_web::cookie::Key; +use uuid::Uuid; + +pub fn store() -> CookieSessionStore { + CookieSessionStore::default() +} + +pub fn user_id() -> String { + Uuid::new_v4().to_string() +} + +pub fn session_middleware() -> SessionMiddleware { + SessionMiddleware::builder(store(), Key::generate()) + .cookie_domain(Some("localhost".into())) + .build() +} diff --git a/actix-identity/tests/integration/integration.rs b/actix-identity/tests/integration/integration.rs new file mode 100644 index 000000000..71d6dc5ba --- /dev/null +++ b/actix-identity/tests/integration/integration.rs @@ -0,0 +1,168 @@ +use std::time::Duration; + +use actix_identity::{config::LogoutBehaviour, IdentityMiddleware}; +use actix_web::http::StatusCode; + +use crate::{fixtures::user_id, test_app::TestApp}; + +#[actix_web::test] +async fn opaque_401_is_returned_for_unauthenticated_users() { + let app = TestApp::spawn(); + + let response = app.get_identity_required().await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(response.bytes().await.unwrap().is_empty()); +} + +#[actix_web::test] +async fn login_works() { + let app = TestApp::spawn(); + let user_id = user_id(); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + + // Access identity-restricted route successfully + let response = app.get_identity_required().await; + assert!(response.status().is_success()); +} + +#[actix_web::test] +async fn logging_in_again_replaces_the_current_identity() { + let app = TestApp::spawn(); + let first_user_id = user_id(); + let second_user_id = user_id(); + + // Log-in + let body = app.post_login(first_user_id.clone()).await; + assert_eq!(body.user_id, Some(first_user_id.clone())); + + // Log-in again + let body = app.post_login(second_user_id.clone()).await; + assert_eq!(body.user_id, Some(second_user_id.clone())); + + let body = app.get_current().await; + assert_eq!(body.user_id, Some(second_user_id.clone())); +} + +#[actix_web::test] +async fn session_key_is_renewed_on_login() { + let app = TestApp::spawn(); + let user_id = user_id(); + + // Create an anonymous session + let body = app.post_increment().await; + assert_eq!(body.user_id, None); + assert_eq!(body.counter, 1); + assert_eq!(body.session_status, "changed"); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + assert_eq!(body.counter, 1); + assert_eq!(body.session_status, "renewed"); +} + +#[actix_web::test] +async fn logout_works() { + let app = TestApp::spawn(); + let user_id = user_id(); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + + // Log-out + let response = app.post_logout().await; + assert!(response.status().is_success()); + + // Try to access identity-restricted route + let response = app.get_identity_required().await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[actix_web::test] +async fn logout_can_avoid_destroying_the_whole_session() { + let app = TestApp::spawn_with_config( + IdentityMiddleware::builder().logout_behaviour(LogoutBehaviour::DeleteIdentityKeys), + ); + let user_id = user_id(); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + assert_eq!(body.counter, 0); + + // Increment counter + let body = app.post_increment().await; + assert_eq!(body.user_id, Some(user_id.clone())); + assert_eq!(body.counter, 1); + + // Log-out + let response = app.post_logout().await; + assert!(response.status().is_success()); + + // Check the state of the counter attached to the session state + let body = app.get_current().await; + assert_eq!(body.user_id, None); + // It would be 0 if the session state had been entirely lost! + assert_eq!(body.counter, 1); +} + +#[actix_web::test] +async fn user_is_logged_out_when_login_deadline_is_elapsed() { + let login_deadline = Duration::from_millis(10); + let app = TestApp::spawn_with_config( + IdentityMiddleware::builder().login_deadline(Some(login_deadline)), + ); + let user_id = user_id(); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + + // Wait for deadline to pass + actix_web::rt::time::sleep(login_deadline * 2).await; + + let body = app.get_current().await; + // We have been logged out! + assert_eq!(body.user_id, None); +} + +#[actix_web::test] +async fn login_deadline_does_not_log_users_out_before_their_time() { + // 1 hour + let login_deadline = Duration::from_secs(60 * 60); + let app = TestApp::spawn_with_config( + IdentityMiddleware::builder().login_deadline(Some(login_deadline)), + ); + let user_id = user_id(); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + + let body = app.get_current().await; + assert_eq!(body.user_id, Some(user_id)); +} + +#[actix_web::test] +async fn user_is_logged_out_when_visit_deadline_is_elapsed() { + let visit_deadline = Duration::from_millis(10); + let app = TestApp::spawn_with_config( + IdentityMiddleware::builder().visit_deadline(Some(visit_deadline)), + ); + let user_id = user_id(); + + // Log-in + let body = app.post_login(user_id.clone()).await; + assert_eq!(body.user_id, Some(user_id.clone())); + + // Wait for deadline to pass + actix_web::rt::time::sleep(visit_deadline * 2).await; + + let body = app.get_current().await; + // We have been logged out! + assert_eq!(body.user_id, None); +} diff --git a/actix-identity/tests/integration/main.rs b/actix-identity/tests/integration/main.rs new file mode 100644 index 000000000..8ebd2e86a --- /dev/null +++ b/actix-identity/tests/integration/main.rs @@ -0,0 +1,3 @@ +pub mod fixtures; +mod integration; +pub mod test_app; diff --git a/actix-identity/tests/integration/test_app.rs b/actix-identity/tests/integration/test_app.rs new file mode 100644 index 000000000..93f41b85e --- /dev/null +++ b/actix-identity/tests/integration/test_app.rs @@ -0,0 +1,186 @@ +use std::net::TcpListener; + +use actix_identity::{config::IdentityMiddlewareBuilder, Identity, IdentityMiddleware}; +use actix_session::{Session, SessionStatus}; +use actix_web::{web, App, HttpMessage, HttpRequest, HttpResponse, HttpServer}; +use serde::{Deserialize, Serialize}; + +use crate::fixtures::session_middleware; + +pub struct TestApp { + port: u16, + api_client: reqwest::Client, +} + +impl TestApp { + /// Spawn a test application using a custom configuration for `IdentityMiddleware`. + pub fn spawn_with_config(builder: IdentityMiddlewareBuilder) -> Self { + // Random OS port + let listener = TcpListener::bind("localhost:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = HttpServer::new(move || { + App::new() + .wrap(builder.clone().build()) + .wrap(session_middleware()) + .route("/increment", web::post().to(increment)) + .route("/current", web::get().to(show)) + .route("/login", web::post().to(login)) + .route("/logout", web::post().to(logout)) + .route("/identity_required", web::get().to(identity_required)) + }) + .workers(1) + .listen(listener) + .unwrap() + .run(); + let _ = actix_web::rt::spawn(server); + + let client = reqwest::Client::builder() + .cookie_store(true) + .build() + .unwrap(); + + TestApp { + port, + api_client: client, + } + } + + /// Spawn a test application using the default configuration settings for `IdentityMiddleware`. + pub fn spawn() -> Self { + Self::spawn_with_config(IdentityMiddleware::builder()) + } + + fn url(&self) -> String { + format!("http://localhost:{}", self.port) + } + + pub async fn get_identity_required(&self) -> reqwest::Response { + self.api_client + .get(format!("{}/identity_required", &self.url())) + .send() + .await + .unwrap() + } + + pub async fn get_current(&self) -> EndpointResponse { + self.api_client + .get(format!("{}/current", &self.url())) + .send() + .await + .unwrap() + .json() + .await + .unwrap() + } + + pub async fn post_increment(&self) -> EndpointResponse { + let response = self + .api_client + .post(format!("{}/increment", &self.url())) + .send() + .await + .unwrap(); + response.json().await.unwrap() + } + + pub async fn post_login(&self, user_id: String) -> EndpointResponse { + let response = self + .api_client + .post(format!("{}/login", &self.url())) + .json(&LoginRequest { user_id }) + .send() + .await + .unwrap(); + response.json().await.unwrap() + } + + pub async fn post_logout(&self) -> reqwest::Response { + self.api_client + .post(format!("{}/logout", &self.url())) + .send() + .await + .unwrap() + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct EndpointResponse { + pub user_id: Option, + pub counter: i32, + pub session_status: String, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +struct LoginRequest { + user_id: String, +} + +async fn show(user: Option, session: Session) -> HttpResponse { + let user_id = user.map(|u| u.id().unwrap()); + let counter: i32 = session + .get::("counter") + .unwrap_or(Some(0)) + .unwrap_or(0); + + HttpResponse::Ok().json(&EndpointResponse { + user_id, + counter, + session_status: session_status(session), + }) +} + +async fn increment(session: Session, user: Option) -> HttpResponse { + let user_id = user.map(|u| u.id().unwrap()); + let counter: i32 = session + .get::("counter") + .unwrap_or(Some(0)) + .map_or(1, |inner| inner + 1); + session.insert("counter", &counter).unwrap(); + + HttpResponse::Ok().json(&EndpointResponse { + user_id, + counter, + session_status: session_status(session), + }) +} + +async fn login( + user_id: web::Json, + request: HttpRequest, + session: Session, +) -> HttpResponse { + let id = user_id.into_inner().user_id; + let user = Identity::login(&request.extensions(), id).unwrap(); + + let counter: i32 = session + .get::("counter") + .unwrap_or(Some(0)) + .unwrap_or(0); + + HttpResponse::Ok().json(&EndpointResponse { + user_id: Some(user.id().unwrap()), + counter, + session_status: session_status(session), + }) +} + +async fn logout(user: Option) -> HttpResponse { + if let Some(user) = user { + user.logout(); + } + HttpResponse::Ok().finish() +} + +async fn identity_required(_identity: Identity) -> HttpResponse { + HttpResponse::Ok().finish() +} + +fn session_status(session: Session) -> String { + match session.status() { + SessionStatus::Changed => "changed", + SessionStatus::Purged => "purged", + SessionStatus::Renewed => "renewed", + SessionStatus::Unchanged => "unchanged", + } + .into() +} diff --git a/actix-session/CHANGES.md b/actix-session/CHANGES.md index d76f86a7c..2f2391f01 100644 --- a/actix-session/CHANGES.md +++ b/actix-session/CHANGES.md @@ -1,6 +1,6 @@ # Changes -## Unreleased - 2022-xx-xx +## Unreleased - 2021-xx-xx - Added `TtlExtensionPolicy` enum to support different strategies for extending the TTL attached to the session state. `TtlExtensionPolicy::OnEveryRequest` now allows for long-lived sessions that do not expire if the user remains active. [#233] - `SessionLength` is now called `SessionLifecycle`. [#233] - `SessionLength::Predetermined` is now called `SessionLifecycle::PersistentSession`. [#233] diff --git a/actix-session/src/lib.rs b/actix-session/src/lib.rs index fcc51744b..917121429 100644 --- a/actix-session/src/lib.rs +++ b/actix-session/src/lib.rs @@ -146,7 +146,7 @@ mod session_ext; pub mod storage; pub use self::middleware::SessionMiddleware; -pub use self::session::{Session, SessionStatus}; +pub use self::session::{Session, SessionGetError, SessionInsertError, SessionStatus}; pub use self::session_ext::SessionExt; #[cfg(test)] diff --git a/actix-session/src/session.rs b/actix-session/src/session.rs index 2aa3bae68..219cdde95 100644 --- a/actix-session/src/session.rs +++ b/actix-session/src/session.rs @@ -1,16 +1,20 @@ use std::{ cell::{Ref, RefCell}, collections::HashMap, + error::Error as StdError, mem, rc::Rc, }; use actix_utils::future::{ready, Ready}; use actix_web::{ + body::BoxBody, dev::{Extensions, Payload, ServiceRequest, ServiceResponse}, error::Error, - FromRequest, HttpMessage, HttpRequest, + FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError, }; +use anyhow::Context; +use derive_more::{Display, From}; use serde::{de::DeserializeOwned, Serialize}; /// The primary interface to access and modify session state. @@ -38,6 +42,7 @@ use serde::{de::DeserializeOwned, Serialize}; /// [`SessionExt`]. /// /// [`SessionExt`]: crate::SessionExt +#[derive(Clone)] pub struct Session(Rc>); /// Status of a [`Session`]. @@ -78,9 +83,20 @@ 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> { + pub fn get(&self, key: &str) -> Result, SessionGetError> { if let Some(val_str) = self.0.borrow().state.get(key) { - Ok(Some(serde_json::from_str(val_str)?)) + Ok(Some( + serde_json::from_str(val_str) + .with_context(|| { + format!( + "Failed to deserialize the JSON-encoded session data attached to key \ + `{}` as a `{}` type", + key, + std::any::type_name::() + ) + }) + .map_err(SessionGetError)?, + )) } else { Ok(None) } @@ -104,17 +120,29 @@ impl Session { /// only a reference to the value is taken. /// /// It returns an error if it fails to serialize `value` to JSON. - pub fn insert( + pub fn insert( &self, key: impl Into, - value: impl Serialize, - ) -> Result<(), serde_json::Error> { + value: T, + ) -> Result<(), SessionInsertError> { 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); + + let key = key.into(); + let val = serde_json::to_string(&value) + .with_context(|| { + format!( + "Failed to serialize the provided `{}` type instance as JSON in order to \ + attach as session data to the `{}` key", + std::any::type_name::(), + &key + ) + }) + .map_err(SessionInsertError)?; + + inner.state.insert(key, val); } Ok(()) @@ -136,7 +164,7 @@ impl Session { /// Remove value from the session and deserialize. /// - /// Returns None if key was not present in session. Returns `T` if deserialization succeeds, + /// 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) @@ -144,7 +172,7 @@ impl Session { Ok(val) => Ok(val), Err(_err) => { tracing::debug!( - "removed value (key: {}) could not be deserialized as {}", + "Removed value (key: {}) could not be deserialized as {}", key, std::any::type_name::() ); @@ -195,9 +223,9 @@ impl Session { /// 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`). + /// 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) { @@ -254,3 +282,37 @@ impl FromRequest for Session { ready(Ok(Session::get_session(&mut *req.extensions_mut()))) } } + +/// Error returned by [`Session::get`]. +#[derive(Debug, Display, From)] +#[display(fmt = "{}", _0)] +pub struct SessionGetError(anyhow::Error); + +impl StdError for SessionGetError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(self.0.as_ref()) + } +} + +impl ResponseError for SessionGetError { + fn error_response(&self) -> HttpResponse { + HttpResponse::new(self.status_code()) + } +} + +/// Error returned by [`Session::insert`]. +#[derive(Debug, Display, From)] +#[display(fmt = "{}", _0)] +pub struct SessionInsertError(anyhow::Error); + +impl StdError for SessionInsertError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(self.0.as_ref()) + } +} + +impl ResponseError for SessionInsertError { + fn error_response(&self) -> HttpResponse { + HttpResponse::new(self.status_code()) + } +}