From 23912afd492a43e41563d85bc3a8a7b408423d95 Mon Sep 17 00:00:00 2001 From: Rob Ede Date: Tue, 23 Mar 2021 05:05:03 +0000 Subject: [PATCH] refactor identity (#168) --- actix-identity/CHANGES.md | 8 +- actix-identity/Cargo.toml | 5 +- actix-identity/src/cookie.rs | 827 ++++++++++++++++++++++ actix-identity/src/identity.rs | 101 +++ actix-identity/src/lib.rs | 1114 ++---------------------------- actix-identity/src/middleware.rs | 168 +++++ actix-redis/CHANGES.md | 2 +- actix-session/CHANGES.md | 1 - actix-web-httpauth/CHANGES.md | 1 - 9 files changed, 1169 insertions(+), 1058 deletions(-) create mode 100644 actix-identity/src/cookie.rs create mode 100644 actix-identity/src/identity.rs create mode 100644 actix-identity/src/middleware.rs diff --git a/actix-identity/CHANGES.md b/actix-identity/CHANGES.md index 04b14dd40..33465f3bd 100644 --- a/actix-identity/CHANGES.md +++ b/actix-identity/CHANGES.md @@ -1,12 +1,18 @@ # Changes ## Unreleased - 2020-xx-xx +* Rename `CookieIdentityPolicy::{max_age => max_age_secs}`. [#168] +* Rename `CookieIdentityPolicy::{max_age_time => max_age}`. [#168] * Update `actix-web` dependency to 4.0.0 beta. * Minimum supported Rust version (MSRV) is now 1.46.0. +[#168]: https://github.com/actix/actix-extras/pull/168 + ## 0.3.1 - 2020-09-20 -* Add method to set HttpOnly flag on cookie identity. [#102] +* Add method to set `HttpOnly` flag on cookie identity. [#102] + +[#102]: https://github.com/actix/actix-extras/pull/102 ## 0.3.0 - 2020-09-11 diff --git a/actix-identity/Cargo.toml b/actix-identity/Cargo.toml index 3941a683e..e410274a6 100644 --- a/actix-identity/Cargo.toml +++ b/actix-identity/Cargo.toml @@ -16,13 +16,14 @@ name = "actix_identity" path = "src/lib.rs" [dependencies] -actix-web = { version = "4.0.0-beta.4", default-features = false, features = ["cookies", "secure-cookies"] } actix-service = "2.0.0-beta.5" +actix-web = { version = "4.0.0-beta.4", default-features = false, features = ["cookies", "secure-cookies"] } + futures-util = { version = "0.3.7", default-features = false } serde = "1.0" serde_json = "1.0" time = "0.2.23" [dev-dependencies] -actix-rt = "2" actix-http = "3.0.0-beta.4" +actix-rt = "2" diff --git a/actix-identity/src/cookie.rs b/actix-identity/src/cookie.rs new file mode 100644 index 000000000..a78dcc6fb --- /dev/null +++ b/actix-identity/src/cookie.rs @@ -0,0 +1,827 @@ +use std::{rc::Rc, time::SystemTime}; + +use futures_util::future::{ready, Ready}; +use serde::{Deserialize, Serialize}; +use time::Duration; + +use actix_web::{ + cookie::{Cookie, CookieJar, Key, SameSite}, + dev::{ServiceRequest, ServiceResponse}, + error::{Error, Result}, + http::header::{self, HeaderValue}, + HttpMessage, +}; + +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) + } + }); + + let mut cookie = + Cookie::new(self.name.clone(), val.unwrap_or_else(|| Ok(String::new()))?); + 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(&key).add(cookie); + } else { + jar.add_original(cookie.clone()); + jar.private(&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(&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(&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::{ + 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(&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_rt::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_rt::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_rt::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(&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(&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_rt::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_rt::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_rt::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_rt::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_rt::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_rt::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_rt::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_rt::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_rt::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_rt::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_rt::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 new file mode 100644 index 000000000..d41da796a --- /dev/null +++ b/actix-identity/src/identity.rs @@ -0,0 +1,101 @@ +use actix_web::{ + dev::{Extensions, Payload}, + Error, FromRequest, HttpRequest, +}; +use futures_util::future::{ready, Ready}; + +pub(crate) struct IdentityItem { + pub(crate) id: Option, + pub(crate) changed: bool, +} + +/// The extractor type to obtain your identity from a request. +/// +/// ``` +/// use actix_web::*; +/// use actix_identity::Identity; +/// +/// #[get("/")] +/// async fn index(id: Identity) -> impl Responder { +/// // access request identity +/// if let Some(id) = id.identity() { +/// format!("Welcome! {}", id) +/// } else { +/// "Welcome Anonymous!".to_owned() +/// } +/// } +/// +/// #[post("/login")] +/// async fn login(id: Identity) -> impl Responder { +/// // remember identity +/// id.remember("User1".to_owned()); +/// +/// HttpResponse::Ok() +/// } +/// +/// #[post("/logout")] +/// async fn logout(id: Identity) -> impl Responder { +/// // remove identity +/// id.forget(); +/// +/// 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. +/// +/// ``` +/// # use actix_web::*; +/// use actix_identity::Identity; +/// +/// #[get("/")] +/// async fn index(id: Identity) -> impl Responder { +/// // access request identity +/// if let Some(id) = id.identity() { +/// format!("Welcome! {}", id) +/// } else { +/// "Welcome Anonymous!".to_owned() +/// } +/// } +/// ``` +impl FromRequest for Identity { + type Config = (); + type Error = Error; + type Future = Ready>; + + #[inline] + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + ready(Ok(Identity(req.clone()))) + } +} diff --git a/actix-identity/src/lib.rs b/actix-identity/src/lib.rs index 68871c426..434e7d0d0 100644 --- a/actix-identity/src/lib.rs +++ b/actix-identity/src/lib.rs @@ -1,21 +1,17 @@ -//! Request identity service for Actix applications. +//! Opinionated request identity service for Actix Web apps. //! -//! [**IdentityService**](struct.IdentityService.html) middleware can be -//! used with different policies types to store identity information. +//! [`IdentityService`] middleware can be used with different policies types to store +//! identity information. //! -//! By default, only cookie identity policy is implemented. Other backend -//! implementations can be added separately. +//! A cookie based policy is provided. [`CookieIdentityPolicy`] uses cookies as identity storage. //! -//! [**CookieIdentityPolicy**](struct.CookieIdentityPolicy.html) -//! uses cookies as identity storage. -//! -//! To access current request identity -//! [**Identity**](struct.Identity.html) extractor should be used. +//! To access current request identity, use the [`Identity`] extractor. //! //! ``` //! use actix_web::*; //! use actix_identity::{Identity, CookieIdentityPolicy, IdentityService}; //! +//! #[get("/")] //! async fn index(id: Identity) -> String { //! // access request identity //! if let Some(id) = id.identity() { @@ -25,114 +21,70 @@ //! } //! } //! +//! #[post("/login")] //! async fn login(id: Identity) -> HttpResponse { //! id.remember("User1".to_owned()); // <- remember identity //! HttpResponse::Ok().finish() //! } //! +//! #[post("/logout")] //! async fn logout(id: Identity) -> HttpResponse { //! id.forget(); // <- remove identity //! HttpResponse::Ok().finish() //! } //! -//! fn main() { -//! let app = App::new().wrap(IdentityService::new( -//! // <- create identity middleware -//! CookieIdentityPolicy::new(&[0; 32]) // <- create cookie identity policy -//! .name("auth-cookie") -//! .secure(false))) -//! .service(web::resource("/index.html").to(index)) -//! .service(web::resource("/login.html").to(login)) -//! .service(web::resource("/logout.html").to(logout)); -//! } +//! // create cookie identity backend +//! let policy = CookieIdentityPolicy::new(&[0; 32]) +//! .name("auth-cookie") +//! .secure(false); +//! +//! let app = App::new() +//! // wrap policy into middleware identity middleware +//! .wrap(IdentityService::new(policy)) +//! .service(services![index, login, logout]); //! ``` -#![deny(rust_2018_idioms)] +#![deny(rust_2018_idioms, nonstandard_style)] -use std::{future::Future, rc::Rc, time::SystemTime}; +use std::future::Future; -use actix_service::{Service, Transform}; -use futures_util::future::{ok, FutureExt, LocalBoxFuture, Ready}; -use serde::{Deserialize, Serialize}; -use time::Duration; +use actix_web::{ + dev::{ServiceRequest, ServiceResponse}, + Error, HttpMessage, Result, +}; -use actix_web::cookie::{Cookie, CookieJar, Key, SameSite}; -use actix_web::dev::{Extensions, Payload, ServiceRequest, ServiceResponse}; -use actix_web::error::{Error, Result}; -use actix_web::http::header::{self, HeaderValue}; -use actix_web::{FromRequest, HttpMessage, HttpRequest}; +mod cookie; +mod identity; +mod middleware; -/// The extractor type to obtain your identity from a request. -/// -/// ```rust -/// use actix_web::*; -/// use actix_identity::Identity; -/// -/// fn index(id: Identity) -> Result { -/// // access request identity -/// if let Some(id) = id.identity() { -/// Ok(format!("Welcome! {}", id)) -/// } else { -/// Ok("Welcome Anonymous!".to_owned()) -/// } -/// } -/// -/// fn login(id: Identity) -> HttpResponse { -/// id.remember("User1".to_owned()); // <- remember identity -/// HttpResponse::Ok().finish() -/// } -/// -/// fn logout(id: Identity) -> HttpResponse { -/// id.forget(); // <- remove identity -/// HttpResponse::Ok().finish() -/// } -/// # fn main() {} -/// ``` -#[derive(Clone)] -pub struct Identity(HttpRequest); +pub use self::cookie::CookieIdentityPolicy; +pub use self::identity::Identity; +pub use self::middleware::IdentityService; -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()) - } +/// Identity policy. +pub trait IdentityPolicy: Sized + 'static { + /// The return type of the middleware + type Future: Future, Error>>; - /// 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; - } - } + /// The return type of the middleware + type ResponseFuture: Future>; - /// 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; - } - } + /// Parse the session from request and load data from a service identity. + fn from_request(&self, req: &mut ServiceRequest) -> Self::Future; - fn get_identity(extensions: &Extensions) -> Option { - if let Some(id) = extensions.get::() { - id.id.clone() - } else { - None - } - } -} - -struct IdentityItem { - id: Option, - changed: bool, + /// 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`. +/// 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; } @@ -146,629 +98,32 @@ where } } -/// Extractor implementation for Identity type. -/// -/// ```rust -/// # use actix_web::*; -/// use actix_identity::Identity; -/// -/// fn index(id: Identity) -> String { -/// // access request identity -/// if let Some(id) = id.identity() { -/// format!("Welcome! {}", id) -/// } else { -/// "Welcome Anonymous!".to_owned() -/// } -/// } -/// # fn main() {} -/// ``` -impl FromRequest for Identity { - type Config = (); - type Error = Error; - type Future = Ready>; - - #[inline] - fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { - ok(Identity(req.clone())) - } -} - -/// Identity policy definition. -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. - fn from_request(&self, request: &mut ServiceRequest) -> Self::Future; - - /// Write changes to response - fn to_response( - &self, - identity: Option, - changed: bool, - response: &mut ServiceResponse, - ) -> Self::ResponseFuture; -} - -/// Request identity middleware -/// -/// ```rust -/// use actix_web::App; -/// use actix_identity::{CookieIdentityPolicy, IdentityService}; -/// -/// let app = App::new().wrap(IdentityService::new( -/// // <- create identity middleware -/// CookieIdentityPolicy::new(&[0; 32]) // <- create cookie session backend -/// .name("auth-cookie") -/// .secure(false), -/// )); -/// ``` -pub struct IdentityService { - backend: Rc, -} - -impl IdentityService { - /// Create new identity service with specified backend. - pub fn new(backend: T) -> Self { - IdentityService { - backend: Rc::new(backend), - } - } -} - -impl Transform for IdentityService -where - S: Service, Error = Error> + 'static, - S::Future: 'static, - T: IdentityPolicy, - B: 'static, -{ - type Response = ServiceResponse; - type Error = Error; - type InitError = (); - type Transform = IdentityServiceMiddleware; - type Future = Ready>; - - fn new_transform(&self, service: S) -> Self::Future { - ok(IdentityServiceMiddleware { - backend: self.backend.clone(), - service: Rc::new(service), - }) - } -} - -#[doc(hidden)] -pub struct IdentityServiceMiddleware { - service: Rc, - backend: Rc, -} - -impl Clone for IdentityServiceMiddleware { - fn clone(&self) -> Self { - Self { - backend: Rc::clone(&self.backend), - service: Rc::clone(&self.service), - } - } -} - -impl Service for IdentityServiceMiddleware -where - B: 'static, - S: Service, Error = Error> + 'static, - S::Future: 'static, - T: IdentityPolicy, -{ - 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 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), - Err(e) => Ok(res.error_response(e)), - } - } else { - Ok(res) - } - } - Err(err) => Ok(req.error_response(err)), - } - } - .boxed_local() - } -} - -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(Deserialize, Serialize, Debug)] -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.iter().chain([1, 0, 0, 0].iter()).cloned().collect(); - 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) - } - }); - let mut cookie = - Cookie::new(self.name.clone(), val.unwrap_or_else(|| Ok(String::new()))?); - 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(&key).add(cookie); - } else { - jar.add_original(cookie.clone()); - jar.private(&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(&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(&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 { - if now.duration_since(value.visit_timestamp?).ok()? > visit_deadline { - return None; - } - } - if let Some(login_deadline) = self.login_deadline { - if now.duration_since(value.login_timestamp?).ok()? > 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. -/// -/// The constructors take a key as an argument. -/// This is the private key for cookie - when this value is changed, -/// all identities are lost. The constructors will panic if the key is less -/// than 32 bytes in length. -/// -/// # Example -/// -/// ```rust -/// use actix_web::App; -/// use actix_identity::{CookieIdentityPolicy, IdentityService}; -/// -/// let app = App::new().wrap(IdentityService::new( -/// // <- create identity middleware -/// CookieIdentityPolicy::new(&[0; 32]) // <- construct cookie policy -/// .domain("www.rust-lang.org") -/// .name("actix_auth") -/// .path("/") -/// .secure(true), -/// )); -/// ``` -pub struct CookieIdentityPolicy(Rc); - -impl CookieIdentityPolicy { - /// Construct new `CookieIdentityPolicy` instance. - /// - /// Panics if key length is less than 32 bytes. - pub fn new(key: &[u8]) -> CookieIdentityPolicy { - CookieIdentityPolicy(Rc::new(CookieIdentityInner::new(key))) - } - - /// Sets the `path` field in the session cookie being built. - pub fn path>(mut self, value: S) -> CookieIdentityPolicy { - 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) -> CookieIdentityPolicy { - 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) -> CookieIdentityPolicy { - Rc::get_mut(&mut self.0).unwrap().domain = Some(value.into()); - 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) -> CookieIdentityPolicy { - Rc::get_mut(&mut self.0).unwrap().secure = value; - self - } - - /// Sets the `max-age` field in the session cookie being built with given number of seconds. - pub fn max_age(self, seconds: i64) -> CookieIdentityPolicy { - self.max_age_time(Duration::seconds(seconds)) - } - - /// Sets the `max-age` field in the session cookie being built with `time::Duration`. - pub fn max_age_time(mut self, value: Duration) -> CookieIdentityPolicy { - Rc::get_mut(&mut self.0).unwrap().max_age = Some(value); - self - } - - /// Sets the `http_only` field in the session cookie being built. - pub fn http_only(mut self, http_only: bool) -> Self { - Rc::get_mut(&mut self.0).unwrap().http_only = Some(http_only); - self - } - - /// Sets the `same_site` field in the session cookie being built. - pub fn same_site(mut self, same_site: SameSite) -> Self { - Rc::get_mut(&mut self.0).unwrap().same_site = Some(same_site); - self - } - - /// Accepts only users whose cookie has been seen before the given deadline - /// - /// By default visit deadline is disabled. - pub fn visit_deadline(mut self, value: Duration) -> CookieIdentityPolicy { - Rc::get_mut(&mut self.0).unwrap().visit_deadline = Some(value); - self - } - - /// Accepts only users which has been authenticated before the given deadline - /// - /// By default login deadline is disabled. - pub fn login_deadline(mut self, value: Duration) -> CookieIdentityPolicy { - Rc::get_mut(&mut self.0).unwrap().login_deadline = Some(value); - self - } -} - -impl IdentityPolicy for CookieIdentityPolicy { - type Future = Ready, Error>>; - type ResponseFuture = Ready>; - - fn from_request(&self, req: &mut ServiceRequest) -> Self::Future { - ok(self.0.load(req).map( - |CookieValue { - identity, - login_timestamp, - .. - }| { - 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: lt, - } = res.request().extensions_mut().remove().unwrap(); - lt - } 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(()) - }; - ok(()) - } -} - #[cfg(test)] mod tests { - use std::borrow::Borrow; + use std::time::SystemTime; + + use actix_web::{dev::ServiceResponse, test, web, App, Error}; use super::*; - use actix_service::into_service; - use actix_web::http::StatusCode; - use actix_web::test::{self, TestRequest}; - use actix_web::{error, web, App, Error, HttpResponse}; - const COOKIE_KEY_MASTER: [u8; 32] = [0; 32]; - const COOKIE_NAME: &str = "actix_auth"; - const COOKIE_LOGIN: &str = "test"; + 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"; - #[actix_rt::test] - async fn test_identity() { - 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)) + #[allow(clippy::enum_variant_names)] + pub(crate) enum LoginTimestampCheck { + NoTimestamp, + NewTimestamp, + OldTimestamp(SystemTime), } - #[actix_rt::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_time(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()); + #[allow(clippy::enum_variant_names)] + pub(crate) enum VisitTimeStampCheck { + NoTimestamp, + NewTimestamp, } - #[actix_rt::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()); - } - - #[actix_rt::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(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()); - } - - async fn create_identity_server< + pub(crate) async fn create_identity_server< F: Fn(CookieIdentityPolicy) -> CookieIdentityPolicy + Sync + Send + Clone + 'static, >( f: F, @@ -794,349 +149,4 @@ mod tests { ) .await } - - fn legacy_login_cookie(identity: &'static str) -> Cookie<'static> { - let mut jar = CookieJar::new(); - jar.private(&Key::derive_from(&COOKIE_KEY_MASTER)) - .add(Cookie::new(COOKIE_NAME, identity)); - jar.get(COOKIE_NAME).unwrap().clone() - } - - 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(&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() - } - - 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(&Key::derive_from(&COOKIE_KEY_MASTER)) - .get(COOKIE_NAME) - .unwrap(); - assert_eq!(cookie.value(), identity); - } - - #[allow(clippy::enum_variant_names)] - enum LoginTimestampCheck { - NoTimestamp, - NewTimestamp, - OldTimestamp(SystemTime), - } - - #[allow(clippy::enum_variant_names)] - enum VisitTimeStampCheck { - NoTimestamp, - NewTimestamp, - } - - 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 - ), - } - } - - 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_rt::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_rt::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_rt::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_rt::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_rt::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_rt::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_rt::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_rt::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_rt::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; - } - - // https://github.com/actix/actix-web/issues/1263 - #[actix_rt::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; - } - - #[actix_rt::test] - async fn test_borrowed_mut_error() { - use futures_util::future::{lazy, ok, Ready}; - - struct Ident; - impl IdentityPolicy for Ident { - type Future = Ready, Error>>; - type ResponseFuture = Ready>; - - fn from_request(&self, _: &mut ServiceRequest) -> Self::Future { - ok(Some("test".to_string())) - } - - fn to_response( - &self, - _: Option, - _: bool, - _: &mut ServiceResponse, - ) -> Self::ResponseFuture { - ok(()) - } - } - - let srv = IdentityServiceMiddleware { - backend: Rc::new(Ident), - service: Rc::new(into_service(|_: ServiceRequest| async move { - actix_rt::time::sleep(std::time::Duration::from_secs(100)).await; - Err::(error::ErrorBadRequest("error")) - })), - }; - - let srv2 = srv.clone(); - let req = TestRequest::default().to_srv_request(); - actix_rt::spawn(async move { - let _ = srv2.call(req).await; - }); - actix_rt::time::sleep(std::time::Duration::from_millis(50)).await; - - let _ = lazy(|cx| srv.poll_ready(cx)).await; - } } diff --git a/actix-identity/src/middleware.rs b/actix-identity/src/middleware.rs new file mode 100644 index 000000000..676d62f57 --- /dev/null +++ b/actix-identity/src/middleware.rs @@ -0,0 +1,168 @@ +use std::rc::Rc; + +use actix_web::{ + dev::{Service, ServiceRequest, ServiceResponse, Transform}, + Error, HttpMessage, Result, +}; +use futures_util::future::{ready, FutureExt, LocalBoxFuture, Ready}; + +use crate::{identity::IdentityItem, IdentityPolicy}; + +/// Request identity middleware +/// +/// ``` +/// 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, +} + +impl IdentityService { + /// Create new identity service with specified backend. + pub fn new(backend: T) -> Self { + IdentityService { + backend: Rc::new(backend), + } + } +} + +impl Transform for IdentityService +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + T: IdentityPolicy, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = IdentityServiceMiddleware; + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(IdentityServiceMiddleware { + backend: self.backend.clone(), + service: Rc::new(service), + })) + } +} + +pub struct IdentityServiceMiddleware { + pub(crate) service: Rc, + pub(crate) backend: Rc, +} + +impl Clone for IdentityServiceMiddleware { + fn clone(&self) -> Self { + Self { + backend: Rc::clone(&self.backend), + service: Rc::clone(&self.service), + } + } +} + +impl Service for IdentityServiceMiddleware +where + B: 'static, + S: Service, Error = Error> + 'static, + S::Future: 'static, + T: IdentityPolicy, +{ + 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 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), + Err(e) => Ok(res.error_response(e)), + } + } else { + Ok(res) + } + } + Err(err) => Ok(req.error_response(err)), + } + } + .boxed_local() + } +} + +#[cfg(test)] +mod tests { + use std::{rc::Rc, time::Duration}; + + use actix_service::into_service; + use actix_web::{dev, error, test, Error, Result}; + + use super::*; + + #[actix_rt::test] + async fn test_borrowed_mut_error() { + use futures_util::future::{lazy, ok, Ready}; + + 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 srv = crate::middleware::IdentityServiceMiddleware { + backend: Rc::new(Ident), + service: Rc::new(into_service(|_: dev::ServiceRequest| async move { + actix_rt::time::sleep(Duration::from_secs(100)).await; + Err::(error::ErrorBadRequest("error")) + })), + }; + + let srv2 = srv.clone(); + let req = test::TestRequest::default().to_srv_request(); + + actix_rt::spawn(async move { + let _ = srv2.call(req).await; + }); + + actix_rt::time::sleep(Duration::from_millis(50)).await; + + let _ = lazy(|cx| srv.poll_ready(cx)).await; + } +} diff --git a/actix-redis/CHANGES.md b/actix-redis/CHANGES.md index 6750cd79b..6a0c92756 100644 --- a/actix-redis/CHANGES.md +++ b/actix-redis/CHANGES.md @@ -7,7 +7,7 @@ ## 0.9.2 - 2020-03-21 * Implement `std::error::Error` for `Error` [#135] -* Allow the removal of Max-Age for session-only cookies. [#161] +* Allow the removal of `Max-Age` for session-only cookies. [#161] [#135]: https://github.com/actix/actix-extras/pull/135 [#161]: https://github.com/actix/actix-extras/pull/161 diff --git a/actix-session/CHANGES.md b/actix-session/CHANGES.md index fff079fd1..58353597a 100644 --- a/actix-session/CHANGES.md +++ b/actix-session/CHANGES.md @@ -8,7 +8,6 @@ ## 0.4.1 - 2020-03-21 * `Session::set_session` takes a `IntoIterator` instead of `Iterator`. [#105] * Fix calls to `session.purge()` from paths other than the one specified in the cookie. [#129] -* Minimum supported Rust version (MSRV) is now 1.46.0. [#105]: https://github.com/actix/actix-extras/pull/105 [#129]: https://github.com/actix/actix-extras/pull/129 diff --git a/actix-web-httpauth/CHANGES.md b/actix-web-httpauth/CHANGES.md index 8c956d5f1..d36a69a22 100644 --- a/actix-web-httpauth/CHANGES.md +++ b/actix-web-httpauth/CHANGES.md @@ -7,7 +7,6 @@ ## 0.5.1 - 2020-03-21 * Correct error handling when extracting auth details from request. [#128] -* Minimum supported Rust version (MSRV) is now 1.46.0. [#128]: https://github.com/actix/actix-extras/pull/128