1
0
mirror of https://github.com/actix/actix-extras.git synced 2024-11-27 17:22:57 +01: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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1155 additions and 1153 deletions

View File

@ -1,7 +1,10 @@
[package] [package]
name = "actix-identity" name = "actix-identity"
version = "0.4.0" version = "0.4.0"
authors = ["Nikolay Kim <fafhrd91@gmail.com>"] authors = [
"Nikolay Kim <fafhrd91@gmail.com>",
"Luca Palmieri <rust@lpalmieri.com>",
]
description = "Identity service for Actix Web" description = "Identity service for Actix Web"
keywords = ["actix", "auth", "identity", "web", "security"] keywords = ["actix", "auth", "identity", "web", "security"]
homepage = "https://actix.rs" homepage = "https://actix.rs"
@ -15,14 +18,21 @@ path = "src/lib.rs"
[dependencies] [dependencies]
actix-service = "2" actix-service = "2"
actix-session = "0.6.2" # update to 0.7 with release of -session
actix-utils = "3" actix-utils = "3"
actix-web = { version = "4", default-features = false, features = ["cookies", "secure-cookies"] } actix-web = { version = "4", default-features = false, features = ["cookies", "secure-cookies"] }
futures-util = { version = "0.3.7", default-features = false } anyhow = "1"
env_logger = "0.9"
futures-core = "0.3.7"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
time = "0.3" time = "0.3"
tracing = { version = "0.1.30", default-features = false, features = ["log"] }
[dev-dependencies] [dev-dependencies]
actix-http = "3.0.0-rc.1" actix-http = "3"
actix-web = { version = "4", default_features = false, features = ["macros", "cookies", "secure-cookies"] } actix-web = { version = "4", default_features = false, features = ["macros", "cookies", "secure-cookies"] }
actix-session = { version = "0.6.2", features = ["redis-rs-session", "cookie-session"] } # update to 0.7 with release of -session
uuid = { version = "1", features = ["v4"] }
reqwest = { version = "0.11", default_features = false, features = ["cookies", "json"] }

View File

