1
0
mirror of https://github.com/actix/actix-extras.git synced 2025-06-26 10:27:42 +02:00

Rework actix session (#212)

Co-authored-by: Rob Ede <robjtede@icloud.com>
Co-authored-by: Luca P <rust@lpalmieri.com>
Co-authored-by: Sebastian Rollén <38324289+SebRollen@users.noreply.github.com>
This commit is contained in:
Luca Palmieri
2022-03-05 23:22:14 +00:00
committed by GitHub
parent a1d0f051b7
commit 7e6335a09f
27 changed files with 2647 additions and 1761 deletions

View File

@ -2,6 +2,23 @@
## Unreleased - 2021-xx-xx
### Added
- `SessionMiddleware`, a middleware to provide support for saving/updating/deleting session state against a pluggable storage backend (see `SessionStore` trait) [#212]
- `CookieSessionStore`, a cookie-based backend to store session state [#212]
- `RedisActorSessionStore`, a Redis-based backend to store session state powered by `actix-redis` [#212]
- `RedisSessionStore`, a Redis-based backend to store session state powered by `redis-rs` [#212]
- Add TLS support for Redis via `RedisSessionStore` [#212]
- Implement `SessionExt` for `ServiceResponse` [#212]
### Changed
- Rename `UserSession` to `SessionExt` [#212]
### Removed
- `CookieSession` has been removed in favour of `CookieSessionStore`, a storage backend for `SessionMiddleware` [#212]
- `Session::set_session` has been removed. Use `Session::insert` to modify the session state. [#212]
[#212]: https://github.com/actix/actix-extras/pull/212
## 0.5.0 - 2022-03-01
- Update `actix-web` dependency to `4`.
@ -27,7 +44,9 @@
## 0.5.0-beta.5 - 2021-12-12
- Update `actix-web` dependency to `4.0.0.beta-14`. [#209]
- Remove `UserSession` implementation for `RequestHead`. [#209]
- A session will be created in the storage backend if and only if there is some data inside the session state. This reduces the performance impact of `SessionMiddleware` on routes that do not leverage sessions. [#207]
[#207]: https://github.com/actix/actix-extras/pull/207
[#209]: https://github.com/actix/actix-extras/pull/209

View File

@ -1,7 +1,10 @@
[package]
name = "actix-session"
version = "0.5.0"
authors = ["Nikolay Kim <fafhrd91@gmail.com>"]
authors = [
"Nikolay Kim <fafhrd91@gmail.com>",
"Luca Palmieri <rust@lpalmieri.com>",
]
description = "Sessions for Actix Web"
keywords = ["http", "web", "framework", "async", "session"]
homepage = "https://actix.rs"
@ -9,25 +12,54 @@ repository = "https://github.com/actix/actix-extras.git"
license = "MIT OR Apache-2.0"
edition = "2018"
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[lib]
name = "actix_session"
path = "src/lib.rs"
[features]
default = ["cookie-session"]
cookie-session = ["actix-web/secure-cookies"]
default = []
cookie-session = []
redis-actor-session = ["actix-redis", "actix", "futures-core", "rand"]
redis-rs-session = ["redis", "rand"]
redis-rs-tls-session = ["redis-rs-session", "redis/tokio-native-tls-comp"]
[dependencies]
actix-service = "2"
actix-utils = "3"
actix-web = { version = "4", default_features = false, features = ["cookies"] }
actix-web = { version = "4", default_features = false, features = ["cookies", "secure-cookies"] }
anyhow = "1"
async-trait = "0.1"
derive_more = "0.99.5"
futures-util = { version = "0.3.7", default-features = false }
log = "0.4"
serde = "1.0"
serde_json = "1.0"
rand = { version = "0.8", optional = true }
serde = { version = "1" }
serde_json = { version = "1" }
time = "0.3"
tracing = { version = "0.1.30", default-features = false, features = ["log"] }
# redis-actor-session
actix = { version = "0.12.0", default-features = false, optional = true }
actix-redis = { version = "0.10", optional = true }
futures-core = { version = "0.3.7", default-features = false, optional = true }
# redis-rs-session
redis = { version = "0.21", default-features = false, features = ["aio", "tokio-comp", "connection-manager"], optional = true }
[dev-dependencies]
actix-web = { version = "4", default_features = false, features = ["macros", "cookies"] }
actix-session = { path = ".", features = ["cookie-session", "redis-actor-session", "redis-rs-session"] }
actix-test = "0.1.0-beta.10"
actix-web = { version = "4", default_features = false, features = ["cookies", "secure-cookies", "macros"] }
env_logger = "0.9"
log = "0.4"
[[example]]
name = "basic"
required-features = ["redis-actor-session"]
[[example]]
name = "authentication"
required-features = ["redis-actor-session"]

View File

@ -1,6 +1,6 @@
# actix-session
> Sessions for Actix Web.
> Session management for Actix Web applications.
[![crates.io](https://img.shields.io/crates/v/actix-session?label=latest)](https://crates.io/crates/actix-session)
[![Documentation](https://docs.rs/actix-session/badge.svg?version=0.5.0)](https://docs.rs/actix-session/0.5.0)

View File

@ -0,0 +1,103 @@
use actix_session::{storage::RedisActorSessionStore, Session, SessionMiddleware};
use actix_web::{
cookie::{Key, SameSite},
error::InternalError,
middleware, web, App, Error, HttpResponse, HttpServer, Responder,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Credentials {
username: String,
password: String,
}
#[derive(Serialize)]
struct User {
id: i64,
username: String,
password: String,
}
impl User {
fn authenticate(credentials: Credentials) -> Result<Self, HttpResponse> {
// TODO: figure out why I keep getting hacked
if &credentials.password != "hunter2" {
return Err(HttpResponse::Unauthorized().json("Unauthorized"));
}
Ok(User {
id: 42,
username: credentials.username,
password: credentials.password,
})
}
}
pub fn validate_session(session: &Session) -> Result<i64, HttpResponse> {
let user_id: Option<i64> = session.get("user_id").unwrap_or(None);
match user_id {
Some(id) => {
// keep the user's session alive
session.renew();
Ok(id)
}
None => Err(HttpResponse::Unauthorized().json("Unauthorized")),
}
}
async fn login(
credentials: web::Json<Credentials>,
session: Session,
) -> Result<impl Responder, Error> {
let credentials = credentials.into_inner();
match User::authenticate(credentials) {
Ok(user) => session.insert("user_id", user.id).unwrap(),
Err(err) => return Err(InternalError::from_response("", err).into()),
};
Ok("Welcome!")
}
/// some protected resource
async fn secret(session: Session) -> Result<impl Responder, Error> {
// only allow access to this resource if the user has an active session
validate_session(&session).map_err(|err| InternalError::from_response("", err))?;
Ok("secret revealed")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
// The signing key would usually be read from a configuration file/environment variables.
let signing_key = Key::generate();
log::info!("starting HTTP server at http://localhost:8080");
HttpServer::new(move || {
App::new()
// enable logger
.wrap(middleware::Logger::default())
// cookie session middleware
.wrap(
SessionMiddleware::builder(
RedisActorSessionStore::new("127.0.0.1:6379"),
signing_key.clone(),
)
// allow the cookie to be accessed from javascript
.cookie_http_only(false)
// allow the cookie only from the current domain
.cookie_same_site(SameSite::Strict)
.build(),
)
.route("/login", web::post().to(login))
.route("/secret", web::get().to(secret))
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}

View File

@ -0,0 +1,43 @@
use actix_session::{storage::RedisActorSessionStore, Session, SessionMiddleware};
use actix_web::{cookie::Key, middleware, web, App, Error, HttpRequest, HttpServer, Responder};
/// simple handler
async fn index(req: HttpRequest, session: Session) -> Result<impl Responder, Error> {
println!("{:?}", req);
// session
if let Some(count) = session.get::<i32>("counter")? {
println!("SESSION value: {}", count);
session.insert("counter", count + 1)?;
} else {
session.insert("counter", 1)?;
}
Ok("Welcome!")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
// The signing key would usually be read from a configuration file/environment variables.
let signing_key = Key::generate();
log::info!("starting HTTP server at http://localhost:8080");
HttpServer::new(move || {
App::new()
// enable logger
.wrap(middleware::Logger::default())
// cookie session middleware
.wrap(SessionMiddleware::new(
RedisActorSessionStore::new("127.0.0.1:6379"),
signing_key.clone(),
))
// register simple route, handle all methods
.service(web::resource("/").to(index))
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}

View File

@ -1,66 +0,0 @@
use std::{error::Error as StdError, fmt};
use actix_web::ResponseError;
use derive_more::Display;
#[derive(Debug, Display)]
pub(crate) enum InsertErrorKind {
#[display(fmt = "{}", _0)]
Json(serde_json::Error),
}
impl Into<actix_web::Error> for InsertErrorKind {
fn into(self) -> actix_web::Error {
match self {
InsertErrorKind::Json(err) => err.into(),
}
}
}
/// Error returned by [`Session::insert`][crate::Session::insert]. Allows access to value that
/// failed to be inserted.
pub struct InsertError<T> {
pub(crate) value: Option<T>,
pub(crate) error: InsertErrorKind,
}
impl<T> InsertError<T> {
/// Takes value out of error.
///
/// # Panics
/// Panics if called more than once.
pub fn take_value(&mut self) -> T {
self.value
.take()
.expect("take_value should only be called once")
}
}
impl<T> fmt::Debug for InsertError<T> {
fn fmt<'a>(&'a self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut dbg = f.debug_struct("SessionInsertError");
match &self.value {
Some(_) => dbg.field("value", &"Some([value])" as _),
None => dbg.field("value", &None::<()> as _),
};
dbg.field("error", &self.error).finish()
}
}
impl<T> fmt::Display for InsertError<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.error, f)
}
}
impl<T: fmt::Debug> StdError for InsertError<T> {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(match &self.error {
InsertErrorKind::Json(err) => err,
})
}
}
impl<T> ResponseError for InsertError<T> {}

View File

@ -1,562 +0,0 @@
//! Cookie based sessions. See docs for [`CookieSession`].
use std::{collections::HashMap, rc::Rc};
use actix_utils::future::{ok, Ready};
use actix_web::{
body::{EitherBody, MessageBody},
cookie::{Cookie, CookieJar, Key, SameSite},
dev::{Service, ServiceRequest, ServiceResponse, Transform},
http::header::{HeaderValue, SET_COOKIE},
Error, ResponseError,
};
use derive_more::Display;
use futures_util::future::{FutureExt as _, LocalBoxFuture};
use serde_json::error::Error as JsonError;
use time::{Duration, OffsetDateTime};
use crate::{Session, SessionStatus};
/// Errors that can occur during handling cookie session
#[derive(Debug, Display)]
pub enum CookieSessionError {
/// Size of the serialized session is greater than 4000 bytes.
#[display(fmt = "Size of the serialized session is greater than 4000 bytes.")]
Overflow,
/// Fail to serialize session.
#[display(fmt = "Fail to serialize session")]
Serialize(JsonError),
}
impl ResponseError for CookieSessionError {}
#[derive(Copy, Clone)]
enum CookieSecurity {
Signed,
Private,
}
#[derive(Clone)]
struct CookieSessionInner {
key: Key,
security: CookieSecurity,
name: String,
path: String,
domain: Option<String>,
lazy: bool,
secure: bool,
http_only: bool,
max_age: Option<Duration>,
expires_in: Option<Duration>,
same_site: Option<SameSite>,
}
impl CookieSessionInner {
fn new(key: &[u8], security: CookieSecurity) -> CookieSessionInner {
CookieSessionInner {
security,
key: Key::derive_from(key),
name: "actix-session".to_owned(),
path: "/".to_owned(),
domain: None,
lazy: false,
secure: true,
http_only: true,
max_age: None,
expires_in: None,
same_site: None,
}
}
fn set_cookie<B>(
&self,
res: &mut ServiceResponse<B>,
state: impl Iterator<Item = (String, String)>,
) -> Result<(), Error> {
let state: HashMap<String, String> = state.collect();
if self.lazy && state.is_empty() {
return Ok(());
}
let value = serde_json::to_string(&state).map_err(CookieSessionError::Serialize)?;
if value.len() > 4064 {
return Err(CookieSessionError::Overflow.into());
}
let mut cookie = Cookie::new(self.name.clone(), value);
cookie.set_path(self.path.clone());
cookie.set_secure(self.secure);
cookie.set_http_only(self.http_only);
if let Some(ref domain) = self.domain {
cookie.set_domain(domain.clone());
}
if let Some(expires_in) = self.expires_in {
cookie.set_expires(OffsetDateTime::now_utc() + expires_in);
}
if let Some(max_age) = self.max_age {
cookie.set_max_age(max_age);
}
if let Some(same_site) = self.same_site {
cookie.set_same_site(same_site);
}
let mut jar = CookieJar::new();
match self.security {
CookieSecurity::Signed => jar.signed_mut(&self.key).add(cookie),
CookieSecurity::Private => jar.private_mut(&self.key).add(cookie),
}
for cookie in jar.delta() {
let val = HeaderValue::from_str(&cookie.encoded().to_string())?;
res.headers_mut().append(SET_COOKIE, val);
}
Ok(())
}
/// invalidates session cookie
fn remove_cookie<B>(&self, res: &mut ServiceResponse<B>) -> Result<(), Error> {
let mut cookie = Cookie::named(self.name.clone());
cookie.set_path(self.path.clone());
cookie.set_value("");
cookie.set_max_age(Duration::ZERO);
cookie.set_expires(OffsetDateTime::now_utc() - Duration::days(365));
let val = HeaderValue::from_str(&cookie.to_string())?;
res.headers_mut().append(SET_COOKIE, val);
Ok(())
}
fn load(&self, req: &ServiceRequest) -> (bool, HashMap<String, String>) {
if let Ok(cookies) = req.cookies() {
for cookie in cookies.iter() {
if cookie.name() == self.name {
let mut jar = CookieJar::new();
jar.add_original(cookie.clone());
let cookie_opt = match self.security {
CookieSecurity::Signed => jar.signed(&self.key).get(&self.name),
CookieSecurity::Private => jar.private(&self.key).get(&self.name),
};
if let Some(cookie) = cookie_opt {
if let Ok(val) = serde_json::from_str(cookie.value()) {
return (false, val);
}
}
}
}
}
(true, HashMap::new())
}
}
/// Use cookies for session storage.
///
/// `CookieSession` creates sessions which are limited to storing
/// fewer than 4000 bytes of data (as the payload must fit into a single
/// cookie). An Internal Server Error is generated if the session contains more
/// than 4000 bytes.
///
/// A cookie may have a security policy of *signed* or *private*. Each has a
/// respective `CookieSession` constructor.
///
/// A *signed* cookie is stored on the client as plaintext alongside
/// a signature such that the cookie may be viewed but not modified by the
/// client.
///
/// A *private* cookie is stored on the client as encrypted text
/// such that it may neither be viewed nor modified by the client.
///
/// The constructors take a key as an argument.
/// This is the private key for cookie session - when this value is changed,
/// all session data is lost. The constructors will panic if the key is less
/// than 32 bytes in length.
///
/// The backend relies on `cookie` crate to create and read cookies.
/// By default all cookies are percent encoded, but certain symbols may
/// cause troubles when reading cookie, if they are not properly percent encoded.
///
/// # Examples
/// ```
/// use actix_session::CookieSession;
/// use actix_web::{web, App, HttpResponse, HttpServer};
///
/// let app = App::new().wrap(
/// CookieSession::signed(&[0; 32])
/// .domain("www.rust-lang.org")
/// .name("actix_session")
/// .path("/")
/// .secure(true))
/// .service(web::resource("/").to(|| HttpResponse::Ok()));
/// ```
#[derive(Clone)]
pub struct CookieSession(Rc<CookieSessionInner>);
impl CookieSession {
/// Construct new *signed* `CookieSession` instance.
///
/// Panics if key length is less than 32 bytes.
pub fn signed(key: &[u8]) -> CookieSession {
CookieSession(Rc::new(CookieSessionInner::new(
key,
CookieSecurity::Signed,
)))
}
/// Construct new *private* `CookieSession` instance.
///
/// Panics if key length is less than 32 bytes.
pub fn private(key: &[u8]) -> CookieSession {
CookieSession(Rc::new(CookieSessionInner::new(
key,
CookieSecurity::Private,
)))
}
/// Sets the `path` field in the session cookie being built.
pub fn path<S: Into<String>>(mut self, value: S) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().path = value.into();
self
}
/// Sets the `name` field in the session cookie being built.
pub fn name<S: Into<String>>(mut self, value: S) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().name = value.into();
self
}
/// Sets the `domain` field in the session cookie being built.
pub fn domain<S: Into<String>>(mut self, value: S) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().domain = Some(value.into());
self
}
/// When true, prevents adding session cookies to responses until
/// the session contains data. Default is `false`.
///
/// Useful when trying to comply with laws that require consent for setting cookies.
pub fn lazy(mut self, value: bool) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().lazy = value;
self
}
/// Sets the `secure` field in the session cookie being built.
///
/// If the `secure` field is set, a cookie will only be transmitted when the
/// connection is secure - i.e. `https`
pub fn secure(mut self, value: bool) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().secure = value;
self
}
/// Sets the `http_only` field in the session cookie being built.
pub fn http_only(mut self, value: bool) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().http_only = value;
self
}
/// Sets the `same_site` field in the session cookie being built.
pub fn same_site(mut self, value: SameSite) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().same_site = Some(value);
self
}
/// Sets the `max-age` field in the session cookie being built.
pub fn max_age(self, seconds: i64) -> CookieSession {
self.max_age_time(Duration::seconds(seconds))
}
/// Sets the `max-age` field in the session cookie being built.
pub fn max_age_time(mut self, value: time::Duration) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().max_age = Some(value);
self
}
/// Sets the `expires` field in the session cookie being built.
pub fn expires_in(self, seconds: i64) -> CookieSession {
self.expires_in_time(Duration::seconds(seconds))
}
/// Sets the `expires` field in the session cookie being built.
pub fn expires_in_time(mut self, value: Duration) -> CookieSession {
Rc::get_mut(&mut self.0).unwrap().expires_in = Some(value);
self
}
}
impl<S, B> Transform<S, ServiceRequest> for CookieSession
where
S: Service<ServiceRequest, Response = ServiceResponse<B>>,
S::Future: 'static,
S::Error: 'static,
B: MessageBody + 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = S::Error;
type InitError = ();
type Transform = CookieSessionMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ok(CookieSessionMiddleware {
service,
inner: self.0.clone(),
})
}
}
/// Cookie based session middleware.
pub struct CookieSessionMiddleware<S> {
service: S,
inner: Rc<CookieSessionInner>,
}
impl<S, B> Service<ServiceRequest> for CookieSessionMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>>,
S::Future: 'static,
S::Error: 'static,
B: MessageBody + 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = S::Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
actix_service::forward_ready!(service);
/// On first request, a new session cookie is returned in response, regardless
/// of whether any session state is set. With subsequent requests, if the
/// session state changes, then set-cookie is returned in response. As
/// a user logs out, call session.purge() to set SessionStatus accordingly
/// and this will trigger removal of the session cookie in the response.
fn call(&self, mut req: ServiceRequest) -> Self::Future {
let inner = self.inner.clone();
let (is_new, state) = self.inner.load(&req);
let prolong_expiration = self.inner.expires_in.is_some();
Session::set_session(&mut req, state);
let fut = self.service.call(req);
async move {
let mut res = fut.await?;
let result = match Session::get_changes(&mut res) {
(SessionStatus::Changed, state) | (SessionStatus::Renewed, state) => {
inner.set_cookie(&mut res, state)
}
(SessionStatus::Unchanged, state) if prolong_expiration => {
inner.set_cookie(&mut res, state)
}
// set a new session cookie upon first request (new client)
(SessionStatus::Unchanged, _) => {
if is_new {
let state: HashMap<String, String> = HashMap::new();
inner.set_cookie(&mut res, state.into_iter())
} else {
Ok(())
}
}
(SessionStatus::Purged, _) => {
let _ = inner.remove_cookie(&mut res);
Ok(())
}
};
match result {
Ok(()) => Ok(res.map_into_left_body()),
Err(error) => Ok(res.error_response(error).map_into_right_body()),
}
}
.boxed_local()
}
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::web::Bytes;
use actix_web::{test, web, App};
#[actix_web::test]
async fn cookie_session() {
let app = test::init_service(
App::new()
.wrap(CookieSession::signed(&[0; 32]).secure(false))
.service(web::resource("/").to(|ses: Session| async move {
let _ = ses.insert("counter", 100);
"test"
})),
)
.await;
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
assert!(response
.response()
.cookies()
.any(|c| c.name() == "actix-session"));
}
#[actix_web::test]
async fn private_cookie() {
let app = test::init_service(
App::new()
.wrap(CookieSession::private(&[0; 32]).secure(false))
.service(web::resource("/").to(|ses: Session| async move {
let _ = ses.insert("counter", 100);
"test"
})),
)
.await;
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
assert!(response
.response()
.cookies()
.any(|c| c.name() == "actix-session"));
}
#[actix_web::test]
async fn lazy_cookie() {
let app = test::init_service(
App::new()
.wrap(CookieSession::signed(&[0; 32]).secure(false).lazy(true))
.service(web::resource("/count").to(|ses: Session| async move {
let _ = ses.insert("counter", 100);
"counting"
}))
.service(web::resource("/").to(|_ses: Session| async move { "test" })),
)
.await;
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
assert!(response.response().cookies().count() == 0);
let request = test::TestRequest::with_uri("/count").to_request();
let response = app.call(request).await.unwrap();
assert!(response
.response()
.cookies()
.any(|c| c.name() == "actix-session"));
}
#[actix_web::test]
async fn cookie_session_extractor() {
let app = test::init_service(
App::new()
.wrap(CookieSession::signed(&[0; 32]).secure(false))
.service(web::resource("/").to(|ses: Session| async move {
let _ = ses.insert("counter", 100);
"test"
})),
)
.await;
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
assert!(response
.response()
.cookies()
.any(|c| c.name() == "actix-session"));
}
#[actix_web::test]
async fn basics() {
let app = test::init_service(
App::new()
.wrap(
CookieSession::signed(&[0; 32])
.path("/test/")
.name("actix-test")
.domain("localhost")
.http_only(true)
.same_site(SameSite::Lax)
.max_age(100),
)
.service(web::resource("/").to(|ses: Session| async move {
let _ = ses.insert("counter", 100);
"test"
}))
.service(web::resource("/test/").to(|ses: Session| async move {
let val: usize = ses.get("counter").unwrap().unwrap();
format!("counter: {}", val)
})),
)
.await;
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
let cookie = response
.response()
.cookies()
.find(|c| c.name() == "actix-test")
.unwrap()
.clone();
assert_eq!(cookie.path().unwrap(), "/test/");
let request = test::TestRequest::with_uri("/test/")
.cookie(cookie)
.to_request();
let body = test::call_and_read_body(&app, request).await;
assert_eq!(body, Bytes::from_static(b"counter: 100"));
}
#[actix_web::test]
async fn prolong_expiration() {
let app = test::init_service(
App::new()
.wrap(CookieSession::signed(&[0; 32]).secure(false).expires_in(60))
.service(web::resource("/").to(|ses: Session| async move {
let _ = ses.insert("counter", 100);
"test"
}))
.service(web::resource("/test/").to(|| async move { "no-changes-in-session" })),
)
.await;
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
let expires_1 = response
.response()
.cookies()
.find(|c| c.name() == "actix-session")
.expect("Cookie is set")
.expires()
.expect("Expiration is set")
.datetime()
.expect("Expiration is a datetime");
actix_web::rt::time::sleep(std::time::Duration::from_secs(1)).await;
let request = test::TestRequest::with_uri("/test/").to_request();
let response = app.call(request).await.unwrap();
let expires_2 = response
.response()
.cookies()
.find(|c| c.name() == "actix-session")
.expect("Cookie is set")
.expires()
.expect("Expiration is set")
.datetime()
.expect("Expiration is a datetime");
assert!(expires_2 - expires_1 >= Duration::seconds(1));
}
}

View File

@ -1,23 +1,81 @@
//! Sessions for Actix Web.
//! Session management for Actix Web
//!
//! Provides a general solution for session management. Session middleware could provide different
//! implementations which could be accessed via general session API.
//! The HTTP protocol, at a first glance, is stateless: the client sends a request, the server
//! parses its content, performs some processing and returns a response. The outcome is only
//! influenced by the provided inputs (i.e. the request content) and whatever state the server
//! queries while performing its processing.
//!
//! This crate provides a general solution for session management and includes a cookie backend.
//! Other backend implementations can be built to use persistent or key-value stores, for example.
//! Stateless systems are easier to reason about, but they are not quite as powerful as we need to
//! be - e.g. how do you authenticate a user? The user would be forced to authenticate **for every
//! single request**. That is, for example, how 'Basic' Authentication works. While it may work for
//! a machine user (i.e. an API client), it is impractical for a person—you do not want a login
//! prompt on every single page you navigate to!
//!
//! In general, some session middleware, such as a [`CookieSession`] is initialized and applied.
//! To access session data, the [`Session`] extractor must be used. This extractor allows reading
//! modifying session data.
//! There is a solution - **sessions**. Using sessions the server can attach state to a set of
//! requests coming from the same client. They are built on top of cookies - the server sets a
//! cookie in the HTTP response (`Set-Cookie` header), the client (e.g. the browser) will store the
//! cookie and play it back to the server when sending new requests (using the `Cookie` header).
//!
//! We refer to the cookie used for sessions as a **session cookie**. Its content is called
//! **session key** (or **session ID**), while the state attached to the session is referred to as
//! **session state**.
//!
//! `actix-session` provides an easy-to-use framework to manage sessions in applications built on
//! top of Actix Web. [`SessionMiddleware`] is the middleware underpinning the functionality
//! provided by `actix-session`; it takes care of all the session cookie handling and instructs the
//! **storage backend** to create/delete/update the session state based on the operations performed
//! against the active [`Session`].
//!
//! `actix-session` provides some built-in storage backends: ([`storage::CookieSessionStore`],
//! [`storage::RedisSessionStore`], and [`storage::RedisActorSessionStore`]) - you can create a
//! custom storage backend by implementing the [`SessionStore`](storage::SessionStore) trait.
//!
//! Further reading on sessions:
//! - [RFC6265](https://datatracker.ietf.org/doc/html/rfc6265);
//! - [OWASP's session management cheat-sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html).
//!
//! # Getting started
//! To start using sessions in your Actix Web application you must register [`SessionMiddleware`]
//! as a middleware on your `App`:
//!
//! ```no_run
//! use actix_web::{web, App, HttpServer, HttpResponse, Error};
//! use actix_session::{Session, CookieSession};
//! use actix_session::{Session, SessionMiddleware, storage::RedisActorSessionStore};
//! use actix_web::cookie::Key;
//!
//! #[actix_web::main]
//! async fn main() -> std::io::Result<()> {
//! // The secret key would usually be read from a configuration file/environment variables.
//! let secret_key = Key::generate();
//! let redis_connection_string = "127.0.0.1:6379";
//! HttpServer::new(move ||
//! App::new()
//! // Add session management to your application using Redis for session state storage
//! .wrap(
//! SessionMiddleware::new(
//! RedisActorSessionStore::new(redis_connection_string),
//! secret_key.clone()
//! )
//! )
//! .default_service(web::to(|| HttpResponse::Ok())))
//! .bind(("127.0.0.1", 8080))?
//! .run()
//! .await
//! }
//! ```
//!
//! The session state can be accessed and modified by your request handlers using the [`Session`]
//! extractor.
//!
//! ```no_run
//! use actix_web::Error;
//! use actix_session::Session;
//!
//! fn index(session: Session) -> Result<&'static str, Error> {
//! // access session data
//! // Access the session state
//! if let Some(count) = session.get::<i32>("counter")? {
//! println!("SESSION value: {}", count);
//! // Modify the session state
//! session.insert("counter", count + 1)?;
//! } else {
//! session.insert("counter", 1)?;
@ -25,356 +83,499 @@
//!
//! Ok("Welcome!")
//! }
//!
//! #[actix_web::main]
//! async fn main() -> std::io::Result<()> {
//! HttpServer::new(
//! || App::new()
//! // create cookie based session middleware
//! .wrap(CookieSession::signed(&[0; 32]).secure(false))
//! .default_service(web::to(|| HttpResponse::Ok())))
//! .bind(("127.0.0.1", 8080))?
//! .run()
//! .await
//! }
//! ```
//!
//! # Choosing A Backend
//!
//! By default, `actix-session` does not provide any storage backend to retrieve and save the state
//! attached to your sessions. You can enable:
//!
//! - a purely cookie-based "backend", [`storage::CookieSessionStore`], using the `cookie-session`
//! feature flag.
//!
//! ```toml
//! [dependencies]
//! # ...
//! actix-session = { version = "...", features = ["cookie-session"] }
//! ```
//!
//! - a Redis-based backend via `actix-redis`, [`storage::RedisActorSessionStore`], using the
//! `redis-actor-session` feature flag.
//!
//! ```toml
//! [dependencies]
//! # ...
//! actix-session = { version = "...", features = ["redis-actor-session"] }
//! ```
//!
//! - a Redis-based backend via [`redis-rs`](https://github.com/mitsuhiko/redis-rs),
//! [`storage::RedisSessionStore`], using the `redis-rs-session` feature flag.
//!
//! ```toml
//! [dependencies]
//! # ...
//! actix-session = { version = "...", features = ["redis-rs-session"] }
//! ```
//!
//! Add the `redis-rs-tls-session` feature flag if you want to connect to Redis using a secured
//! connection:
//!
//! ```toml
//! [dependencies]
//! # ...
//! actix-session = { version = "...", features = ["redis-rs-session", "redis-rs-tls-session"] }
//! ```
//!
//! You can provide a different session store by implementing the [`storage::SessionStore`] trait.
#![deny(rust_2018_idioms, nonstandard_style)]
#![warn(future_incompatible, missing_docs)]
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
#![cfg_attr(docsrs, feature(doc_cfg))]
use std::{
cell::{Ref, RefCell},
collections::HashMap,
mem,
rc::Rc,
mod middleware;
mod session;
mod session_ext;
pub mod storage;
pub use self::middleware::{
CookieContentSecurity, SessionLength, SessionMiddleware, SessionMiddlewareBuilder,
};
use actix_utils::future::{ok, Ready};
use actix_web::{
dev::{Extensions, Payload, ServiceRequest, ServiceResponse},
Error, FromRequest, HttpMessage, HttpRequest,
};
use serde::{de::DeserializeOwned, Serialize};
#[cfg(feature = "cookie-session")]
mod cookie;
#[cfg(feature = "cookie-session")]
pub use self::cookie::CookieSession;
/// The high-level interface you use to modify session data.
///
/// Session object is obtained with [`UserSession::get_session`]. The [`UserSession`] trait is
/// implemented for `HttpRequest`, `ServiceRequest`, and `RequestHead`.
///
/// ```
/// use actix_session::Session;
/// use actix_web::Result;
///
/// async fn index(session: Session) -> Result<&'static str> {
/// // access session data
/// if let Some(count) = session.get::<i32>("counter")? {
/// session.insert("counter", count + 1)?;
/// } else {
/// session.insert("counter", 1)?;
/// }
///
/// Ok("Welcome!")
/// }
/// ```
pub struct Session(Rc<RefCell<SessionInner>>);
/// Extraction of a [`Session`] object.
pub trait UserSession {
/// Extract the [`Session`] object
fn get_session(&self) -> Session;
}
impl UserSession for HttpRequest {
fn get_session(&self) -> Session {
Session::get_session(&mut *self.extensions_mut())
}
}
impl UserSession for ServiceRequest {
fn get_session(&self) -> Session {
Session::get_session(&mut *self.extensions_mut())
}
}
/// Status of a [`Session`].
#[derive(PartialEq, Clone, Debug)]
pub enum SessionStatus {
/// Session has been updated and requires a new persist operation.
Changed,
/// Session is flagged for deletion and should be removed from client and server.
///
/// Most operations on the session after purge flag is set should have no effect.
Purged,
/// Session is flagged for refresh.
///
/// For example, when using a backend that has a TTL (time-to-live) expiry on the session entry,
/// the session will be refreshed even if no data inside it has changed. The client may also
/// be notified of the refresh.
Renewed,
/// Session is unchanged from when last seen (if exists).
///
/// This state also captures new (previously unissued) sessions such as a user's first
/// site visit.
Unchanged,
}
impl Default for SessionStatus {
fn default() -> SessionStatus {
SessionStatus::Unchanged
}
}
#[derive(Default)]
struct SessionInner {
state: HashMap<String, String>,
status: SessionStatus,
}
impl Session {
/// Get a `value` from the session.
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, Error> {
if let Some(s) = self.0.borrow().state.get(key) {
Ok(Some(serde_json::from_str(s)?))
} else {
Ok(None)
}
}
/// Get all raw key-value data from the session.
///
/// Note that values are JSON encoded.
pub fn entries(&self) -> Ref<'_, HashMap<String, String>> {
Ref::map(self.0.borrow(), |inner| &inner.state)
}
/// Inserts a key-value pair into the session.
///
/// Any serializable value can be used and will be encoded as JSON in session data, hence why
/// only a reference to the value is taken.
pub fn insert(&self, key: impl Into<String>, value: impl Serialize) -> Result<(), Error> {
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);
}
Ok(())
}
/// Remove value from the session.
///
/// If present, the JSON encoded value is returned.
pub fn remove(&self, key: &str) -> Option<String> {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
inner.status = SessionStatus::Changed;
return inner.state.remove(key);
}
None
}
/// Remove value from the session and deserialize.
///
/// 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)
.map(|val_str| match serde_json::from_str(&val_str) {
Ok(val) => Ok(val),
Err(_err) => {
log::debug!(
"removed value (key: {}) could not be deserialized as {}",
key,
std::any::type_name::<T>()
);
Err(val_str)
}
})
}
/// Clear the session.
pub fn clear(&self) {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
inner.status = SessionStatus::Changed;
inner.state.clear()
}
}
/// Removes session both client and server side.
pub fn purge(&self) {
let mut inner = self.0.borrow_mut();
inner.status = SessionStatus::Purged;
inner.state.clear();
}
/// Renews the session key, assigning existing session state to new key.
pub fn renew(&self) {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
inner.status = SessionStatus::Renewed;
}
}
/// Adds the given key-value pairs to the session on the request.
///
/// Values that match keys already existing on the session will be overwritten. Values should
/// already be JSON serialized.
///
/// # Examples
/// ```
/// # use actix_session::Session;
/// # use actix_web::test;
/// let mut req = test::TestRequest::default().to_srv_request();
///
/// Session::set_session(
/// &mut req,
/// vec![("counter".to_string(), serde_json::to_string(&0).unwrap())],
/// );
/// ```
pub fn set_session(req: &mut ServiceRequest, data: impl IntoIterator<Item = (String, String)>) {
let session = Session::get_session(&mut *req.extensions_mut());
let mut inner = session.0.borrow_mut();
inner.state.extend(data);
}
/// Returns session status and iterator of key-value pairs of changes.
pub fn get_changes<B>(
res: &mut ServiceResponse<B>,
) -> (SessionStatus, impl Iterator<Item = (String, String)>) {
if let Some(s_impl) = res
.request()
.extensions()
.get::<Rc<RefCell<SessionInner>>>()
{
let state = mem::take(&mut s_impl.borrow_mut().state);
(s_impl.borrow().status.clone(), state.into_iter())
} else {
(SessionStatus::Unchanged, HashMap::new().into_iter())
}
}
fn get_session(extensions: &mut Extensions) -> Session {
if let Some(s_impl) = extensions.get::<Rc<RefCell<SessionInner>>>() {
return Session(Rc::clone(s_impl));
}
let inner = Rc::new(RefCell::new(SessionInner::default()));
extensions.insert(inner.clone());
Session(inner)
}
}
/// Extractor implementation for Session type.
///
/// # Examples
/// ```
/// # use actix_web::*;
/// use actix_session::Session;
///
/// #[get("/")]
/// async fn index(session: Session) -> Result<impl Responder> {
/// // access session data
/// if let Some(count) = session.get::<i32>("counter")? {
/// session.insert("counter", count + 1)?;
/// } else {
/// session.insert("counter", 1)?;
/// }
///
/// let count = session.get::<i32>("counter")?.unwrap();
/// Ok(format!("Counter: {}", count))
/// }
/// ```
impl FromRequest for Session {
type Error = Error;
type Future = Ready<Result<Session, Error>>;
#[inline]
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
ok(Session::get_session(&mut *req.extensions_mut()))
}
}
pub use self::session::{Session, SessionStatus};
pub use self::session_ext::SessionExt;
#[cfg(test)]
mod tests {
use actix_web::{test, HttpResponse};
pub mod test_helpers {
use actix_web::cookie::Key;
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use super::*;
use crate::{storage::SessionStore, CookieContentSecurity};
#[actix_web::test]
async fn session() {
let mut req = test::TestRequest::default().to_srv_request();
Session::set_session(
&mut req,
vec![("key".to_string(), serde_json::to_string("value").unwrap())],
);
let session = Session::get_session(&mut *req.extensions_mut());
let res = session.get::<String>("key").unwrap();
assert_eq!(res, Some("value".to_string()));
session.insert("key2", "value2").unwrap();
session.remove("key");
let mut res = req.into_response(HttpResponse::Ok().finish());
let (_status, state) = Session::get_changes(&mut res);
let changes: Vec<_> = state.collect();
assert_eq!(changes, [("key2".to_string(), "\"value2\"".to_string())]);
/// Generate a random cookie signing/encryption key.
pub fn key() -> Key {
let signing_key: String = thread_rng()
.sample_iter(&Alphanumeric)
.take(64)
.map(char::from)
.collect();
Key::from(signing_key.as_bytes())
}
#[actix_web::test]
async fn get_session() {
let mut req = test::TestRequest::default().to_srv_request();
Session::set_session(
&mut req,
vec![("key".to_string(), serde_json::to_string(&true).unwrap())],
);
let session = req.get_session();
let res = session.get("key").unwrap();
assert_eq!(res, Some(true));
/// A ready-to-go acceptance test suite to verify that sessions behave as expected
/// regardless of the underlying session store.
///
/// `is_invalidation_supported` must be set to `true` if the backend supports
/// "remembering" that a session has been invalidated (e.g. by logging out).
/// It should be to `false` if the backend allows multiple cookies to be active
/// at the same time (e.g. cookie store backend).
pub async fn acceptance_test_suite<F, Store>(store_builder: F, is_invalidation_supported: bool)
where
Store: SessionStore + 'static,
F: Fn() -> Store + Clone + Send + 'static,
{
for policy in &[
CookieContentSecurity::Signed,
CookieContentSecurity::Private,
] {
println!("Using {:?} as cookie content security policy.", policy);
acceptance_tests::basic_workflow(store_builder.clone(), *policy).await;
acceptance_tests::expiration_is_refreshed_on_changes(store_builder.clone(), *policy)
.await;
acceptance_tests::complex_workflow(
store_builder.clone(),
is_invalidation_supported,
*policy,
)
.await;
}
}
#[actix_web::test]
async fn purge_session() {
let req = test::TestRequest::default().to_srv_request();
let session = Session::get_session(&mut *req.extensions_mut());
assert_eq!(session.0.borrow().status, SessionStatus::Unchanged);
session.purge();
assert_eq!(session.0.borrow().status, SessionStatus::Purged);
}
mod acceptance_tests {
use actix_web::{
dev::Service,
middleware, test,
web::{self, get, post, resource, Bytes},
App, HttpResponse, Result,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use time::Duration;
#[actix_web::test]
async fn renew_session() {
let req = test::TestRequest::default().to_srv_request();
let session = Session::get_session(&mut *req.extensions_mut());
assert_eq!(session.0.borrow().status, SessionStatus::Unchanged);
session.renew();
assert_eq!(session.0.borrow().status, SessionStatus::Renewed);
}
use crate::{
middleware::SessionLength, storage::SessionStore, test_helpers::key,
CookieContentSecurity, Session, SessionMiddleware,
};
#[actix_web::test]
async fn session_entries() {
let session = Session(Rc::new(RefCell::new(SessionInner::default())));
session.insert("test_str", "val").unwrap();
session.insert("test_num", 1).unwrap();
pub(super) async fn basic_workflow<F, Store>(
store_builder: F,
policy: CookieContentSecurity,
) where
Store: SessionStore + 'static,
F: Fn() -> Store + Clone + Send + 'static,
{
let app = test::init_service(
App::new()
.wrap(
SessionMiddleware::builder(store_builder(), key())
.cookie_path("/test/".into())
.cookie_name("actix-test".into())
.cookie_domain(Some("localhost".into()))
.cookie_content_security(policy)
.session_length(SessionLength::Predetermined {
max_session_length: Some(time::Duration::seconds(100)),
})
.build(),
)
.service(web::resource("/").to(|ses: Session| async move {
let _ = ses.insert("counter", 100);
"test"
}))
.service(web::resource("/test/").to(|ses: Session| async move {
let val: usize = ses.get("counter").unwrap().unwrap();
format!("counter: {}", val)
})),
)
.await;
let map = session.entries();
map.contains_key("test_str");
map.contains_key("test_num");
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
let cookie = response
.response()
.cookies()
.find(|c| c.name() == "actix-test")
.unwrap()
.clone();
assert_eq!(cookie.path().unwrap(), "/test/");
let request = test::TestRequest::with_uri("/test/")
.cookie(cookie)
.to_request();
let body = test::call_and_read_body(&app, request).await;
assert_eq!(body, Bytes::from_static(b"counter: 100"));
}
pub(super) async fn expiration_is_refreshed_on_changes<F, Store>(
store_builder: F,
policy: CookieContentSecurity,
) where
Store: SessionStore + 'static,
F: Fn() -> Store + Clone + Send + 'static,
{
let app = test::init_service(
App::new()
.wrap(
SessionMiddleware::builder(store_builder(), key())
.cookie_content_security(policy)
.session_length(SessionLength::Predetermined {
max_session_length: Some(time::Duration::seconds(60)),
})
.build(),
)
.service(web::resource("/").to(|ses: Session| async move {
let _ = ses.insert("counter", 100);
"test"
}))
.service(web::resource("/test/").to(|| async move { "no-changes-in-session" })),
)
.await;
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
let cookie_1 = response
.response()
.cookies()
.find(|c| c.name() == "id")
.expect("Cookie is set");
assert_eq!(cookie_1.max_age(), Some(Duration::seconds(60)));
let request = test::TestRequest::with_uri("/test/").to_request();
let response = app.call(request).await.unwrap();
assert!(response.response().cookies().next().is_none());
let request = test::TestRequest::get().to_request();
let response = app.call(request).await.unwrap();
let cookie_2 = response
.response()
.cookies()
.find(|c| c.name() == "id")
.expect("Cookie is set");
assert_eq!(cookie_2.max_age(), Some(Duration::seconds(60)));
}
pub(super) async fn complex_workflow<F, Store>(
store_builder: F,
is_invalidation_supported: bool,
policy: CookieContentSecurity,
) where
Store: SessionStore + 'static,
F: Fn() -> Store + Clone + Send + 'static,
{
let srv = actix_test::start(move || {
App::new()
.wrap(
SessionMiddleware::builder(store_builder(), key())
.cookie_name("test-session".into())
.cookie_content_security(policy)
.session_length(SessionLength::Predetermined {
max_session_length: Some(time::Duration::days(7)),
})
.build(),
)
.wrap(middleware::Logger::default())
.service(resource("/").route(get().to(index)))
.service(resource("/do_something").route(post().to(do_something)))
.service(resource("/login").route(post().to(login)))
.service(resource("/logout").route(post().to(logout)))
});
// Step 1: GET index
// - set-cookie actix-session should NOT be in response (session data is empty)
// - response should be: {"counter": 0, "user_id": None}
let req_1a = srv.get("/").send();
let mut resp_1 = req_1a.await.unwrap();
assert!(resp_1.cookies().unwrap().is_empty());
let result_1 = resp_1.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_1,
IndexResponse {
user_id: None,
counter: 0
}
);
// Step 2: POST to do_something
// - adds new session state in redis: {"counter": 1}
// - set-cookie actix-session should be in response (session cookie #1)
// - response should be: {"counter": 1, "user_id": None}
let req_2 = srv.post("/do_something").send();
let mut resp_2 = req_2.await.unwrap();
let result_2 = resp_2.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_2,
IndexResponse {
user_id: None,
counter: 1
}
);
let cookie_1 = resp_2
.cookies()
.unwrap()
.clone()
.into_iter()
.find(|c| c.name() == "test-session")
.unwrap();
assert_eq!(cookie_1.max_age(), Some(Duration::days(7)));
// Step 3: GET index, including session cookie #1 in request
// - set-cookie will *not* be in response
// - response should be: {"counter": 1, "user_id": None}
let req_3 = srv.get("/").cookie(cookie_1.clone()).send();
let mut resp_3 = req_3.await.unwrap();
assert!(resp_3.cookies().unwrap().is_empty());
let result_3 = resp_3.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_3,
IndexResponse {
user_id: None,
counter: 1
}
);
// Step 4: POST again to do_something, including session cookie #1 in request
// - set-cookie will be in response (session cookie #2)
// - updates session state: {"counter": 2}
// - response should be: {"counter": 2, "user_id": None}
let req_4 = srv.post("/do_something").cookie(cookie_1.clone()).send();
let mut resp_4 = req_4.await.unwrap();
let result_4 = resp_4.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_4,
IndexResponse {
user_id: None,
counter: 2
}
);
let cookie_2 = resp_4
.cookies()
.unwrap()
.clone()
.into_iter()
.find(|c| c.name() == "test-session")
.unwrap();
assert_eq!(cookie_2.max_age(), Some(Duration::days(7)));
// Step 5: POST to login, including session cookie #2 in request
// - set-cookie actix-session will be in response (session cookie #3)
// - updates session state: {"counter": 2, "user_id": "ferris"}
let req_5 = srv
.post("/login")
.cookie(cookie_2.clone())
.send_json(&json!({"user_id": "ferris"}));
let mut resp_5 = req_5.await.unwrap();
let cookie_3 = resp_5
.cookies()
.unwrap()
.clone()
.into_iter()
.find(|c| c.name() == "test-session")
.unwrap();
assert_ne!(cookie_2.value(), cookie_3.value());
let result_5 = resp_5.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_5,
IndexResponse {
user_id: Some("ferris".into()),
counter: 2
}
);
// Step 6: GET index, including session cookie #3 in request
// - response should be: {"counter": 2, "user_id": "ferris"}
let req_6 = srv.get("/").cookie(cookie_3.clone()).send();
let mut resp_6 = req_6.await.unwrap();
let result_6 = resp_6.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_6,
IndexResponse {
user_id: Some("ferris".into()),
counter: 2
}
);
// Step 7: POST again to do_something, including session cookie #3 in request
// - updates session state: {"counter": 3, "user_id": "ferris"}
// - response should be: {"counter": 3, "user_id": "ferris"}
let req_7 = srv.post("/do_something").cookie(cookie_3.clone()).send();
let mut resp_7 = req_7.await.unwrap();
let result_7 = resp_7.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_7,
IndexResponse {
user_id: Some("ferris".into()),
counter: 3
}
);
// Step 8: GET index, including session cookie #2 in request
// If invalidation is supported, no state will be found associated to this session.
// If invalidation is not supported, the old state will still be retrieved.
let req_8 = srv.get("/").cookie(cookie_2.clone()).send();
let mut resp_8 = req_8.await.unwrap();
if is_invalidation_supported {
assert!(resp_8.cookies().unwrap().is_empty());
let result_8 = resp_8.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_8,
IndexResponse {
user_id: None,
counter: 0
}
);
} else {
let result_8 = resp_8.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_8,
IndexResponse {
user_id: None,
counter: 2
}
);
}
// Step 9: POST to logout, including session cookie #3
// - set-cookie actix-session will be in response with session cookie #3
// invalidation logic
let req_9 = srv.post("/logout").cookie(cookie_3.clone()).send();
let resp_9 = req_9.await.unwrap();
let cookie_3 = resp_9
.cookies()
.unwrap()
.clone()
.into_iter()
.find(|c| c.name() == "test-session")
.unwrap();
assert_eq!(0, cookie_3.max_age().map(|t| t.whole_seconds()).unwrap());
assert_eq!("/", cookie_3.path().unwrap());
// Step 10: GET index, including session cookie #3 in request
// - set-cookie actix-session should NOT be in response if invalidation is supported
// - response should be: {"counter": 0, "user_id": None}
let req_10 = srv.get("/").cookie(cookie_3.clone()).send();
let mut resp_10 = req_10.await.unwrap();
if is_invalidation_supported {
assert!(resp_10.cookies().unwrap().is_empty());
}
let result_10 = resp_10.json::<IndexResponse>().await.unwrap();
assert_eq!(
result_10,
IndexResponse {
user_id: None,
counter: 0
}
);
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct IndexResponse {
user_id: Option<String>,
counter: i32,
}
async fn index(session: Session) -> Result<HttpResponse> {
let user_id: Option<String> = session.get::<String>("user_id").unwrap();
let counter: i32 = session
.get::<i32>("counter")
.unwrap_or(Some(0))
.unwrap_or(0);
Ok(HttpResponse::Ok().json(&IndexResponse { user_id, counter }))
}
async fn do_something(session: Session) -> Result<HttpResponse> {
let user_id: Option<String> = session.get::<String>("user_id").unwrap();
let counter: i32 = session
.get::<i32>("counter")
.unwrap_or(Some(0))
.map_or(1, |inner| inner + 1);
session.insert("counter", &counter)?;
Ok(HttpResponse::Ok().json(&IndexResponse { user_id, counter }))
}
#[derive(Deserialize)]
struct Identity {
user_id: String,
}
async fn login(user_id: web::Json<Identity>, session: Session) -> Result<HttpResponse> {
let id = user_id.into_inner().user_id;
session.insert("user_id", &id)?;
session.renew();
let counter: i32 = session
.get::<i32>("counter")
.unwrap_or(Some(0))
.unwrap_or(0);
Ok(HttpResponse::Ok().json(&IndexResponse {
user_id: Some(id),
counter,
}))
}
async fn logout(session: Session) -> Result<HttpResponse> {
let id: Option<String> = session.get("user_id")?;
let body = if let Some(x) = id {
session.purge();
format!("Logged out: {}", x)
} else {
"Could not log out anonymous user".to_owned()
};
Ok(HttpResponse::Ok().body(body))
}
}
}

