mirror of
https://github.com/actix/actix-extras.git
synced 2024-11-27 09:12: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:
parent
b8f4a658a9
commit
b1cea64795
@ -1,7 +1,10 @@
|
||||
[package]
|
||||
name = "actix-identity"
|
||||
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"
|
||||
keywords = ["actix", "auth", "identity", "web", "security"]
|
||||
homepage = "https://actix.rs"
|
||||
@ -15,14 +18,21 @@ path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
actix-service = "2"
|
||||
actix-session = "0.6.2" # update to 0.7 with release of -session
|
||||
actix-utils = "3"
|
||||
actix-web = { version = "4", default-features = false, features = ["cookies", "secure-cookies"] }
|
||||
|
||||
futures-util = { version = "0.3.7", default-features = false }
|
||||
anyhow = "1"
|
||||
env_logger = "0.9"
|
||||
futures-core = "0.3.7"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
time = "0.3"
|
||||
tracing = { version = "0.1.30", default-features = false, features = ["log"] }
|
||||
|
||||
[dev-dependencies]
|
||||
actix-http = "3.0.0-rc.1"
|
||||
actix-http = "3"
|
||||
actix-web = { version = "4", default_features = false, features = ["macros", "cookies", "secure-cookies"] }
|
||||
actix-session = { version = "0.6.2", features = ["redis-rs-session", "cookie-session"] } # update to 0.7 with release of -session
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
reqwest = { version = "0.11", default_features = false, features = ["cookies", "json"] }
|
||||
|
84
actix-identity/examples/identity.rs
Normal file
84
actix-identity/examples/identity.rs
Normal 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()
|
||||
}
|
101
actix-identity/src/config.rs
Normal file
101
actix-identity/src/config.rs
Normal 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)
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
27
actix-identity/src/identity_ext.rs
Normal file
27
actix-identity/src/identity_ext.rs
Normal 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())
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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};
|
||||
|
||||
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()))
|
||||
if !must_extract_identity {
|
||||
return;
|
||||
}
|
||||
|
||||
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"))
|
||||
})),
|
||||
};
|
||||
|
||||
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(login_deadline) = configuration.login_deadline {
|
||||
if matches!(
|
||||
enforce_login_deadline(&identity, login_deadline),
|
||||
PolicyDecision::LogOut
|
||||
) {
|
||||
identity.logout();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
17
actix-identity/tests/integration/fixtures.rs
Normal file
17
actix-identity/tests/integration/fixtures.rs
Normal 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()
|
||||
}
|
168
actix-identity/tests/integration/integration.rs
Normal file
168
actix-identity/tests/integration/integration.rs
Normal 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);
|
||||
}
|
3
actix-identity/tests/integration/main.rs
Normal file
3
actix-identity/tests/integration/main.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod fixtures;
|
||||
mod integration;
|
||||
pub mod test_app;
|
186
actix-identity/tests/integration/test_app.rs
Normal file
186
actix-identity/tests/integration/test_app.rs
Normal 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()
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
# Changes
|
||||
|
||||
## Unreleased - 2022-xx-xx
|
||||
## Unreleased - 2021-xx-xx
|
||||
- Added `TtlExtensionPolicy` enum to support different strategies for extending the TTL attached to the session state. `TtlExtensionPolicy::OnEveryRequest` now allows for long-lived sessions that do not expire if the user remains active. [#233]
|
||||
- `SessionLength` is now called `SessionLifecycle`. [#233]
|
||||
- `SessionLength::Predetermined` is now called `SessionLifecycle::PersistentSession`. [#233]
|
||||
|
@ -146,7 +146,7 @@ mod session_ext;
|
||||
pub mod storage;
|
||||
|
||||
pub use self::middleware::SessionMiddleware;
|
||||
pub use self::session::{Session, SessionStatus};
|
||||
pub use self::session::{Session, SessionGetError, SessionInsertError, SessionStatus};
|
||||
pub use self::session_ext::SessionExt;
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -1,16 +1,20 @@
|
||||
use std::{
|
||||
cell::{Ref, RefCell},
|
||||
collections::HashMap,
|
||||
error::Error as StdError,
|
||||
mem,
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use actix_utils::future::{ready, Ready};
|
||||
use actix_web::{
|
||||
body::BoxBody,
|
||||
dev::{Extensions, Payload, ServiceRequest, ServiceResponse},
|
||||
error::Error,
|
||||
FromRequest, HttpMessage, HttpRequest,
|
||||
FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use derive_more::{Display, From};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
|
||||
/// The primary interface to access and modify session state.
|
||||
@ -38,6 +42,7 @@ use serde::{de::DeserializeOwned, Serialize};
|
||||
/// [`SessionExt`].
|
||||
///
|
||||
/// [`SessionExt`]: crate::SessionExt
|
||||
#[derive(Clone)]
|
||||
pub struct Session(Rc<RefCell<SessionInner>>);
|
||||
|
||||
/// Status of a [`Session`].
|
||||
@ -78,9 +83,20 @@ impl Session {
|
||||
/// Get a `value` from the session.
|
||||
///
|
||||
/// It returns an error if it fails to deserialize as `T` the JSON value associated with `key`.
|
||||
pub fn get<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) {
|
||||
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 {
|
||||
Ok(None)
|
||||
}
|
||||
@ -104,17 +120,29 @@ impl Session {
|
||||
/// only a reference to the value is taken.
|
||||
///
|
||||
/// It returns an error if it fails to serialize `value` to JSON.
|
||||
pub fn insert(
|
||||
pub fn insert<T: Serialize>(
|
||||
&self,
|
||||
key: impl Into<String>,
|
||||
value: impl Serialize,
|
||||
) -> Result<(), serde_json::Error> {
|
||||
value: T,
|
||||
) -> Result<(), SessionInsertError> {
|
||||
let mut inner = self.0.borrow_mut();
|
||||
|
||||
if inner.status != SessionStatus::Purged {
|
||||
inner.status = SessionStatus::Changed;
|
||||
let val = serde_json::to_string(&value)?;
|
||||
inner.state.insert(key.into(), val);
|
||||
|
||||
let key = key.into();
|
||||
let val = serde_json::to_string(&value)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to serialize the provided `{}` type instance as JSON in order to \
|
||||
attach as session data to the `{}` key",
|
||||
std::any::type_name::<T>(),
|
||||
&key
|
||||
)
|
||||
})
|
||||
.map_err(SessionInsertError)?;
|
||||
|
||||
inner.state.insert(key, val);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -136,7 +164,7 @@ impl Session {
|
||||
|
||||
/// Remove value from the session and deserialize.
|
||||
///
|
||||
/// Returns None if key was not present in session. Returns `T` if deserialization succeeds,
|
||||
/// Returns `None` if key was not present in session. Returns `T` if deserialization succeeds,
|
||||
/// otherwise returns un-deserialized JSON string.
|
||||
pub fn remove_as<T: DeserializeOwned>(&self, key: &str) -> Option<Result<T, String>> {
|
||||
self.remove(key)
|
||||
@ -144,7 +172,7 @@ impl Session {
|
||||
Ok(val) => Ok(val),
|
||||
Err(_err) => {
|
||||
tracing::debug!(
|
||||
"removed value (key: {}) could not be deserialized as {}",
|
||||
"Removed value (key: {}) could not be deserialized as {}",
|
||||
key,
|
||||
std::any::type_name::<T>()
|
||||
);
|
||||
@ -195,9 +223,9 @@ impl Session {
|
||||
|
||||
/// Returns session status and iterator of key-value pairs of changes.
|
||||
///
|
||||
/// This is a destructive operation - the session state is removed from the request extensions typemap,
|
||||
/// leaving behind a new empty map. It should only be used when the session is being finalised (i.e.
|
||||
/// in `SessionMiddleware`).
|
||||
/// This is a destructive operation - the session state is removed from the request extensions
|
||||
/// typemap, leaving behind a new empty map. It should only be used when the session is being
|
||||
/// finalised (i.e. in `SessionMiddleware`).
|
||||
pub(crate) fn get_changes<B>(
|
||||
res: &mut ServiceResponse<B>,
|
||||
) -> (SessionStatus, HashMap<String, String>) {
|
||||
@ -254,3 +282,37 @@ impl FromRequest for Session {
|
||||
ready(Ok(Session::get_session(&mut *req.extensions_mut())))
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned by [`Session::get`].
|
||||
#[derive(Debug, Display, From)]
|
||||
#[display(fmt = "{}", _0)]
|
||||
pub struct SessionGetError(anyhow::Error);
|
||||
|
||||
impl StdError for SessionGetError {
|
||||
fn source(&self) -> Option<&(dyn StdError + 'static)> {
|
||||
Some(self.0.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for SessionGetError {
|
||||
fn error_response(&self) -> HttpResponse<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())
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user