@ -0,0 +1,84 @@
//! A rudimentary example of how to set up and use `actix-identity`.
//!
//! ```bash
//! # using HTTPie (https://httpie.io/cli)
//!
//! # outputs "Welcome Anonymous!" message
//! http -v --session=identity GET localhost:8080/
//!
//! # log in using fake details, ensuring that --session is used to persist cookies
//! http -v --session=identity POST localhost:8080/login user_id=foo
//!
//! # outputs "Welcome User1" message
//! http -v --session=identity GET localhost:8080/
//! ```
use std::io;
use actix_identity::{Identity, IdentityMiddleware};
use actix_session::{storage::CookieSessionStore, SessionMiddleware};
use actix_web::{
cookie::Key, get, middleware::Logger, post, App, HttpMessage, HttpRequest, HttpResponse,
HttpServer, Responder,
};
#[actix_web::main]
async fn main() -> io::Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
let secret_key = Key::generate();
HttpServer::new(move || {
let session_mw =
SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone())
// disable secure cookie for local testing
.cookie_secure(false)
.build();
App::new()
// Install the identity framework first.
.wrap(IdentityMiddleware::default())
// The identity system is built on top of sessions. You must install the session
// middleware to leverage `actix-identity`. The session middleware must be mounted
// AFTER the identity middleware: `actix-web` invokes middleware in the OPPOSITE
// order of registration when it receives an incoming request.
.wrap(session_mw)
.wrap(Logger::default())
.service(index)
.service(login)
.service(logout)
})
.bind(("127.0.0.1", 8080))
.unwrap()
.workers(2)
.run()
.await
}
#[get("/")]
async fn index(user: Option<Identity>) -> impl Responder {
if let Some(user) = user {
format!("Welcome! {}", user.id().unwrap())
} else {
"Welcome Anonymous!".to_owned()
}
}
#[post("/login")]
async fn login(request: HttpRequest) -> impl Responder {
// Some kind of authentication should happen here -
// e.g. password-based, biometric, etc.
// [...]
// Attached a verified user identity to the active
// session.
Identity::login(&request.extensions(), "User1".into()).unwrap();
HttpResponse::Ok()
}
#[post("/logout")]
async fn logout(user: Identity) -> impl Responder {
user.logout();
HttpResponse::NoContent()
}

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_utils::future::{ready, Ready};
use actix_web::{ use actix_web::{
cookie::time::OffsetDateTime,
dev::{Extensions, Payload}, dev::{Extensions, Payload},
Error, FromRequest, HttpMessage as _, HttpRequest, http::StatusCode,
Error, FromRequest, HttpMessage, HttpRequest, HttpResponse,
}; };
use anyhow::{anyhow, Context};
pub(crate) struct IdentityItem { use crate::config::LogoutBehaviour;
pub(crate) id: Option<String>,
pub(crate) changed: bool,
}
/// 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; /// use actix_identity::Identity;
/// ///
/// #[get("/")] /// #[get("/")]
/// async fn index(id: Identity) -> impl Responder { /// async fn index(user: Option<Identity>) -> impl Responder {
/// // access request identity /// if let Some(user) = user {
/// if let Some(id) = id.identity() { /// format!("Welcome! {}", user.id().unwrap())
/// format!("Welcome! {}", id)
/// } else { /// } else {
/// "Welcome Anonymous!".to_owned() /// "Welcome Anonymous!".to_owned()
/// } /// }
/// } /// }
/// ///
/// #[post("/login")] /// #[post("/login")]
/// async fn login(id: Identity) -> impl Responder { /// async fn login(request: HttpRequest) -> impl Responder {
/// // remember identity /// Identity::login(&request.extensions(), "User1".into());
/// id.remember("User1".to_owned());
///
/// HttpResponse::Ok() /// HttpResponse::Ok()
/// } /// }
/// ///
/// #[post("/logout")] /// #[post("/logout")]
/// async fn logout(id: Identity) -> impl Responder { /// async fn logout(user: Identity) -> impl Responder {
/// // remove identity /// user.logout();
/// id.forget();
///
/// HttpResponse::Ok() /// 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; /// use actix_identity::Identity;
/// ///
/// #[get("/")] /// #[get("/")]
/// async fn index(id: Identity) -> impl Responder { /// async fn index(user: Option<Identity>) -> impl Responder {
/// // access request identity /// if let Some(user) = user {
/// if let Some(id) = id.identity() { /// HttpResponse::Ok().finish()
/// format!("Welcome! {}", id) /// } 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 { /// } else {
/// "Welcome Anonymous!".to_owned() /// "Welcome Anonymous!".to_owned()
/// } /// }
@ -91,10 +240,17 @@ impl Identity {
/// ``` /// ```
impl FromRequest for Identity { impl FromRequest for Identity {
type Error = Error; type Error = Error;
type Future = Ready<Result<Identity, Error>>; type Future = Ready<Result<Self, Self::Error>>;
#[inline] #[inline]
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { 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 //! `actix-identity` can be used to track identity of a user across multiple requests. It is built
//! identity information. //! 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("/")] //! #[get("/")]
//! async fn index(id: Identity) -> String { //! async fn index(user: Option<Identity>) -> impl Responder {
//! // access request identity //! if let Some(user) = user {
//! if let Some(id) = id.identity() { //! format!("Welcome! {}", user.id().unwrap())
//! format!("Welcome! {}", id)
//! } else { //! } else {
//! "Welcome Anonymous!".to_owned() //! "Welcome Anonymous!".to_owned()
//! } //! }
//! } //! }
//! //!
//! #[post("/login")] //! #[post("/login")]
//! async fn login(id: Identity) -> HttpResponse { //! async fn login(request: HttpRequest) -> impl Responder {
//! // remember identity //! // Some kind of authentication should happen here
//! id.remember("User1".to_owned()); //! // e.g. password-based, biometric, etc.
//! HttpResponse::Ok().finish() //! // [...]
//!
//! // attach a verified user identity to the active session
//! Identity::login(&request.extensions(), "User1".into()).unwrap();
//!
//! HttpResponse::Ok()
//! } //! }
//! //!
//! #[post("/logout")] //! #[post("/logout")]
//! async fn logout(id: Identity) -> HttpResponse { //! async fn logout(user: Identity) -> impl Responder {
//! // remove identity //! user.logout();
//! id.forget(); //! HttpResponse::Ok()
//! HttpResponse::Ok().finish()
//! } //! }
//!
//! 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)] #![warn(future_incompatible)]
use std::future::Future; pub mod config;
use actix_web::{
dev::{ServiceRequest, ServiceResponse},
Error, HttpMessage, Result,
};
mod cookie;
mod identity; mod identity;
mod identity_ext;
mod middleware; mod middleware;
pub use self::cookie::CookieIdentityPolicy;
pub use self::identity::Identity; pub use self::identity::Identity;
pub use self::middleware::IdentityService; pub use self::identity_ext::IdentityExt;
pub use self::middleware::IdentityMiddleware;
/// 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
}
}

View File

@ -1,171 +1,251 @@
use std::rc::Rc; use std::rc::Rc;
use actix_session::SessionExt;
use actix_utils::future::{ready, Ready}; use actix_utils::future::{ready, Ready};
use actix_web::{ use actix_web::{
body::{EitherBody, MessageBody}, body::MessageBody,
cookie::time::format_description::well_known::Rfc3339,
dev::{Service, ServiceRequest, ServiceResponse, Transform}, 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; #[derive(Default, Clone)]
/// use actix_identity::{CookieIdentityPolicy, IdentityService}; pub struct IdentityMiddleware {
/// configuration: Rc<Configuration>,
/// // 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>,
} }
impl<T> IdentityService<T> { impl IdentityMiddleware {
/// Create new identity service with specified backend. pub(crate) fn new(configuration: Configuration) -> Self {
pub fn new(backend: T) -> Self { Self {
IdentityService { configuration: Rc::new(configuration),
backend: Rc::new(backend),
}
} }
} }
impl<S, T, B> Transform<S, ServiceRequest> for IdentityService<T> /// A fluent API to configure [`IdentityMiddleware`].
pub fn builder() -> IdentityMiddlewareBuilder {
IdentityMiddlewareBuilder::new()
}
}
impl<S, B> Transform<S, ServiceRequest> for IdentityMiddleware
where where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static, S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static, S::Future: 'static,
T: IdentityPolicy,
B: MessageBody + 'static, B: MessageBody + 'static,
{ {
type Response = ServiceResponse<EitherBody<B>>; type Response = ServiceResponse<B>;
type Error = Error; type Error = Error;
type Transform = InnerIdentityMiddleware<S>;
type InitError = (); type InitError = ();
type Transform = IdentityServiceMiddleware<S, T>;
type Future = Ready<Result<Self::Transform, Self::InitError>>; type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future { fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(IdentityServiceMiddleware { ready(Ok(InnerIdentityMiddleware {
backend: self.backend.clone(),
service: Rc::new(service), service: Rc::new(service),
configuration: Rc::clone(&self.configuration),
})) }))
} }
} }
pub struct IdentityServiceMiddleware<S, T> { #[doc(hidden)]
pub(crate) service: Rc<S>, pub struct InnerIdentityMiddleware<S> {
pub(crate) backend: Rc<T>, service: Rc<S>,
configuration: Rc<Configuration>,
} }
impl<S, T> Clone for IdentityServiceMiddleware<S, T> { impl<S> Clone for InnerIdentityMiddleware<S> {
fn clone(&self) -> Self { fn clone(&self) -> Self {
Self { Self {
backend: Rc::clone(&self.backend),
service: Rc::clone(&self.service), 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 where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static, S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static, S::Future: 'static,
T: IdentityPolicy,
B: MessageBody + 'static, B: MessageBody + 'static,
{ {
type Response = ServiceResponse<EitherBody<B>>; type Response = ServiceResponse<B>;
type Error = Error; type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>; type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
actix_service::forward_ready!(service); 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 srv = Rc::clone(&self.service);
let backend = Rc::clone(&self.backend); let configuration = Rc::clone(&self.configuration);
let fut = self.backend.from_request(&mut req); Box::pin(async move {
let identity_inner = IdentityInner {
async move { session: req.get_session(),
match fut.await { logout_behaviour: configuration.on_logout.clone(),
Ok(id) => { is_login_deadline_enabled: configuration.login_deadline.is_some(),
req.extensions_mut() is_visit_deadline_enabled: configuration.visit_deadline.is_some(),
.insert(IdentityItem { id, changed: false }); };
req.extensions_mut().insert(identity_inner);
let mut res = srv.call(req).await?; enforce_policies(&req, &configuration);
let id = res.request().extensions_mut().remove::<IdentityItem>(); srv.call(req).await
})
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()
} }
} }
#[cfg(test)] // easier to scan with returns where they are
mod tests { // especially if the function body were to evolve in the future
use std::{rc::Rc, time::Duration}; #[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; if !must_extract_identity {
use actix_web::{dev, error, test, Error, Result}; 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>( let identity = match Identity::extract(&req.extensions()) {
&self, Ok(identity) => identity,
_: Option<String>, Err(err) => {
_: bool, tracing::debug!(
_: &mut dev::ServiceResponse<B>, error.display = %err,
) -> Self::ResponseFuture { error.debug = ?err,
ok(()) "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"))
})),
}; };
let srv2 = srv.clone(); if let Some(login_deadline) = configuration.login_deadline {
let req = test::TestRequest::default().to_srv_request(); if matches!(
enforce_login_deadline(&identity, login_deadline),
actix_web::rt::spawn(async move { PolicyDecision::LogOut
let _ = srv2.call(req).await; ) {
}); identity.logout();
return;
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,
}

View File

@ -0,0 +1,17 @@
use actix_session::{storage::CookieSessionStore, SessionMiddleware};
use actix_web::cookie::Key;
use uuid::Uuid;
pub fn store() -> CookieSessionStore {
CookieSessionStore::default()
}
pub fn user_id() -> String {
Uuid::new_v4().to_string()
}
pub fn session_middleware() -> SessionMiddleware<CookieSessionStore> {
SessionMiddleware::builder(store(), Key::generate())
.cookie_domain(Some("localhost".into()))
.build()
}

View File

@ -0,0 +1,168 @@
use std::time::Duration;
use actix_identity::{config::LogoutBehaviour, IdentityMiddleware};
use actix_web::http::StatusCode;
use crate::{fixtures::user_id, test_app::TestApp};
#[actix_web::test]
async fn opaque_401_is_returned_for_unauthenticated_users() {
let app = TestApp::spawn();
let response = app.get_identity_required().await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(response.bytes().await.unwrap().is_empty());
}
#[actix_web::test]
async fn login_works() {
let app = TestApp::spawn();
let user_id = user_id();
// Log-in
let body = app.post_login(user_id.clone()).await;
assert_eq!(body.user_id, Some(user_id.clone()));
// Access identity-restricted route successfully
let response = app.get_identity_required().await;
assert!(response.status().is_success());
}
#[actix_web::test]
async fn logging_in_again_replaces_the_current_identity() {
let app = TestApp::spawn();
let first_user_id = user_id();
let second_user_id = user_id();
// Log-in
let body = app.post_login(first_user_id.clone()).await;
assert_eq!(body.user_id, Some(first_user_id.clone()));
// Log-in again
let body = app.post_login(second_user_id.clone()).await;
assert_eq!(body.user_id, Some(second_user_id.clone()));
let body = app.get_current().await;
assert_eq!(body.user_id, Some(second_user_id.clone()));
}
#[actix_web::test]
async fn session_key_is_renewed_on_login() {
let app = TestApp::spawn();
let user_id = user_id();
// Create an anonymous session
let body = app.post_increment().await;
assert_eq!(body.user_id, None);
assert_eq!(body.counter, 1);
assert_eq!(body.session_status, "changed");
// Log-in
let body = app.post_login(user_id.clone()).await;
assert_eq!(body.user_id, Some(user_id.clone()));
assert_eq!(body.counter, 1);
assert_eq!(body.session_status, "renewed");
}
#[actix_web::test]
async fn logout_works() {
let app = TestApp::spawn();
let user_id = user_id();
// Log-in
let body = app.post_login(user_id.clone()).await;
assert_eq!(body.user_id, Some(user_id.clone()));
// Log-out
let response = app.post_logout().await;
assert!(response.status().is_success());
// Try to access identity-restricted route
let response = app.get_identity_required().await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[actix_web::test]
async fn logout_can_avoid_destroying_the_whole_session() {
let app = TestApp::spawn_with_config(
IdentityMiddleware::builder().logout_behaviour(LogoutBehaviour::DeleteIdentityKeys),
);
let user_id = user_id();
// Log-in
let body = app.post_login(user_id.clone()).await;
assert_eq!(body.user_id, Some(user_id.clone()));
assert_eq!(body.counter, 0);
// Increment counter
let body = app.post_increment().await;
assert_eq!(body.user_id, Some(user_id.clone()));
assert_eq!(body.counter, 1);
// Log-out
let response = app.post_logout().await;
assert!(response.status().is_success());
// Check the state of the counter attached to the session state
let body = app.get_current().await;
assert_eq!(body.user_id, None);
// It would be 0 if the session state had been entirely lost!
assert_eq!(body.counter, 1);
}
#[actix_web::test]
async fn user_is_logged_out_when_login_deadline_is_elapsed() {
let login_deadline = Duration::from_millis(10);
let app = TestApp::spawn_with_config(
IdentityMiddleware::builder().login_deadline(Some(login_deadline)),
);
let user_id = user_id();
// Log-in
let body = app.post_login(user_id.clone()).await;
assert_eq!(body.user_id, Some(user_id.clone()));
// Wait for deadline to pass
actix_web::rt::time::sleep(login_deadline * 2).await;
let body = app.get_current().await;
// We have been logged out!
assert_eq!(body.user_id, None);
}
#[actix_web::test]
async fn login_deadline_does_not_log_users_out_before_their_time() {
// 1 hour
let login_deadline = Duration::from_secs(60 * 60);
let app = TestApp::spawn_with_config(
IdentityMiddleware::builder().login_deadline(Some(login_deadline)),
);
let user_id = user_id();
// Log-in
let body = app.post_login(user_id.clone()).await;
assert_eq!(body.user_id, Some(user_id.clone()));
let body = app.get_current().await;
assert_eq!(body.user_id, Some(user_id));
}
#[actix_web::test]
async fn user_is_logged_out_when_visit_deadline_is_elapsed() {
let visit_deadline = Duration::from_millis(10);
let app = TestApp::spawn_with_config(
IdentityMiddleware::builder().visit_deadline(Some(visit_deadline)),
);
let user_id = user_id();
// Log-in
let body = app.post_login(user_id.clone()).await;
assert_eq!(body.user_id, Some(user_id.clone()));
// Wait for deadline to pass
actix_web::rt::time::sleep(visit_deadline * 2).await;
let body = app.get_current().await;
// We have been logged out!
assert_eq!(body.user_id, None);
}

View File

@ -0,0 +1,3 @@
pub mod fixtures;
mod integration;
pub mod test_app;

View File

@ -0,0 +1,186 @@
use std::net::TcpListener;
use actix_identity::{config::IdentityMiddlewareBuilder, Identity, IdentityMiddleware};
use actix_session::{Session, SessionStatus};
use actix_web::{web, App, HttpMessage, HttpRequest, HttpResponse, HttpServer};
use serde::{Deserialize, Serialize};
use crate::fixtures::session_middleware;
pub struct TestApp {
port: u16,
api_client: reqwest::Client,
}
impl TestApp {
/// Spawn a test application using a custom configuration for `IdentityMiddleware`.
pub fn spawn_with_config(builder: IdentityMiddlewareBuilder) -> Self {
// Random OS port
let listener = TcpListener::bind("localhost:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = HttpServer::new(move || {
App::new()
.wrap(builder.clone().build())
.wrap(session_middleware())
.route("/increment", web::post().to(increment))
.route("/current", web::get().to(show))
.route("/login", web::post().to(login))
.route("/logout", web::post().to(logout))
.route("/identity_required", web::get().to(identity_required))
})
.workers(1)
.listen(listener)
.unwrap()
.run();
let _ = actix_web::rt::spawn(server);
let client = reqwest::Client::builder()
.cookie_store(true)
.build()
.unwrap();
TestApp {
port,
api_client: client,
}
}
/// Spawn a test application using the default configuration settings for `IdentityMiddleware`.
pub fn spawn() -> Self {
Self::spawn_with_config(IdentityMiddleware::builder())
}
fn url(&self) -> String {
format!("http://localhost:{}", self.port)
}
pub async fn get_identity_required(&self) -> reqwest::Response {
self.api_client
.get(format!("{}/identity_required", &self.url()))
.send()
.await
.unwrap()
}
pub async fn get_current(&self) -> EndpointResponse {
self.api_client
.get(format!("{}/current", &self.url()))
.send()
.await
.unwrap()
.json()
.await
.unwrap()
}
pub async fn post_increment(&self) -> EndpointResponse {
let response = self
.api_client
.post(format!("{}/increment", &self.url()))
.send()
.await
.unwrap();
response.json().await.unwrap()
}
pub async fn post_login(&self, user_id: String) -> EndpointResponse {
let response = self
.api_client
.post(format!("{}/login", &self.url()))
.json(&LoginRequest { user_id })
.send()
.await
.unwrap();
response.json().await.unwrap()
}
pub async fn post_logout(&self) -> reqwest::Response {
self.api_client
.post(format!("{}/logout", &self.url()))
.send()
.await
.unwrap()
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct EndpointResponse {
pub user_id: Option<String>,
pub counter: i32,
pub session_status: String,
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct LoginRequest {
user_id: String,
}
async fn show(user: Option<Identity>, session: Session) -> HttpResponse {
let user_id = user.map(|u| u.id().unwrap());
let counter: i32 = session
.get::<i32>("counter")
.unwrap_or(Some(0))
.unwrap_or(0);
HttpResponse::Ok().json(&EndpointResponse {
user_id,
counter,
session_status: session_status(session),
})
}
async fn increment(session: Session, user: Option<Identity>) -> HttpResponse {
let user_id = user.map(|u| u.id().unwrap());
let counter: i32 = session
.get::<i32>("counter")
.unwrap_or(Some(0))
.map_or(1, |inner| inner + 1);
session.insert("counter", &counter).unwrap();
HttpResponse::Ok().json(&EndpointResponse {
user_id,
counter,
session_status: session_status(session),
})
}
async fn login(
user_id: web::Json<LoginRequest>,
request: HttpRequest,
session: Session,
) -> HttpResponse {
let id = user_id.into_inner().user_id;
let user = Identity::login(&request.extensions(), id).unwrap();
let counter: i32 = session
.get::<i32>("counter")
.unwrap_or(Some(0))
.unwrap_or(0);
HttpResponse::Ok().json(&EndpointResponse {
user_id: Some(user.id().unwrap()),
counter,
session_status: session_status(session),
})
}
async fn logout(user: Option<Identity>) -> HttpResponse {
if let Some(user) = user {
user.logout();
}
HttpResponse::Ok().finish()
}
async fn identity_required(_identity: Identity) -> HttpResponse {
HttpResponse::Ok().finish()
}
fn session_status(session: Session) -> String {
match session.status() {
SessionStatus::Changed => "changed",
SessionStatus::Purged => "purged",
SessionStatus::Renewed => "renewed",
SessionStatus::Unchanged => "unchanged",
}
.into()
}

View File

@ -1,6 +1,6 @@
# Changes # Changes
## Unreleased - 2022-xx-xx ## Unreleased - 2021-xx-xx
- Added `TtlExtensionPolicy` enum to support different strategies for extending the TTL attached to the session state. `TtlExtensionPolicy::OnEveryRequest` now allows for long-lived sessions that do not expire if the user remains active. [#233] - Added `TtlExtensionPolicy` enum to support different strategies for extending the TTL attached to the session state. `TtlExtensionPolicy::OnEveryRequest` now allows for long-lived sessions that do not expire if the user remains active. [#233]
- `SessionLength` is now called `SessionLifecycle`. [#233] - `SessionLength` is now called `SessionLifecycle`. [#233]
- `SessionLength::Predetermined` is now called `SessionLifecycle::PersistentSession`. [#233] - `SessionLength::Predetermined` is now called `SessionLifecycle::PersistentSession`. [#233]

View File

@ -146,7 +146,7 @@ mod session_ext;
pub mod storage; pub mod storage;
pub use self::middleware::SessionMiddleware; pub use self::middleware::SessionMiddleware;
pub use self::session::{Session, SessionStatus}; pub use self::session::{Session, SessionGetError, SessionInsertError, SessionStatus};
pub use self::session_ext::SessionExt; pub use self::session_ext::SessionExt;
#[cfg(test)] #[cfg(test)]

View File

@ -1,16 +1,20 @@
use std::{ use std::{
cell::{Ref, RefCell}, cell::{Ref, RefCell},
collections::HashMap, collections::HashMap,
error::Error as StdError,
mem, mem,
rc::Rc, rc::Rc,
}; };
use actix_utils::future::{ready, Ready}; use actix_utils::future::{ready, Ready};
use actix_web::{ use actix_web::{
body::BoxBody,
dev::{Extensions, Payload, ServiceRequest, ServiceResponse}, dev::{Extensions, Payload, ServiceRequest, ServiceResponse},
error::Error, error::Error,
FromRequest, HttpMessage, HttpRequest, FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError,
}; };
use anyhow::Context;
use derive_more::{Display, From};
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
/// The primary interface to access and modify session state. /// The primary interface to access and modify session state.
@ -38,6 +42,7 @@ use serde::{de::DeserializeOwned, Serialize};
/// [`SessionExt`]. /// [`SessionExt`].
/// ///
/// [`SessionExt`]: crate::SessionExt /// [`SessionExt`]: crate::SessionExt
#[derive(Clone)]
pub struct Session(Rc<RefCell<SessionInner>>); pub struct Session(Rc<RefCell<SessionInner>>);
/// Status of a [`Session`]. /// Status of a [`Session`].
@ -78,9 +83,20 @@ impl Session {
/// Get a `value` from the session. /// Get a `value` from the session.
/// ///
/// It returns an error if it fails to deserialize as `T` the JSON value associated with `key`. /// It returns an error if it fails to deserialize as `T` the JSON value associated with `key`.
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, serde_json::Error> { pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, SessionGetError> {
if let Some(val_str) = self.0.borrow().state.get(key) { if let Some(val_str) = self.0.borrow().state.get(key) {
Ok(Some(serde_json::from_str(val_str)?)) Ok(Some(
serde_json::from_str(val_str)
.with_context(|| {
format!(
"Failed to deserialize the JSON-encoded session data attached to key \
`{}` as a `{}` type",
key,
std::any::type_name::<T>()
)
})
.map_err(SessionGetError)?,
))
} else { } else {
Ok(None) Ok(None)
} }
@ -104,17 +120,29 @@ impl Session {
/// only a reference to the value is taken. /// only a reference to the value is taken.
/// ///
/// It returns an error if it fails to serialize `value` to JSON. /// It returns an error if it fails to serialize `value` to JSON.
pub fn insert( pub fn insert<T: Serialize>(
&self, &self,
key: impl Into<String>, key: impl Into<String>,
value: impl Serialize, value: T,
) -> Result<(), serde_json::Error> { ) -> Result<(), SessionInsertError> {
let mut inner = self.0.borrow_mut(); let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged { if inner.status != SessionStatus::Purged {
inner.status = SessionStatus::Changed; inner.status = SessionStatus::Changed;
let val = serde_json::to_string(&value)?;
inner.state.insert(key.into(), val); let key = key.into();
let val = serde_json::to_string(&value)
.with_context(|| {
format!(
"Failed to serialize the provided `{}` type instance as JSON in order to \
attach as session data to the `{}` key",
std::any::type_name::<T>(),
&key
)
})
.map_err(SessionInsertError)?;
inner.state.insert(key, val);
} }
Ok(()) Ok(())
@ -136,7 +164,7 @@ impl Session {
/// Remove value from the session and deserialize. /// Remove value from the session and deserialize.
/// ///
/// Returns None if key was not present in session. Returns `T` if deserialization succeeds, /// Returns `None` if key was not present in session. Returns `T` if deserialization succeeds,
/// otherwise returns un-deserialized JSON string. /// otherwise returns un-deserialized JSON string.
pub fn remove_as<T: DeserializeOwned>(&self, key: &str) -> Option<Result<T, String>> { pub fn remove_as<T: DeserializeOwned>(&self, key: &str) -> Option<Result<T, String>> {
self.remove(key) self.remove(key)
@ -144,7 +172,7 @@ impl Session {
Ok(val) => Ok(val), Ok(val) => Ok(val),
Err(_err) => { Err(_err) => {
tracing::debug!( tracing::debug!(
"removed value (key: {}) could not be deserialized as {}", "Removed value (key: {}) could not be deserialized as {}",
key, key,
std::any::type_name::<T>() std::any::type_name::<T>()
); );
@ -195,9 +223,9 @@ impl Session {
/// Returns session status and iterator of key-value pairs of changes. /// Returns session status and iterator of key-value pairs of changes.
/// ///
/// This is a destructive operation - the session state is removed from the request extensions typemap, /// This is a destructive operation - the session state is removed from the request extensions
/// leaving behind a new empty map. It should only be used when the session is being finalised (i.e. /// typemap, leaving behind a new empty map. It should only be used when the session is being
/// in `SessionMiddleware`). /// finalised (i.e. in `SessionMiddleware`).
pub(crate) fn get_changes<B>( pub(crate) fn get_changes<B>(
res: &mut ServiceResponse<B>, res: &mut ServiceResponse<B>,
) -> (SessionStatus, HashMap<String, String>) { ) -> (SessionStatus, HashMap<String, String>) {
@ -254,3 +282,37 @@ impl FromRequest for Session {
ready(Ok(Session::get_session(&mut *req.extensions_mut()))) ready(Ok(Session::get_session(&mut *req.extensions_mut())))
} }
} }
/// Error returned by [`Session::get`].
#[derive(Debug, Display, From)]
#[display(fmt = "{}", _0)]
pub struct SessionGetError(anyhow::Error);
impl StdError for SessionGetError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(self.0.as_ref())
}
}
impl ResponseError for SessionGetError {
fn error_response(&self) -> HttpResponse<BoxBody> {
HttpResponse::new(self.status_code())
}
}
/// Error returned by [`Session::insert`].
#[derive(Debug, Display, From)]
#[display(fmt = "{}", _0)]
pub struct SessionInsertError(anyhow::Error);
impl StdError for SessionInsertError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(self.0.as_ref())
}
}
impl ResponseError for SessionInsertError {
fn error_response(&self) -> HttpResponse<BoxBody> {
HttpResponse::new(self.status_code())
}
}