View File

@ -0,0 +1,646 @@
use std::{collections::HashMap, convert::TryInto, fmt, future::Future, pin::Pin, rc::Rc};
use actix_utils::future::{ready, Ready};
use actix_web::{
body::MessageBody,
cookie::{Cookie, CookieJar, Key, SameSite},
dev::{forward_ready, ResponseHead, Service, ServiceRequest, ServiceResponse, Transform},
http::header::{HeaderValue, SET_COOKIE},
};
use anyhow::Context;
use time::Duration;
use crate::{
storage::{LoadError, SessionKey, SessionStore},
Session, SessionStatus,
};
/// A middleware for session management in Actix Web applications.
///
/// [`SessionMiddleware`] takes care of a few jobs:
///
/// - Instructs the session storage backend to create/update/delete/retrieve the state attached to
/// a session according to its status and the operations that have been performed against it;
/// - Set/remove a cookie, on the client side, to enable a user to be consistently associated with
/// the same session across multiple HTTP requests.
///
/// Use [`SessionMiddleware::new`] to initialize the session framework using the default parameters.
/// To create a new instance of [`SessionMiddleware`] you need to provide:
///
/// - an instance of the session storage backend you wish to use (i.e. an implementation of
/// [`SessionStore]);
/// - a secret key, to sign or encrypt the content of client-side session cookie.
///
/// ```no_run
/// use actix_web::{web, App, HttpServer, HttpResponse, Error};
/// use actix_session::{Session, SessionMiddleware, storage::RedisActorSessionStore};
/// use actix_web::cookie::Key;
///
/// // The secret key would usually be read from a configuration file/environment variables.
/// fn get_secret_key() -> Key {
/// # todo!()
/// // [...]
/// }
///
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let secret_key = get_secret_key();
/// let redis_connection_string = "127.0.0.1:6379";
/// HttpServer::new(move ||
/// App::new()
/// // Add session management to your application using Redis for session state storage
/// .wrap(
/// SessionMiddleware::new(
/// RedisActorSessionStore::new(redis_connection_string),
/// secret_key.clone()
/// )
/// )
/// .default_service(web::to(|| HttpResponse::Ok())))
/// .bind(("127.0.0.1", 8080))?
/// .run()
/// .await
/// }
/// ```
///
/// If you want to customise use [`builder`](Self::builder) instead of [`new`](Self::new):
///
/// ```no_run
/// use actix_web::{cookie::Key, web, App, HttpServer, HttpResponse, Error};
/// use actix_session::{Session, SessionMiddleware, storage::RedisActorSessionStore, SessionLength};
///
/// // The secret key would usually be read from a configuration file/environment variables.
/// fn get_secret_key() -> Key {
/// # todo!()
/// // [...]
/// }
///
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let secret_key = get_secret_key();
/// let redis_connection_string = "127.0.0.1:6379";
/// HttpServer::new(move ||
/// App::new()
/// // Customise session length!
/// .wrap(
/// SessionMiddleware::builder(
/// RedisActorSessionStore::new(redis_connection_string),
/// secret_key.clone()
/// )
/// .session_length(SessionLength::Predetermined {
/// max_session_length: Some(time::Duration::days(5)),
/// })
/// .build(),
/// )
/// .default_service(web::to(|| HttpResponse::Ok())))
/// .bind(("127.0.0.1", 8080))?
/// .run()
/// .await
/// }
/// ```
///
/// ## How did we choose defaults?
///
/// You should not regret adding `actix-session` to your dependencies and going to production using
/// the default configuration. That is why, when in doubt, we opt to use the most secure option for
/// each configuration parameter.
///
/// We expose knobs to change the default to suit your needs—i.e., if you know what you are doing,
/// we will not stop you. But being a subject-matter expert should not be a requirement to deploy
/// reasonably secure implementation of sessions.
#[derive(Clone)]
pub struct SessionMiddleware<Store: SessionStore> {
storage_backend: Rc<Store>,
configuration: Rc<Configuration>,
}
#[derive(Clone)]
struct Configuration {
cookie: CookieConfiguration,
session: SessionConfiguration,
}
#[derive(Clone)]
struct SessionConfiguration {
state_ttl: Duration,
}
#[derive(Clone)]
struct CookieConfiguration {
secure: bool,
http_only: bool,
name: String,
same_site: SameSite,
path: String,
domain: Option<String>,
max_age: Option<Duration>,
content_security: CookieContentSecurity,
key: Key,
}
/// Describes how long a session should last.
///
/// Used by [`SessionMiddlewareBuilder::session_length`].
#[derive(Clone, Debug)]
pub enum SessionLength {
/// The session cookie will expire when the current browser session ends.
///
/// When does a browser session end? It depends on the browser! Chrome, for example, will often
/// continue running in the background when the browser is closed—session cookies are not
/// deleted and they will still be available when the browser is opened again. Check the
/// documentation of the browser you are targeting for up-to-date information.
BrowserSession {
/// We must provide a time-to-live (TTL) when storing the session state in the storage
/// backend—we do not want to store session states indefinitely, otherwise we will
/// inevitably run out of storage by holding on to the state of countless abandoned or
/// expired sessions!
///
/// We are dealing with the lifecycle of two uncorrelated object here: the session cookie
/// and the session state. It is not a big issue if the session state outlives the cookie—
/// we are wasting some space in the backend storage, but it will be cleaned up eventually.
/// What happens, instead, if the cookie outlives the session state? A new session starts—
/// e.g. if sessions are being used for authentication, the user is de-facto logged out.
///
/// It is not possible to predict with certainty how long a browser session is going to
/// last—you need to provide a reasonable upper bound. You do so via `state_ttl`—it dictates
/// what TTL should be used for session state when the lifecycle of the session cookie is
/// tied to the browser session length. [`SessionMiddleware`] will default to 1 day if
/// `state_ttl` is left unspecified.
state_ttl: Option<Duration>,
},
/// The session cookie will be a [persistent cookie].
///
/// Persistent cookies have a pre-determined lifetime, specified via the `Max-Age` or `Expires`
/// attribute. They do not disappear when the current browser session ends.
///
/// [persistent cookie]: https://www.whitehatsec.com/glossary/content/persistent-session-cookie
Predetermined {
/// Set `max_session_length` to specify how long the session cookie should live.
/// [`SessionMiddleware`] will default to 1 day if `max_session_length` is set to `None`.
///
/// `max_session_length` is also used as the TTL for the session state in the
/// storage backend.
max_session_length: Option<Duration>,
},
}
/// Used by [`SessionMiddlewareBuilder::cookie_content_security`] to determine how to secure
/// the content of the session cookie.
#[derive(Debug, Clone, Copy)]
pub enum CookieContentSecurity {
/// `CookieContentSecurity::Private` translates into an encrypted cookie content. The end-user/
/// JavaScript cannot tamper with its content nor decode it (i.e., it preserves confidentiality,
/// as long the as the encryption key is not breached).
Private,
/// `CookieContentSecurity::Signed` translates into a signed cookie content. The end-user/
/// JavaScript cannot tamper with its content, but they can read it (i.e., no confidentiality).
Signed,
}
fn default_configuration(key: Key) -> Configuration {
Configuration {
cookie: CookieConfiguration {
secure: true,
http_only: true,
name: "id".into(),
same_site: SameSite::Lax,
path: "/".into(),
domain: None,
max_age: None,
content_security: CookieContentSecurity::Private,
key,
},
session: SessionConfiguration {
state_ttl: default_ttl(),
},
}
}
fn default_ttl() -> Duration {
Duration::days(1)
}
impl<Store: SessionStore> SessionMiddleware<Store> {
/// Use [`SessionMiddleware::new`] to initialize the session framework using the default
/// parameters.
///
/// To create a new instance of [`SessionMiddleware`] you need to provide:
/// - an instance of the session storage backend you wish to use (i.e. an implementation of
/// [`SessionStore]);
/// - a secret key, to sign or encrypt the content of client-side session cookie.
pub fn new(store: Store, key: Key) -> Self {
Self {
storage_backend: Rc::new(store),
configuration: Rc::new(default_configuration(key)),
}
}
/// A fluent API to configure [`SessionMiddleware`].
///
/// It takes as input the two required inputs to create a new instance of [`SessionMiddleware`]:
/// - an instance of the session storage backend you wish to use (i.e. an implementation of
/// [`SessionStore]);
/// - a secret key, to sign or encrypt the content of client-side session cookie.
pub fn builder(store: Store, key: Key) -> SessionMiddlewareBuilder<Store> {
SessionMiddlewareBuilder {
storage_backend: Rc::new(store),
configuration: default_configuration(key),
}
}
}
/// A fluent builder to construct a [`SessionMiddleware`] instance with custom configuration
/// parameters.
#[must_use]
pub struct SessionMiddlewareBuilder<Store: SessionStore> {
storage_backend: Rc<Store>,
configuration: Configuration,
}
impl<Store: SessionStore> SessionMiddlewareBuilder<Store> {
/// Set the name of the cookie used to store the session ID.
///
/// Defaults to `id`.
pub fn cookie_name(mut self, name: String) -> Self {
self.configuration.cookie.name = name;
self
}
/// Set the `Secure` attribute for the cookie used to store the session ID.
///
/// If the cookie is set as secure, it will only be transmitted when the connection is secure
/// (using `https`).
///
/// Default is `true`.
pub fn cookie_secure(mut self, secure: bool) -> Self {
self.configuration.cookie.secure = secure;
self
}
/// Determine how long a session should last - check out [`SessionLength`]'s documentation for
/// more details on the available options.
///
/// Default is [`SessionLength::BrowserSession`].
pub fn session_length(mut self, session_length: SessionLength) -> Self {
match session_length {
SessionLength::BrowserSession { state_ttl } => {
self.configuration.cookie.max_age = None;
self.configuration.session.state_ttl = state_ttl.unwrap_or_else(default_ttl);
}
SessionLength::Predetermined { max_session_length } => {
let ttl = max_session_length.unwrap_or_else(default_ttl);
self.configuration.cookie.max_age = Some(ttl);
self.configuration.session.state_ttl = ttl;
}
}
self
}
/// Set the `SameSite` attribute for the cookie used to store the session ID.
///
/// By default, the attribute is set to `Lax`.
pub fn cookie_same_site(mut self, same_site: SameSite) -> Self {
self.configuration.cookie.same_site = same_site;
self
}
/// Set the `Path` attribute for the cookie used to store the session ID.
///
/// By default, the attribute is set to `/`.
pub fn cookie_path(mut self, path: String) -> Self {
self.configuration.cookie.path = path;
self
}
/// Set the `Domain` attribute for the cookie used to store the session ID.
///
/// Use `None` to leave the attribute unspecified. If unspecified, the attribute defaults
/// to the same host that set the cookie, excluding subdomains.
///
/// By default, the attribute is left unspecified.
pub fn cookie_domain(mut self, domain: Option<String>) -> Self {
self.configuration.cookie.domain = domain;
self
}
/// Choose how the session cookie content should be secured.
///
/// - `CookieContentSecurity::Private` translates into an encrypted cookie content.
/// - `CookieContentSecurity::Signed` translates into a signed cookie content.
///
/// # Default
/// By default, the cookie content is encrypted. Encrypted was chosen instead of signed as
/// default because it reduces the chances of sensitive information being exposed in the session
/// key by accident, regardless of [`SessionStore`] implementation you chose to use.
///
/// For example, if you are using cookie-based storage, you definitely want the cookie content
/// to be encrypted—the whole session state is embedded in the cookie! If you are using
/// Redis-based storage, signed is more than enough - the cookie content is just a unique
/// tamper-proof session key.
pub fn cookie_content_security(mut self, content_security: CookieContentSecurity) -> Self {
self.configuration.cookie.content_security = content_security;
self
}
/// Set the `HttpOnly` attribute for the cookie used to store the session ID.
///
/// If the cookie is set as `HttpOnly`, it will not be visible to any JavaScript snippets
/// running in the browser.
///
/// Default is `true`.
pub fn cookie_http_only(mut self, http_only: bool) -> Self {
self.configuration.cookie.http_only = http_only;
self
}
/// Finalise the builder and return a [`SessionMiddleware`] instance.
#[must_use]
pub fn build(self) -> SessionMiddleware<Store> {
SessionMiddleware {
storage_backend: self.storage_backend,
configuration: Rc::new(self.configuration),
}
}
}
impl<S, B, Store> Transform<S, ServiceRequest> for SessionMiddleware<Store>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
S::Future: 'static,
B: MessageBody + 'static,
Store: SessionStore + 'static,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type Transform = InnerSessionMiddleware<S, Store>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(InnerSessionMiddleware {
service: Rc::new(service),
configuration: Rc::clone(&self.configuration),
storage_backend: Rc::clone(&self.storage_backend),
}))
}
}
/// Short-hand to create an `actix_web::Error` instance that will result in an `Internal Server
/// Error` response while preserving the error root cause (e.g. in logs).
fn e500<E: fmt::Debug + fmt::Display + 'static>(err: E) -> actix_web::Error {
actix_web::error::ErrorInternalServerError(err)
}
#[doc(hidden)]
#[non_exhaustive]
pub struct InnerSessionMiddleware<S, Store: SessionStore + 'static> {
service: Rc<S>,
configuration: Rc<Configuration>,
storage_backend: Rc<Store>,
}
impl<S, B, Store> Service<ServiceRequest> for InnerSessionMiddleware<S, Store>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
S::Future: 'static,
Store: SessionStore + 'static,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
#[allow(clippy::type_complexity)]
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
forward_ready!(service);
fn call(&self, mut req: ServiceRequest) -> Self::Future {
let service = Rc::clone(&self.service);
let storage_backend = Rc::clone(&self.storage_backend);
let configuration = Rc::clone(&self.configuration);
Box::pin(async move {
let session_key = extract_session_key(&req, &configuration.cookie);
let (session_key, session_state) =
load_session_state(session_key, storage_backend.as_ref()).await?;
Session::set_session(&mut req, session_state);
let mut res = service.call(req).await?;
let (status, session_state) = Session::get_changes(&mut res);
match session_key {
None => {
// we do not create an entry in the session store if there is no state attached
// to a fresh session
if !session_state.is_empty() {
let session_key = storage_backend
.save(session_state, &configuration.session.state_ttl)
.await
.map_err(e500)?;
set_session_cookie(
res.response_mut().head_mut(),
session_key,
&configuration.cookie,
)
.map_err(e500)?;
}
}
Some(session_key) => {
match status {
SessionStatus::Changed => {
let session_key = storage_backend
.update(
session_key,
session_state,
&configuration.session.state_ttl,
)
.await
.map_err(e500)?;
set_session_cookie(
res.response_mut().head_mut(),
session_key,
&configuration.cookie,
)
.map_err(e500)?;
}
SessionStatus::Purged => {
storage_backend.delete(&session_key).await.map_err(e500)?;
delete_session_cookie(
res.response_mut().head_mut(),
&configuration.cookie,
)
.map_err(e500)?;
}
SessionStatus::Renewed => {
storage_backend.delete(&session_key).await.map_err(e500)?;
let session_key = storage_backend
.save(session_state, &configuration.session.state_ttl)
.await
.map_err(e500)?;
set_session_cookie(
res.response_mut().head_mut(),
session_key,
&configuration.cookie,
)
.map_err(e500)?;
}
SessionStatus::Unchanged => {
// Nothing to do; we avoid the unnecessary call to the storage.
}
}
}
};
Ok(res)
})
}
}
fn extract_session_key(req: &ServiceRequest, config: &CookieConfiguration) -> Option<SessionKey> {
let cookies = req.cookies().ok()?;
let session_cookie = cookies
.iter()
.find(|&cookie| cookie.name() == config.name)?;
let mut jar = CookieJar::new();
jar.add_original(session_cookie.clone());
let verification_result = match config.content_security {
CookieContentSecurity::Signed => jar.signed(&config.key).get(&config.name),
CookieContentSecurity::Private => jar.private(&config.key).get(&config.name),
};
if verification_result.is_none() {
tracing::warn!(
"The session cookie attached to the incoming request failed to pass cryptographic \
checks (signature verification/decryption)."
);
}
match verification_result?.value().to_owned().try_into() {
Ok(session_key) => Some(session_key),
Err(err) => {
tracing::warn!(
error.message = %err,
error.cause_chain = ?err,
"Invalid session key, ignoring."
);
None
}
}
}
async fn load_session_state<Store: SessionStore>(
session_key: Option<SessionKey>,
storage_backend: &Store,
) -> Result<(Option<SessionKey>, HashMap<String, String>), actix_web::Error> {
if let Some(session_key) = session_key {
match storage_backend.load(&session_key).await {
Ok(state) => {
if let Some(state) = state {
Ok((Some(session_key), state))
} else {
// We discard the existing session key given that the state attached to it can
// no longer be found (e.g. it expired or we suffered some data loss in the
// storage). Regenerating the session key will trigger the `save` workflow
// instead of the `update` workflow if the session state is modified during the
// lifecycle of the current request.
tracing::info!(
"No session state has been found for a valid session key, creating a new \
empty session."
);
Ok((None, HashMap::new()))
}
}
Err(err) => match err {
LoadError::Deserialization(err) => {
tracing::warn!(
error.message = %err,
error.cause_chain = ?err,
"Invalid session state, creating a new empty session."
);
Ok((Some(session_key), HashMap::new()))
}
LoadError::Other(err) => Err(e500(err)),
},
}
} else {
Ok((None, HashMap::new()))
}
}
fn set_session_cookie(
response: &mut ResponseHead,
session_key: SessionKey,
config: &CookieConfiguration,
) -> Result<(), anyhow::Error> {
let value: String = session_key.into();
let mut cookie = Cookie::new(config.name.clone(), value);
cookie.set_secure(config.secure);
cookie.set_http_only(config.http_only);
cookie.set_same_site(config.same_site);
cookie.set_path(config.path.clone());
if let Some(max_age) = config.max_age {
cookie.set_max_age(max_age);
}
if let Some(ref domain) = config.domain {
cookie.set_domain(domain.clone());
}
let mut jar = CookieJar::new();
match config.content_security {
CookieContentSecurity::Signed => jar.signed_mut(&config.key).add(cookie),
CookieContentSecurity::Private => jar.private_mut(&config.key).add(cookie),
}
// set cookie
let cookie = jar.delta().next().unwrap();
let val = HeaderValue::from_str(&cookie.encoded().to_string())
.context("Failed to attach a session cookie to the outgoing response")?;
response.headers_mut().append(SET_COOKIE, val);
Ok(())
}
fn delete_session_cookie(
response: &mut ResponseHead,
config: &CookieConfiguration,
) -> Result<(), anyhow::Error> {
let removal_cookie = Cookie::build(config.name.clone(), "")
.path(config.path.clone())
.http_only(config.http_only);
let mut removal_cookie = if let Some(ref domain) = config.domain {
removal_cookie.domain(domain)
} else {
removal_cookie
}
.finish();
removal_cookie.make_removal();
let val = HeaderValue::from_str(&removal_cookie.to_string())
.context("Failed to attach a session removal cookie to the outgoing response")?;
response.headers_mut().append(SET_COOKIE, val);
Ok(())
}

