1
0
mirror of https://github.com/actix/actix-extras.git synced 2025-06-26 18:37:41 +02:00

Rebuild actix-identity on top of actix-session (#246)

Co-authored-by: Rob Ede <robjtede@icloud.com>
This commit is contained in:
Luca Palmieri
2022-07-09 19:00:15 +01:00
committed by GitHub
parent b8f4a658a9
commit b1cea64795
15 changed files with 1155 additions and 1153 deletions

View File

@ -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<Duration>,
pub(crate) visit_deadline: Option<Duration>,
}
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<Duration>) -> 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<Duration>) -> Self {
self.configuration.visit_deadline = deadline;
self
}
/// Finalises the builder and returns an [`IdentityMiddleware`] instance.
pub fn build(self) -> IdentityMiddleware {
IdentityMiddleware::new(self.configuration)
}
}

View File

@ -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<String>,
secure: bool,
max_age: Option<Duration>,
http_only: Option<bool>,
same_site: Option<SameSite>,
visit_deadline: Option<Duration>,
login_deadline: Option<Duration>,
}
#[derive(Debug, Deserialize, Serialize)]
struct CookieValue {
identity: String,
#[serde(skip_serializing_if = "Option::is_none")]
login_timestamp: Option<SystemTime>,
#[serde(skip_serializing_if = "Option::is_none")]
visit_timestamp: Option<SystemTime>,
}
#[derive(Debug)]
struct CookieIdentityExtension {
login_timestamp: Option<SystemTime>,
}
impl CookieIdentityInner {
fn new(key: &[u8]) -> CookieIdentityInner {
let key_v2: Vec<u8> = [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<B>(
&self,
resp: &mut ServiceResponse<B>,
value: Option<CookieValue>,
) -> 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<CookieValue> {
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<CookieValue> {
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<CookieIdentityInner>);
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<String>) -> CookieIdentityPolicy {
self.inner_mut().name = value.into();
self
}
/// Sets the `Path` attribute of issued cookies.
pub fn path(mut self, value: impl Into<String>) -> CookieIdentityPolicy {
self.inner_mut().path = value.into();
self
}
/// Sets the `Domain` attribute of issued cookies.
pub fn domain(mut self, value: impl Into<String>) -> 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<Result<Option<String>, Error>>;
type ResponseFuture = Ready<Result<(), Error>>;
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<B>(
&self,
id: Option<String>,
changed: bool,
res: &mut ServiceResponse<B>,
) -> 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<SystemTime>,
visit_timestamp: Option<SystemTime>,
) -> Cookie<'static> {
let mut jar = CookieJar::new();
let key: Vec<u8> = 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<EitherBody<BoxBody>>,
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<u8> = 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<EitherBody<BoxBody>>,
identity: Option<&str>,
) {
let bytes = test::read_body(response).await;
let resp: Option<String> = serde_json::from_slice(&bytes[..]).unwrap();
assert_eq!(resp.as_ref().map(|s| s.borrow()), identity);
}
fn assert_legacy_login_cookie(
response: &mut ServiceResponse<EitherBody<BoxBody>>,
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<EitherBody<BoxBody>>) {
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;
}
}

View File

@ -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<String>,
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<Identity>) -> 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<String> {
Identity::get_identity(&self.0.extensions())
}
/// Remember identity.
pub fn remember(&self, identity: String) {
if let Some(id) = self.0.extensions_mut().get_mut::<IdentityItem>() {
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::<IdentityItem>() {
id.id = None;
id.changed = true;
}
}
pub(crate) fn get_identity(extensions: &Extensions) -> Option<String> {
let id = extensions.get::<IdentityItem>()?;
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<Identity>` or
/// `Result<Identity, actix_web::Error>` 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<Identity>) -> 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::<Self>()
.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<String, anyhow::Error> {
self.session
.get::<String>(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<Identity>) -> impl Responder {
/// if let Some(user) = user {
/// format!("Welcome! {}", user.id().unwrap())
/// } else {
/// "Welcome Anonymous!".to_owned()
/// }
/// }
/// ```
pub fn id(&self) -> Result<String, anyhow::Error> {
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<Self, anyhow::Error> {
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<Self, anyhow::Error> {
let inner = IdentityInner::extract(ext);
inner.get_identity()?;
Ok(Self(inner))
}
pub(crate) fn logged_at(&self) -> Result<Option<OffsetDateTime>, 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<Option<OffsetDateTime>, 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<Identity>) -> 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<Result<Identity, Error>>;
type Future = Ready<Result<Self, Self::Error>>;
#[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)
}))
}
}

View File

@ -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<Identity, anyhow::Error>;
}
impl IdentityExt for HttpRequest {
fn get_identity(&self) -> Result<Identity, anyhow::Error> {
Identity::extract(&self.extensions())
}
}
impl IdentityExt for ServiceRequest {
fn get_identity(&self) -> Result<Identity, anyhow::Error> {
Identity::extract(&self.extensions())
}
}
impl<'a> IdentityExt for GuardContext<'a> {
fn get_identity(&self) -> Result<Identity, anyhow::Error> {
Identity::extract(&self.req_data())
}
}

View File

@ -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<Identity>) -> 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<Output = Result<Option<String>, Error>>;
/// The return type of the middleware
type ResponseFuture: Future<Output = Result<(), Error>>;
/// 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<B>(
&self,
identity: Option<String>,
changed: bool,
response: &mut ServiceResponse<B>,
) -> 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<String>;
}
impl<T> RequestIdentity for T
where
T: HttpMessage,
{
fn get_identity(&self) -> Option<String> {
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<EitherBody<BoxBody>>,
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;

View File

@ -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<T> {
backend: Rc<T>,
#[derive(Default, Clone)]
pub struct IdentityMiddleware {
configuration: Rc<Configuration>,
}
impl<T> IdentityService<T> {
/// 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<S, T, B> Transform<S, ServiceRequest> for IdentityService<T>
impl<S, B> Transform<S, ServiceRequest> for IdentityMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
T: IdentityPolicy,
B: MessageBody + 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Response = ServiceResponse<B>;
type Error = Error;
type Transform = InnerIdentityMiddleware<S>;
type InitError = ();
type Transform = IdentityServiceMiddleware<S, T>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
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<S, T> {
pub(crate) service: Rc<S>,
pub(crate) backend: Rc<T>,
#[doc(hidden)]
pub struct InnerIdentityMiddleware<S> {
service: Rc<S>,
configuration: Rc<Configuration>,
}
impl<S, T> Clone for IdentityServiceMiddleware<S, T> {
impl<S> Clone for InnerIdentityMiddleware<S> {
fn clone(&self) -> Self {
Self {
backend: Rc::clone(&self.backend),
service: Rc::clone(&self.service),
configuration: Rc::clone(&self.configuration),
}
}
}
impl<S, T, B> Service<ServiceRequest> for IdentityServiceMiddleware<S, T>
impl<S, B> Service<ServiceRequest> for InnerIdentityMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
T: IdentityPolicy,
B: MessageBody + 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
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::<IdentityItem>();
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<Result<Option<String>, Error>>;
type ResponseFuture = Ready<Result<(), Error>>;
fn from_request(&self, _: &mut dev::ServiceRequest) -> Self::Future {
ok(Some("test".to_string()))
}
fn to_response<B>(
&self,
_: Option<String>,
_: bool,
_: &mut dev::ServiceResponse<B>,
) -> 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::<dev::ServiceResponse, _>(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,
}