View File

@ -0,0 +1,256 @@
use std::{
cell::{Ref, RefCell},
collections::HashMap,
mem,
rc::Rc,
};
use actix_utils::future::{ready, Ready};
use actix_web::{
dev::{Extensions, Payload, ServiceRequest, ServiceResponse},
error::Error,
FromRequest, HttpMessage, HttpRequest,
};
use serde::{de::DeserializeOwned, Serialize};
/// The primary interface to access and modify session state.
///
/// [`Session`] is an [extractor](#impl-FromRequest)—you can specify it as an input type for your
/// request handlers and it will be automatically extracted from the incoming request.
///
/// ```
/// use actix_session::Session;
///
/// async fn index(session: Session) -> actix_web::Result<&'static str> {
/// // access session data
/// if let Some(count) = session.get::<i32>("counter")? {
/// session.insert("counter", count + 1)?;
/// } else {
/// session.insert("counter", 1)?;
/// }
///
/// Ok("Welcome!")
/// }
/// # actix_web::web::to(index);
/// ```
///
/// You can also retrieve a [`Session`] object from an `HttpRequest` or a `ServiceRequest` using
/// [`SessionExt`].
///
/// [`SessionExt`]: crate::SessionExt
pub struct Session(Rc<RefCell<SessionInner>>);
/// Status of a [`Session`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionStatus {
/// Session state has been updated - the changes will have to be persisted to the backend.
Changed,
/// The session has been flagged for deletion - the session cookie will be removed from
/// the client and the session state will be deleted from the session store.
///
/// Most operations on the session after it has been marked for deletion will have no effect.
Purged,
/// The session has been flagged for renewal.
///
/// The session key will be regenerated and the time-to-live of the session state will be
/// extended.
Renewed,
/// The session state has not been modified since its creation/retrieval.
Unchanged,
}
impl Default for SessionStatus {
fn default() -> SessionStatus {
SessionStatus::Unchanged
}
}
#[derive(Default)]
struct SessionInner {
state: HashMap<String, String>,
status: SessionStatus,
}
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> {
if let Some(val_str) = self.0.borrow().state.get(key) {
Ok(Some(serde_json::from_str(val_str)?))
} else {
Ok(None)
}
}
/// Get all raw key-value data from the session.
///
/// Note that values are JSON encoded.
pub fn entries(&self) -> Ref<'_, HashMap<String, String>> {
Ref::map(self.0.borrow(), |inner| &inner.state)
}
/// Returns session status.
pub fn status(&self) -> SessionStatus {
Ref::map(self.0.borrow(), |inner| &inner.status).clone()
}
/// Inserts a key-value pair into the session.
///
/// Any serializable value can be used and will be encoded as JSON in session data, hence why
/// only a reference to the value is taken.
///
/// It returns an error if it fails to serialize `value` to JSON.
pub fn insert(
&self,
key: impl Into<String>,
value: impl Serialize,
) -> Result<(), serde_json::Error> {
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);
}
Ok(())
}
/// Remove value from the session.
///
/// If present, the JSON encoded value is returned.
pub fn remove(&self, key: &str) -> Option<String> {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
inner.status = SessionStatus::Changed;
return inner.state.remove(key);
}
None
}
/// Remove value from the session and deserialize.
///
/// 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)
.map(|val_str| match serde_json::from_str(&val_str) {
Ok(val) => Ok(val),
Err(_err) => {
tracing::debug!(
"removed value (key: {}) could not be deserialized as {}",
key,
std::any::type_name::<T>()
);
Err(val_str)
}
})
}
/// Clear the session.
pub fn clear(&self) {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
inner.status = SessionStatus::Changed;
inner.state.clear()
}
}
/// Removes session both client and server side.
pub fn purge(&self) {
let mut inner = self.0.borrow_mut();
inner.status = SessionStatus::Purged;
inner.state.clear();
}
/// Renews the session key, assigning existing session state to new key.
pub fn renew(&self) {
let mut inner = self.0.borrow_mut();
if inner.status != SessionStatus::Purged {
inner.status = SessionStatus::Renewed;
}
}
/// Adds the given key-value pairs to the session on the request.
///
/// Values that match keys already existing on the session will be overwritten. Values should
/// already be JSON serialized.
pub(crate) fn set_session(
req: &mut ServiceRequest,
data: impl IntoIterator<Item = (String, String)>,
) {
let session = Session::get_session(&mut *req.extensions_mut());
let mut inner = session.0.borrow_mut();
inner.state.extend(data);
}
/// 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`).
pub(crate) fn get_changes<B>(
res: &mut ServiceResponse<B>,
) -> (SessionStatus, HashMap<String, String>) {
if let Some(s_impl) = res
.request()
.extensions()
.get::<Rc<RefCell<SessionInner>>>()
{
let state = mem::take(&mut s_impl.borrow_mut().state);
(s_impl.borrow().status.clone(), state)
} else {
(SessionStatus::Unchanged, HashMap::new())
}
}
pub(crate) fn get_session(extensions: &mut Extensions) -> Session {
if let Some(s_impl) = extensions.get::<Rc<RefCell<SessionInner>>>() {
return Session(Rc::clone(s_impl));
}
let inner = Rc::new(RefCell::new(SessionInner::default()));
extensions.insert(inner.clone());
Session(inner)
}
}
/// Extractor implementation for [`Session`]s.
///
/// # Examples
/// ```
/// # use actix_web::*;
/// use actix_session::Session;
///
/// #[get("/")]
/// async fn index(session: Session) -> Result<impl Responder> {
/// // access session data
/// if let Some(count) = session.get::<i32>("counter")? {
/// session.insert("counter", count + 1)?;
/// } else {
/// session.insert("counter", 1)?;
/// }
///
/// let count = session.get::<i32>("counter")?.unwrap();
/// Ok(format!("Counter: {}", count))
/// }
/// ```
impl FromRequest for Session {
type Error = Error;
type Future = Ready<Result<Session, Error>>;
#[inline]
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
ready(Ok(Session::get_session(&mut *req.extensions_mut())))
}
}

View File

@ -0,0 +1,31 @@
use actix_web::{
dev::{ServiceRequest, ServiceResponse},
HttpMessage, HttpRequest,
};
use crate::Session;
/// Extract a [`Session`] object from various `actix-web` types (e.g. `HttpRequest`,
/// `ServiceRequest`, `ServiceResponse`).
pub trait SessionExt {
/// Extract a [`Session`] object.
fn get_session(&self) -> Session;
}
impl SessionExt for HttpRequest {
fn get_session(&self) -> Session {
Session::get_session(&mut *self.extensions_mut())
}
}
impl SessionExt for ServiceRequest {
fn get_session(&self) -> Session {
Session::get_session(&mut *self.extensions_mut())
}
}
impl SessionExt for ServiceResponse {
fn get_session(&self) -> Session {
self.request().get_session()
}
}

View File

@ -0,0 +1,116 @@
use std::convert::TryInto;
use time::Duration;
use super::SessionKey;
use crate::storage::{
interface::{LoadError, SaveError, SessionState, UpdateError},
SessionStore,
};
/// Use the session key, stored in the session cookie, as storage backend for the session state.
///
/// ```no_run
/// use actix_web::{cookie::Key, web, App, HttpServer, HttpResponse, Error};
/// use actix_session::{SessionMiddleware, storage::CookieSessionStore};
///
/// // The secret key would usually be read from a configuration file/environment variables.
/// fn get_secret_key() -> Key {
/// # todo!()
/// // [...]
/// }
///
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let secret_key = get_secret_key();
/// HttpServer::new(move ||
/// App::new()
/// .wrap(SessionMiddleware::new(CookieSessionStore::default(), secret_key.clone()))
/// .default_service(web::to(|| HttpResponse::Ok())))
/// .bind(("127.0.0.1", 8080))?
/// .run()
/// .await
/// }
/// ```
///
/// # Limitations
/// Cookies are subject to size limits - we require session keys to be shorter than 4096 bytes. This
/// translates into a limit on the maximum size of the session state when using cookies as storage
/// backend.
///
/// The session cookie can always be inspected by end users via the developer tools exposed by their
/// browsers. We strongly recommend setting the policy to [`CookieContentSecurity::Private`] when
/// using cookies as storage backend.
///
/// There is no way to invalidate a session before its natural expiry when using cookies as the
/// storage backend.
///
/// [`CookieContentSecurity::Private`]: crate::CookieContentSecurity::Private
#[cfg_attr(docsrs, doc(cfg(feature = "cookie-session")))]
#[derive(Default)]
#[non_exhaustive]
pub struct CookieSessionStore;
#[async_trait::async_trait(?Send)]
impl SessionStore for CookieSessionStore {
async fn load(&self, session_key: &SessionKey) -> Result<Option<SessionState>, LoadError> {
serde_json::from_str(session_key.as_ref())
.map(Some)
.map_err(anyhow::Error::new)
.map_err(LoadError::Deserialization)
}
async fn save(
&self,
session_state: SessionState,
_ttl: &Duration,
) -> Result<SessionKey, SaveError> {
let session_key = serde_json::to_string(&session_state)
.map_err(anyhow::Error::new)
.map_err(SaveError::Serialization)?;
Ok(session_key
.try_into()
.map_err(Into::into)
.map_err(SaveError::Other)?)
}
async fn update(
&self,
_session_key: SessionKey,
session_state: SessionState,
ttl: &Duration,
) -> Result<SessionKey, UpdateError> {
self.save(session_state, ttl)
.await
.map_err(|err| match err {
SaveError::Serialization(err) => UpdateError::Serialization(err),
SaveError::Other(err) => UpdateError::Other(err),
})
}
async fn delete(&self, _session_key: &SessionKey) -> Result<(), anyhow::Error> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{storage::utils::generate_session_key, test_helpers::acceptance_test_suite};
#[actix_web::test]
async fn test_session_workflow() {
acceptance_test_suite(CookieSessionStore::default, false).await;
}
#[actix_web::test]
async fn loading_a_random_session_key_returns_deserialization_error() {
let store = CookieSessionStore::default();
let session_key = generate_session_key();
assert!(matches!(
store.load(&session_key).await.unwrap_err(),
LoadError::Deserialization(_),
));
}
}

View File

@ -0,0 +1,104 @@
use std::collections::HashMap;
use derive_more::Display;
use time::Duration;
use super::SessionKey;
pub(crate) type SessionState = HashMap<String, String>;
/// The interface to retrieve and save the current session data from/to the chosen storage backend.
///
/// You can provide your own custom session store backend by implementing this trait.
#[async_trait::async_trait(?Send)]
pub trait SessionStore {
/// Loads the session state associated to a session key.
async fn load(&self, session_key: &SessionKey) -> Result<Option<SessionState>, LoadError>;
/// Persist the session state for a newly created session.
///
/// Returns the corresponding session key.
async fn save(
&self,
session_state: SessionState,
ttl: &Duration,
) -> Result<SessionKey, SaveError>;
/// Updates the session state associated to a pre-existing session key.
async fn update(
&self,
session_key: SessionKey,
session_state: SessionState,
ttl: &Duration,
) -> Result<SessionKey, UpdateError>;
/// Deletes a session from the store.
async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error>;
}
// We cannot derive the `Error` implementation using `derive_more` for our custom errors:
// `derive_more`'s `#[error(source)]` attribute requires the source implement the `Error` trait,
// while it's actually enough for it to be able to produce a reference to a dyn Error.
/// Possible failures modes for [`SessionStore::load`].
#[derive(Debug, Display)]
pub enum LoadError {
/// Failed to deserialize session state.
#[display(fmt = "Failed to deserialize session state")]
Deserialization(anyhow::Error),
/// Something went wrong when retrieving the session state.
#[display(fmt = "Something went wrong when retrieving the session state")]
Other(anyhow::Error),
}
impl std::error::Error for LoadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Deserialization(err) => Some(err.as_ref()),
Self::Other(err) => Some(err.as_ref()),
}
}
}
/// Possible failures modes for [`SessionStore::save`].
#[derive(Debug, Display)]
pub enum SaveError {
/// Failed to serialize session state.
#[display(fmt = "Failed to serialize session state")]
Serialization(anyhow::Error),
/// Something went wrong when persisting the session state.
#[display(fmt = "Something went wrong when persisting the session state")]
Other(anyhow::Error),
}
impl std::error::Error for SaveError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Serialization(err) => Some(err.as_ref()),
Self::Other(err) => Some(err.as_ref()),
}
}
}
#[derive(Debug, Display)]
/// Possible failures modes for [`SessionStore::update`].
pub enum UpdateError {
/// Failed to serialize session state.
#[display(fmt = "Failed to serialize session state")]
Serialization(anyhow::Error),
/// Something went wrong when updating the session state.
#[display(fmt = "Something went wrong when updating the session state.")]
Other(anyhow::Error),
}
impl std::error::Error for UpdateError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Serialization(err) => Some(err.as_ref()),
Self::Other(err) => Some(err.as_ref()),
}
}
}

View File

@ -0,0 +1,28 @@
//! Pluggable storage backends for session state.
mod interface;
mod session_key;
pub use self::interface::{LoadError, SaveError, SessionStore, UpdateError};
pub use self::session_key::SessionKey;
#[cfg(feature = "cookie-session")]
mod cookie;
#[cfg(feature = "redis-actor-session")]
mod redis_actor;
#[cfg(feature = "redis-rs-session")]
mod redis_rs;
#[cfg(any(feature = "redis-actor-session", feature = "redis-rs-session"))]
mod utils;
#[cfg(feature = "cookie-session")]
pub use cookie::CookieSessionStore;
#[cfg(feature = "redis-actor-session")]
pub use redis_actor::{RedisActorSessionStore, RedisActorSessionStoreBuilder};
#[cfg(feature = "redis-rs-session")]
pub use redis_rs::{RedisSessionStore, RedisSessionStoreBuilder};

View File

@ -0,0 +1,294 @@
use actix::Addr;
use actix_redis::{resp_array, Command, RedisActor, RespValue};
use time::{self, Duration};
use super::SessionKey;
use crate::storage::{
interface::{LoadError, SaveError, SessionState, UpdateError},
utils::generate_session_key,
SessionStore,
};
/// Use Redis as session storage backend.
///
/// ```no_run
/// use actix_web::{web, App, HttpServer, HttpResponse, Error};
/// use actix_session::{SessionMiddleware, storage::RedisActorSessionStore};
/// use actix_web::cookie::Key;
///
/// // The secret key would usually be read from a configuration file/environment variables.
/// fn get_secret_key() -> Key {
/// # todo!()
/// // [...]
/// }
///
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let secret_key = get_secret_key();
/// let redis_connection_string = "127.0.0.1:6379";
/// HttpServer::new(move ||
/// App::new()
/// .wrap(
/// SessionMiddleware::new(
/// RedisActorSessionStore::new(redis_connection_string),
/// secret_key.clone()
/// )
/// )
/// .default_service(web::to(|| HttpResponse::Ok())))
/// .bind(("127.0.0.1", 8080))?
/// .run()
/// .await
/// }
/// ```
///
/// # Implementation notes
///
/// `RedisActorSessionStore` leverages `actix-redis`'s `RedisActor` implementation - each thread
/// worker gets its own connection to Redis.
///
/// ## Limitations
///
/// `RedisActorSessionStore` does not currently support establishing authenticated connections to
/// Redis. Use [`RedisSessionStore`] if you need TLS support.
///
/// [`RedisSessionStore`]: crate::storage::RedisSessionStore
#[cfg_attr(docsrs, doc(cfg(feature = "redis-actor-session")))]
pub struct RedisActorSessionStore {
configuration: CacheConfiguration,
addr: Addr<RedisActor>,
}
impl RedisActorSessionStore {
/// A fluent API to configure [`RedisActorSessionStore`].
///
/// It takes as input the only required input to create a new instance of
/// [`RedisActorSessionStore`]—a connection string for Redis.
pub fn builder<S: Into<String>>(connection_string: S) -> RedisActorSessionStoreBuilder {
RedisActorSessionStoreBuilder {
configuration: CacheConfiguration::default(),
connection_string: connection_string.into(),
}
}
/// Create a new instance of [`RedisActorSessionStore`] using the default configuration.
/// It takes as input the only required input to create a new instance of [`RedisActorSessionStore`] - a
/// connection string for Redis.
pub fn new<S: Into<String>>(connection_string: S) -> RedisActorSessionStore {
Self::builder(connection_string).build()
}
}
struct CacheConfiguration {
cache_keygen: Box<dyn Fn(&str) -> String>,
}
impl Default for CacheConfiguration {
fn default() -> Self {
Self {
cache_keygen: Box::new(str::to_owned),
}
}
}
/// A fluent builder to construct a [`RedisActorSessionStore`] instance with custom configuration
/// parameters.
#[cfg_attr(docsrs, doc(cfg(feature = "redis-actor-session")))]
#[must_use]
pub struct RedisActorSessionStoreBuilder {
connection_string: String,
configuration: CacheConfiguration,
}
impl RedisActorSessionStoreBuilder {
/// Set a custom cache key generation strategy, expecting a session key as input.
pub fn cache_keygen<F>(mut self, keygen: F) -> Self
where
F: Fn(&str) -> String + 'static,
{
self.configuration.cache_keygen = Box::new(keygen);
self
}
/// Finalise the builder and return a [`RedisActorSessionStore`] instance.
#[must_use]
pub fn build(self) -> RedisActorSessionStore {
RedisActorSessionStore {
configuration: self.configuration,
addr: RedisActor::start(self.connection_string),
}
}
}
#[async_trait::async_trait(?Send)]
impl SessionStore for RedisActorSessionStore {
async fn load(&self, session_key: &SessionKey) -> Result<Option<SessionState>, LoadError> {
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
let val = self
.addr
.send(Command(resp_array!["GET", cache_key]))
.await
.map_err(Into::into)
.map_err(LoadError::Other)?
.map_err(Into::into)
.map_err(LoadError::Other)?;
match val {
RespValue::Error(err) => Err(LoadError::Other(anyhow::anyhow!(err))),
RespValue::SimpleString(s) => Ok(serde_json::from_str(&s)
.map_err(Into::into)
.map_err(LoadError::Deserialization)?),
RespValue::BulkString(s) => Ok(serde_json::from_slice(&s)
.map_err(Into::into)
.map_err(LoadError::Deserialization)?),
_ => Ok(None),
}
}
async fn save(
&self,
session_state: SessionState,
ttl: &Duration,
) -> Result<SessionKey, SaveError> {
let body = serde_json::to_string(&session_state)
.map_err(Into::into)
.map_err(SaveError::Serialization)?;
let session_key = generate_session_key();
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
let cmd = Command(resp_array![
"SET",
cache_key,
body,
"NX", // NX: only set the key if it does not already exist
"EX", // EX: set expiry
format!("{}", ttl.whole_seconds())
]);
let result = self
.addr
.send(cmd)
.await
.map_err(Into::into)
.map_err(SaveError::Other)?
.map_err(Into::into)
.map_err(SaveError::Other)?;
match result {
RespValue::SimpleString(_) => Ok(session_key),
RespValue::Nil => Err(SaveError::Other(anyhow::anyhow!(
"Failed to save session state. A record with the same key already existed in Redis"
))),
err => Err(SaveError::Other(anyhow::anyhow!(
"Failed to save session state. {:?}",
err
))),
}
}
async fn update(
&self,
session_key: SessionKey,
session_state: SessionState,
ttl: &Duration,
) -> Result<SessionKey, UpdateError> {
let body = serde_json::to_string(&session_state)
.map_err(Into::into)
.map_err(UpdateError::Serialization)?;
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
let cmd = Command(resp_array![
"SET",
cache_key,
body,
"XX", // XX: Only set the key if it already exist.
"EX", // EX: set expiry
format!("{}", ttl.whole_seconds())
]);
let result = self
.addr
.send(cmd)
.await
.map_err(Into::into)
.map_err(UpdateError::Other)?
.map_err(Into::into)
.map_err(UpdateError::Other)?;
match result {
RespValue::Nil => {
// The SET operation was not performed because the XX condition was not verified.
// This can happen if the session state expired between the load operation and the
// update operation. Unlucky, to say the least. We fall back to the `save` routine
// to ensure that the new key is unique.
self.save(session_state, ttl)
.await
.map_err(|err| match err {
SaveError::Serialization(err) => UpdateError::Serialization(err),
SaveError::Other(err) => UpdateError::Other(err),
})
}
RespValue::SimpleString(_) => Ok(session_key),
val => Err(UpdateError::Other(anyhow::anyhow!(
"Failed to update session state. {:?}",
val
))),
}
}
async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error> {
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
let res = self
.addr
.send(Command(resp_array!["DEL", cache_key]))
.await?;
match res {
// Redis returns the number of deleted records
Ok(RespValue::Integer(_)) => Ok(()),
val => Err(anyhow::anyhow!(
"Failed to remove session from cache. {:?}",
val
)),
}
}
}
#[cfg(test)]
mod test {
use std::collections::HashMap;
use super::*;
use crate::test_helpers::acceptance_test_suite;
fn redis_actor_store() -> RedisActorSessionStore {
RedisActorSessionStore::new("127.0.0.1:6379")
}
#[actix_web::test]
async fn test_session_workflow() {
acceptance_test_suite(redis_actor_store, true).await;
}
#[actix_web::test]
async fn loading_a_missing_session_returns_none() {
let store = redis_actor_store();
let session_key = generate_session_key();
assert!(store.load(&session_key).await.unwrap().is_none());
}
#[actix_web::test]
async fn updating_of_an_expired_state_is_handled_gracefully() {
let store = redis_actor_store();
let session_key = generate_session_key();
let initial_session_key = session_key.as_ref().to_owned();
let updated_session_key = store
.update(session_key, HashMap::new(), &time::Duration::seconds(1))
.await
.unwrap();
assert_ne!(initial_session_key, updated_session_key.as_ref());
}
}

View File

@ -0,0 +1,297 @@
use std::sync::Arc;
use redis::{aio::ConnectionManager, AsyncCommands, Value};
use time::{self, Duration};
use super::SessionKey;
use crate::storage::{
interface::{LoadError, SaveError, SessionState, UpdateError},
utils::generate_session_key,
SessionStore,
};
/// Use Redis as session storage backend.
///
/// ```no_run
/// use actix_web::{web, App, HttpServer, HttpResponse, Error};
/// use actix_session::{SessionMiddleware, storage::RedisSessionStore};
/// use actix_web::cookie::Key;
///
/// // The secret key would usually be read from a configuration file/environment variables.
/// fn get_secret_key() -> Key {
/// # todo!()
/// // [...]
/// }
///
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let secret_key = get_secret_key();
/// let redis_connection_string = "redis://127.0.0.1:6379";
/// let store = RedisSessionStore::new(redis_connection_string).await.unwrap();
/// HttpServer::new(move ||
/// App::new()
/// .wrap(SessionMiddleware::new(
/// store.clone(),
/// secret_key.clone()
/// ))
/// .default_service(web::to(|| HttpResponse::Ok())))
/// .bind(("127.0.0.1", 8080))?
/// .run()
/// .await
/// }
/// ```
///
/// # TLS support
/// Add the `redis-rs-tls-session` feature flag to enable TLS support. You can then establish a TLS
/// connection to Redis using the `rediss://` URL scheme:
///
/// ```no_run
/// use actix_session::{storage::RedisSessionStore};
///
/// # actix_web::rt::System::new().block_on(async {
/// let redis_connection_string = "rediss://127.0.0.1:6379";
/// let store = RedisSessionStore::new(redis_connection_string).await.unwrap();
/// # })
/// ```
///
/// # Implementation notes
/// `RedisSessionStore` leverages [`redis-rs`] as Redis client.
///
/// [`redis-rs`]: https://github.com/mitsuhiko/redis-rs
#[cfg_attr(docsrs, doc(cfg(feature = "redis-rs-session")))]
#[derive(Clone)]
pub struct RedisSessionStore {
configuration: CacheConfiguration,
client: ConnectionManager,
}
#[derive(Clone)]
struct CacheConfiguration {
cache_keygen: Arc<dyn Fn(&str) -> String + Send + Sync>,
}
impl Default for CacheConfiguration {
fn default() -> Self {
Self {
cache_keygen: Arc::new(str::to_owned),
}
}
}
impl RedisSessionStore {
/// A fluent API to configure [`RedisSessionStore`].
/// It takes as input the only required input to create a new instance of [`RedisSessionStore`] - a
/// connection string for Redis.
pub fn builder<S: Into<String>>(connection_string: S) -> RedisSessionStoreBuilder {
RedisSessionStoreBuilder {
configuration: CacheConfiguration::default(),
connection_string: connection_string.into(),
}
}
/// Create a new instance of [`RedisSessionStore`] using the default configuration.
/// It takes as input the only required input to create a new instance of [`RedisSessionStore`] - a
/// connection string for Redis.
pub async fn new<S: Into<String>>(
connection_string: S,
) -> Result<RedisSessionStore, anyhow::Error> {
Self::builder(connection_string).build().await
}
}
/// A fluent builder to construct a [`RedisSessionStore`] instance with custom configuration
/// parameters.
///
/// [`RedisSessionStore`]: crate::storage::RedisSessionStore
#[cfg_attr(docsrs, doc(cfg(feature = "redis-rs-session")))]
#[must_use]
pub struct RedisSessionStoreBuilder {
connection_string: String,
configuration: CacheConfiguration,
}
impl RedisSessionStoreBuilder {
/// Set a custom cache key generation strategy, expecting a session key as input.
pub fn cache_keygen<F>(mut self, keygen: F) -> Self
where
F: Fn(&str) -> String + 'static + Send + Sync,
{
self.configuration.cache_keygen = Arc::new(keygen);
self
}
/// Finalise the builder and return a [`RedisActorSessionStore`] instance.
///
/// [`RedisActorSessionStore`]: crate::storage::RedisActorSessionStore
pub async fn build(self) -> Result<RedisSessionStore, anyhow::Error> {
let client = ConnectionManager::new(redis::Client::open(self.connection_string)?).await?;
Ok(RedisSessionStore {
configuration: self.configuration,
client,
})
}
}
#[async_trait::async_trait(?Send)]
impl SessionStore for RedisSessionStore {
async fn load(&self, session_key: &SessionKey) -> Result<Option<SessionState>, LoadError> {
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
let value: Option<String> = self
.client
.clone()
.get(cache_key)
.await
.map_err(Into::into)
.map_err(LoadError::Other)?;
match value {
None => Ok(None),
Some(value) => Ok(serde_json::from_str(&value)
.map_err(Into::into)
.map_err(LoadError::Deserialization)?),
}
}
async fn save(
&self,
session_state: SessionState,
ttl: &Duration,
) -> Result<SessionKey, SaveError> {
let body = serde_json::to_string(&session_state)
.map_err(Into::into)
.map_err(SaveError::Serialization)?;
let session_key = generate_session_key();
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
redis::cmd("SET")
.arg(&[
&cache_key,
&body,
"NX", // NX: only set the key if it does not already exist
"EX", // EX: set expiry
&format!("{}", ttl.whole_seconds()),
])
.query_async(&mut self.client.clone())
.await
.map_err(Into::into)
.map_err(SaveError::Other)?;
Ok(session_key)
}
async fn update(
&self,
session_key: SessionKey,
session_state: SessionState,
ttl: &Duration,
) -> Result<SessionKey, UpdateError> {
let body = serde_json::to_string(&session_state)
.map_err(Into::into)
.map_err(UpdateError::Serialization)?;
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
let v: redis::Value = redis::cmd("SET")
.arg(&[
&cache_key,
&body,
"XX", // XX: Only set the key if it already exist.
"EX", // EX: set expiry
&format!("{}", ttl.whole_seconds()),
])
.query_async(&mut self.client.clone())
.await
.map_err(Into::into)
.map_err(UpdateError::Other)?;
match v {
Value::Nil => {
// The SET operation was not performed because the XX condition was not verified.
// This can happen if the session state expired between the load operation and the
// update operation. Unlucky, to say the least. We fall back to the `save` routine
// to ensure that the new key is unique.
self.save(session_state, ttl)
.await
.map_err(|err| match err {
SaveError::Serialization(err) => UpdateError::Serialization(err),
SaveError::Other(err) => UpdateError::Other(err),
})
}
Value::Int(_) | Value::Okay | Value::Status(_) => Ok(session_key),
val => Err(UpdateError::Other(anyhow::anyhow!(
"Failed to update session state. {:?}",
val
))),
}
}
async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error> {
let cache_key = (self.configuration.cache_keygen)(session_key.as_ref());
self.client
.clone()
.del(&cache_key)
.await
.map_err(Into::into)
.map_err(UpdateError::Other)?;
Ok(())
}
}
#[cfg(test)]
mod test {
use std::collections::HashMap;
use redis::AsyncCommands;
use super::*;
use crate::test_helpers::acceptance_test_suite;
async fn redis_store() -> RedisSessionStore {
RedisSessionStore::new("redis://127.0.0.1:6379")
.await
.unwrap()
}
#[actix_web::test]
async fn test_session_workflow() {
let redis_store = redis_store().await;
acceptance_test_suite(move || redis_store.clone(), true).await;
}
#[actix_web::test]
async fn loading_a_missing_session_returns_none() {
let store = redis_store().await;
let session_key = generate_session_key();
assert!(store.load(&session_key).await.unwrap().is_none());
}
#[actix_web::test]
async fn loading_an_invalid_session_state_returns_deserialization_error() {
let store = redis_store().await;
let session_key = generate_session_key();
store
.client
.clone()
.set::<_, _, ()>(session_key.as_ref(), "random-thing-which-is-not-json")
.await
.unwrap();
assert!(matches!(
store.load(&session_key).await.unwrap_err(),
LoadError::Deserialization(_),
));
}
#[actix_web::test]
async fn updating_of_an_expired_state_is_handled_gracefully() {
let store = redis_store().await;
let session_key = generate_session_key();
let initial_session_key = session_key.as_ref().to_owned();
let updated_session_key = store
.update(session_key, HashMap::new(), &time::Duration::seconds(1))
.await
.unwrap();
assert_ne!(initial_session_key, updated_session_key.as_ref());
}
}

View File

@ -0,0 +1,59 @@
use std::convert::TryFrom;
use derive_more::{Display, From};
/// A session key, the string stored in a client-side cookie to associate a user
/// with its session state on the backend.
///
/// ## Validation
///
/// Session keys are stored as cookies, therefore they cannot be arbitrary long.
/// We require session keys to be smaller than 4064 bytes.
///
/// ```rust
/// use std::convert::TryInto;
/// use actix_session::storage::SessionKey;
///
/// let key: String = std::iter::repeat('a').take(4065).collect();
/// let session_key: Result<SessionKey, _> = key.try_into();
/// assert!(session_key.is_err());
/// ```
#[derive(Debug, PartialEq, Eq)]
pub struct SessionKey(String);
impl TryFrom<String> for SessionKey {
type Error = InvalidSessionKeyError;
fn try_from(v: String) -> Result<Self, Self::Error> {
if v.len() > 4064 {
return Err(anyhow::anyhow!(
"The session key is bigger than 4064 bytes, the upper limit on cookie content."
)
.into());
}
Ok(SessionKey(v))
}
}
impl AsRef<str> for SessionKey {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<SessionKey> for String {
fn from(k: SessionKey) -> Self {
k.0
}
}
#[derive(Debug, Display, From)]
#[display(fmt = "The provided string is not a valid session key")]
pub struct InvalidSessionKeyError(anyhow::Error);
impl std::error::Error for InvalidSessionKeyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(self.0.as_ref())
}
}

View File

@ -0,0 +1,19 @@
use std::convert::TryInto;
use rand::{distributions::Alphanumeric, rngs::OsRng, Rng as _};
use crate::storage::SessionKey;
/// Session key generation routine that follows [OWASP recommendations].
///
/// [OWASP recommendations]: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-id-entropy
pub(crate) fn generate_session_key() -> SessionKey {
let value = std::iter::repeat(())
.map(|()| OsRng.sample(Alphanumeric))
.take(64)
.collect::<Vec<_>>();
// These unwraps will never panic because pre-conditions are always verified
// (i.e. length and character set)
String::from_utf8(value).unwrap().try_into().unwrap()
}

View File

@ -0,0 +1,56 @@
use actix_session::{storage::CookieSessionStore, Session, SessionMiddleware};
use actix_web::{
cookie::{time::Duration, Key},
test, web, App, Responder,
};
async fn login(session: Session) -> impl Responder {
session.insert("user_id", "id").unwrap();
"Logged in"
}
async fn logout(session: Session) -> impl Responder {
session.purge();
"Logged out"
}
#[actix_web::test]
async fn cookie_storage() -> std::io::Result<()> {
let signing_key = Key::generate();
let app = test::init_service(
App::new()
.wrap(
SessionMiddleware::builder(CookieSessionStore::default(), signing_key.clone())
.cookie_path("/test".to_string())
.cookie_domain(Some("localhost".to_string()))
.build(),
)
.route("/login", web::post().to(login))
.route("/logout", web::post().to(logout)),
)
.await;
let login_request = test::TestRequest::post().uri("/login").to_request();
let login_response = test::call_service(&app, login_request).await;
let session_cookie = login_response.response().cookies().next().unwrap();
assert_eq!(session_cookie.name(), "id");
assert_eq!(session_cookie.path().unwrap(), "/test");
assert!(session_cookie.secure().unwrap());
assert!(session_cookie.http_only().unwrap());
assert!(session_cookie.max_age().is_none());
assert_eq!(session_cookie.domain().unwrap(), "localhost");
let logout_request = test::TestRequest::post()
.cookie(session_cookie)
.uri("/logout")
.to_request();
let logout_response = test::call_service(&app, logout_request).await;
let deletion_cookie = logout_response.response().cookies().next().unwrap();
assert_eq!(deletion_cookie.name(), "id");
assert_eq!(deletion_cookie.path().unwrap(), "/test");
assert!(deletion_cookie.secure().is_none());
assert!(deletion_cookie.http_only().unwrap());
assert_eq!(deletion_cookie.max_age().unwrap(), Duration::ZERO);
assert_eq!(deletion_cookie.domain().unwrap(), "localhost");
Ok(())
}

View File

@ -0,0 +1,70 @@
use actix_session::{SessionExt, SessionStatus};
use actix_web::{test, HttpResponse};
#[actix_web::test]
async fn session() {
let req = test::TestRequest::default().to_srv_request();
let session = req.get_session();
session.insert("key", "value").unwrap();
let res = session.get::<String>("key").unwrap();
assert_eq!(res, Some("value".to_string()));
session.insert("key2", "value2").unwrap();
session.remove("key");
let res = req.into_response(HttpResponse::Ok().finish());
let state: Vec<_> = res.get_session().entries().clone().into_iter().collect();
assert_eq!(
state.as_slice(),
[("key2".to_string(), "\"value2\"".to_string())]
);
}
#[actix_web::test]
async fn get_session() {
let req = test::TestRequest::default().to_srv_request();
let session = req.get_session();
session.insert("key", true).unwrap();
let res = session.get("key").unwrap();
assert_eq!(res, Some(true));
}
#[actix_web::test]
async fn get_session_from_request_head() {
let req = test::TestRequest::default().to_srv_request();
let session = req.get_session();
session.insert("key", 10).unwrap();
let res = session.get::<u32>("key").unwrap();
assert_eq!(res, Some(10));
}
#[actix_web::test]
async fn purge_session() {
let req = test::TestRequest::default().to_srv_request();
let session = req.get_session();
assert_eq!(session.status(), SessionStatus::Unchanged);
session.purge();
assert_eq!(session.status(), SessionStatus::Purged);
}
#[actix_web::test]
async fn renew_session() {
let req = test::TestRequest::default().to_srv_request();
let session = req.get_session();
assert_eq!(session.status(), SessionStatus::Unchanged);
session.renew();
assert_eq!(session.status(), SessionStatus::Renewed);
}
#[actix_web::test]
async fn session_entries() {
let req = test::TestRequest::default().to_srv_request();
let session = req.get_session();
session.insert("test_str", "val").unwrap();
session.insert("test_str", 1).unwrap();
let map = session.entries();
map.contains_key("test_str");
map.contains_key("test_num");
}