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

View File

@ -22,10 +22,15 @@ jobs:
services: services:
redis: redis:
image: redis:5.0.7 image: redis:6
ports: ports:
- 6379:6379 - 6379:6379
options: --entrypoint redis-server options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
--entrypoint redis-server
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@ -126,7 +131,7 @@ jobs:
timeout-minutes: 40 timeout-minutes: 40
with: with:
command: ci-test command: ci-test
args: --exclude=actix-redis -- --nocapture args: --exclude=actix-redis --exclude=actix-session -- --nocapture
- name: Clear the cargo caches - name: Clear the cargo caches
run: | run: |

View File

@ -12,8 +12,12 @@ members = [
] ]
[patch.crates-io] [patch.crates-io]
actix-cors = { path = "actix-cors" } actix-cors = { path = "./actix-cors" }
actix-session = { path = "actix-session" } actix-identity = { path = "./actix-identity" }
actix-protobuf = { path = "./actix-protobuf" }
actix-redis = { path = "./actix-redis" }
actix-session = { path = "./actix-session" }
actix-web-httpauth = { path = "./actix-web-httpauth" }
# uncomment to quickly test against local actix-web repo # uncomment to quickly test against local actix-web repo
# actix-http = { path = "../actix-web/actix-http" } # actix-http = { path = "../actix-web/actix-http" }

View File

@ -1,6 +1,13 @@
# Changes # Changes
## Unreleased - 2021-xx-xx ## Unreleased - 2021-xx-xx
### Removed
- `RedisSession` has been removed. Check out `RedisActorSessionStore` in `actix-session` for a session store backed by Redis using `actix-redis`. [#212]
### Changed
- Update `redis-async` dependency to `0.12`. [#212]
[#212]: https://github.com/actix/actix-extras/pull/212
## 0.10.0 - 2022-03-01 ## 0.10.0 - 2022-03-01

View File

@ -2,7 +2,7 @@
name = "actix-redis" name = "actix-redis"
version = "0.10.0" version = "0.10.0"
authors = ["Nikolay Kim <fafhrd91@gmail.com>"] authors = ["Nikolay Kim <fafhrd91@gmail.com>"]
description = "Redis integration for Actix and session store for Actix Web" description = "Redis integration for Actix"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
keywords = ["actix", "redis", "async", "session"] keywords = ["actix", "redis", "async", "session"]
homepage = "https://actix.rs" homepage = "https://actix.rs"
@ -19,14 +19,7 @@ path = "src/lib.rs"
default = ["web"] default = ["web"]
# actix-web integration # actix-web integration
web = [ web = ["actix-web"]
"actix-web/cookies",
"actix-web/secure-cookies",
"actix-session/cookie-session",
"rand",
"serde",
"serde_json"
]
[dependencies] [dependencies]
actix = { version = "0.12", default-features = false } actix = { version = "0.12", default-features = false }
@ -35,32 +28,17 @@ actix-service = "2"
actix-tls = { version = "3", default-features = false, features = ["connect"] } actix-tls = { version = "3", default-features = false, features = ["connect"] }
log = "0.4.6" log = "0.4.6"
backoff = "0.2.1" backoff = "0.4.0"
derive_more = "0.99.5" derive_more = "0.99.5"
futures-core = { version = "0.3.7", default-features = false } futures-core = { version = "0.3.7", default-features = false }
redis2 = { package = "redis", version = "0.19", features = ["tokio-comp", "tokio-native-tls-comp"] } redis-async = { version = "0.12", default-features = false, features = ["tokio10"] }
redis-async = { version = "0.8", default-features = false, features = ["tokio10"] }
time = "0.3" time = "0.3"
tokio = { version = "1.13.1", features = ["sync"] } tokio = { version = "1.13.1", features = ["sync"] }
tokio-util = "0.6" tokio-util = "0.6.1"
# web
actix-web = { version = "4", default_features = false, optional = true } actix-web = { version = "4", default_features = false, optional = true }
actix-session = { version = "0.5", optional = true }
rand = { version = "0.8", optional = true }
serde = { version = "1.0.101", optional = true }
serde_json = { version = "1.0.40", optional = true }
[dev-dependencies] [dev-dependencies]
actix-test = "0.1.0-beta.12" actix-test = "0.1.0-beta.12"
actix-web = { version = "4", default_features = false, features = ["macros"] } actix-web = { version = "4", default_features = false, features = ["macros"] }
env_logger = "0.9" env_logger = "0.9"
serde = { version = "1.0.101", features = ["derive"] } serde = { version = "1.0.101", features = ["derive"] }
[[example]]
name = "basic"
required-features = ["web"]
[[example]]
name = "authentication"
required-features = ["web"]

View File

@ -1,6 +1,6 @@
# actix-redis # actix-redis
> Redis integration for Actix and session store for Actix Web. > Redis integration for Actix.
[![crates.io](https://img.shields.io/crates/v/actix-redis?label=latest)](https://crates.io/crates/actix-redis) [![crates.io](https://img.shields.io/crates/v/actix-redis?label=latest)](https://crates.io/crates/actix-redis)
[![Documentation](https://docs.rs/actix-redis/badge.svg?version=0.10.0)](https://docs.rs/actix-redis/0.10.0) [![Documentation](https://docs.rs/actix-redis/badge.svg?version=0.10.0)](https://docs.rs/actix-redis/0.10.0)
@ -12,36 +12,3 @@
- [API Documentation](https://docs.rs/actix-redis) - [API Documentation](https://docs.rs/actix-redis)
- [Example Project](https://github.com/actix/examples/tree/master/auth/redis-session) - [Example Project](https://github.com/actix/examples/tree/master/auth/redis-session)
- Minimum Supported Rust Version (MSRV): 1.54 - Minimum Supported Rust Version (MSRV): 1.54
## Redis Session Backend
Use redis as session storage.
You need to pass an address of the redis server and random value to the
constructor of `RedisSession`. This is private key for cookie session,
When this value is changed, all session data is lost.
Note that whatever you write into your session is visible by the user (but not modifiable).
Constructor panics if key length is less than 32 bytes.
```rust
use actix_web::{App, HttpServer, middleware::Logger};
use actix_web::web::{resource, get}
use actix_redis::RedisSession;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(move || App::new()
// cookie session middleware
.wrap(RedisSession::new("127.0.0.1:6379", &[0; 32]))
// enable logger
.wrap(Logger::default())
// register simple route, handle all methods
.service(resource("/").route(get().to(index)))
)
.bind("127.0.0.1:8080")?
.run()
.await
}
```

View File

@ -1,4 +1,4 @@
//! Redis integration for Actix and session store for Actix Web. //! Redis integration for `actix`.
#![deny(rust_2018_idioms, nonstandard_style)] #![deny(rust_2018_idioms, nonstandard_style)]
#![warn(future_incompatible)] #![warn(future_incompatible)]
@ -8,14 +8,7 @@ pub use redis::{Command, RedisActor};
use derive_more::{Display, Error, From}; use derive_more::{Display, Error, From};
#[cfg(feature = "web")] /// General purpose `actix-redis` error.
mod session;
#[cfg(feature = "web")]
pub use actix_web::cookie::SameSite;
#[cfg(feature = "web")]
pub use session::RedisSession;
/// General purpose actix redis error
#[derive(Debug, Display, Error, From)] #[derive(Debug, Display, Error, From)]
pub enum Error { pub enum Error {
#[display(fmt = "Redis error {}", _0)] #[display(fmt = "Redis error {}", _0)]
@ -34,3 +27,4 @@ impl actix_web::ResponseError for Error {}
// re-export // re-export
pub use redis_async::error::Error as RespError; pub use redis_async::error::Error as RespError;
pub use redis_async::resp::RespValue; pub use redis_async::resp::RespValue;
pub use redis_async::resp_array;

View File

@ -1,684 +0,0 @@
use std::{collections::HashMap, iter, rc::Rc};
use actix::prelude::*;
use actix_service::{Service, Transform};
use actix_session::{Session, SessionStatus};
use actix_web::{
cookie::{Cookie, CookieJar, Key, SameSite},
dev::{ServiceRequest, ServiceResponse},
error,
http::header::{self, HeaderValue},
Error,
};
use futures_core::future::LocalBoxFuture;
use rand::{distributions::Alphanumeric, rngs::OsRng, Rng};
use redis_async::{resp::RespValue, resp_array};
use time::{self, Duration, OffsetDateTime};
use crate::redis::{Command, RedisActor};
/// Use redis as session storage.
///
/// You need to pass an address of the redis server and random value to the
/// constructor of `RedisSession`. This is private key for cookie
/// session, When this value is changed, all session data is lost.
///
/// Constructor panics if key length is less than 32 bytes.
pub struct RedisSession(Rc<Inner>);
impl RedisSession {
/// Create new redis session backend
///
/// * `addr` - address of the redis server
pub fn new<S: Into<String>>(addr: S, key: &[u8]) -> RedisSession {
RedisSession(Rc::new(Inner {
key: Key::derive_from(key),
cache_keygen: Box::new(|key: &str| format!("session:{}", &key)),
ttl: "7200".to_owned(),
addr: RedisActor::start(addr),
name: "actix-session".to_owned(),
path: "/".to_owned(),
domain: None,
secure: false,
max_age: Some(Duration::days(7)),
same_site: None,
http_only: true,
}))
}
/// Set time to live in seconds for session value.
pub fn ttl(mut self, ttl: u32) -> Self {
Rc::get_mut(&mut self.0).unwrap().ttl = format!("{}", ttl);
self
}
/// Set custom cookie name for session ID.
pub fn cookie_name(mut self, name: &str) -> Self {
Rc::get_mut(&mut self.0).unwrap().name = name.to_owned();
self
}
/// Set custom cookie path.
pub fn cookie_path(mut self, path: &str) -> Self {
Rc::get_mut(&mut self.0).unwrap().path = path.to_owned();
self
}
/// Set custom cookie domain.
pub fn cookie_domain(mut self, domain: &str) -> Self {
Rc::get_mut(&mut self.0).unwrap().domain = Some(domain.to_owned());
self
}
/// Set custom cookie secure.
///
/// If the `secure` field is set, a cookie will only be transmitted when the
/// connection is secure - i.e. `https`.
///
/// Default is false.
pub fn cookie_secure(mut self, secure: bool) -> Self {
Rc::get_mut(&mut self.0).unwrap().secure = secure;
self
}
/// Set custom cookie max-age.
///
/// Use `None` for session-only cookies.
pub fn cookie_max_age(mut self, max_age: impl Into<Option<Duration>>) -> Self {
Rc::get_mut(&mut self.0).unwrap().max_age = max_age.into();
self
}
/// Set custom cookie `SameSite` attribute.
///
/// By default, the attribute is omitted.
pub fn cookie_same_site(mut self, same_site: SameSite) -> Self {
Rc::get_mut(&mut self.0).unwrap().same_site = Some(same_site);
self
}
/// Set custom cookie `HttpOnly` policy.
///
/// Default is true.
pub fn cookie_http_only(mut self, http_only: bool) -> Self {
Rc::get_mut(&mut self.0).unwrap().http_only = http_only;
self
}
/// Set a custom cache key generation strategy, expecting session key as input.
pub fn cache_keygen(mut self, keygen: Box<dyn Fn(&str) -> String>) -> Self {
Rc::get_mut(&mut self.0).unwrap().cache_keygen = keygen;
self
}
}
impl<S, B> Transform<S, ServiceRequest> for RedisSession
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = S::Error;
type Transform = RedisSessionMiddleware<S>;
type InitError = ();
type Future = LocalBoxFuture<'static, Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
let inner = self.0.clone();
Box::pin(async {
Ok(RedisSessionMiddleware {
service: Rc::new(service),
inner,
})
})
}
}
/// Cookie session middleware
pub struct RedisSessionMiddleware<S: 'static> {
service: Rc<S>,
inner: Rc<Inner>,
}
impl<S, B> Service<ServiceRequest> for RedisSessionMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
actix_service::forward_ready!(service);
fn call(&self, mut req: ServiceRequest) -> Self::Future {
let srv = Rc::clone(&self.service);
let inner = Rc::clone(&self.inner);
Box::pin(async move {
let state = inner.load(&req).await?;
let value = if let Some((state, value)) = state {
Session::set_session(&mut req, state);
Some(value)
} else {
None
};
let mut res = srv.call(req).await?;
match Session::get_changes(&mut res) {
(SessionStatus::Unchanged, _) => {
// If the session already exists, we don't need to update the state stored in Redis
// If the session is new, creating an empty session in Redis is unnecessary overhead
Ok(res)
}
(SessionStatus::Changed, state) => inner.update(res, state, value).await,
(SessionStatus::Purged, _) => {
if let Some(val) = value {
inner.clear_cache(val).await?;
match inner.remove_cookie(&mut res) {
Ok(_) => Ok(res),
Err(_err) => Err(error::ErrorInternalServerError(_err)),
}
} else {
Err(error::ErrorInternalServerError("unexpected"))
}
}
(SessionStatus::Renewed, state) => {
if let Some(val) = value {
inner.clear_cache(val).await?;
inner.update(res, state, None).await
} else {
inner.update(res, state, None).await
}
}
}
})
}
}
struct Inner {
key: Key,
cache_keygen: Box<dyn Fn(&str) -> String>,
ttl: String,
addr: Addr<RedisActor>,
name: String,
path: String,
domain: Option<String>,
secure: bool,
max_age: Option<Duration>,
same_site: Option<SameSite>,
http_only: bool,
}
impl Inner {
async fn load(
&self,
req: &ServiceRequest,
) -> Result<Option<(HashMap<String, String>, String)>, Error> {
// wrapped in block to avoid holding `Ref` (from `req.cookies`) across await point
let (value, cache_key) = {
let cookies = if let Ok(cookies) = req.cookies() {
cookies
} else {
return Ok(None);
};
if let Some(cookie) = cookies.iter().find(|&cookie| cookie.name() == self.name) {
let mut jar = CookieJar::new();
jar.add_original(cookie.clone());
if let Some(cookie) = jar.signed(&self.key).get(&self.name) {
let value = cookie.value().to_owned();
let cache_key = (self.cache_keygen)(cookie.value());
(value, cache_key)
} else {
return Ok(None);
}
} else {
return Ok(None);
}
};
let val = self
.addr
.send(Command(resp_array!["GET", cache_key]))
.await
.map_err(error::ErrorInternalServerError)?
.map_err(error::ErrorInternalServerError)?;
match val {
RespValue::Error(err) => {
return Err(error::ErrorInternalServerError(err));
}
RespValue::SimpleString(s) => {
if let Ok(val) = serde_json::from_str(&s) {
return Ok(Some((val, value)));
}
}
RespValue::BulkString(s) => {
if let Ok(val) = serde_json::from_slice(&s) {
return Ok(Some((val, value)));
}
}
_ => {}
}
Ok(None)
}
async fn update<B>(
&self,
mut res: ServiceResponse<B>,
state: impl Iterator<Item = (String, String)>,
value: Option<String>,
) -> Result<ServiceResponse<B>, Error> {
let (value, jar) = if let Some(value) = value {
(value, None)
} else {
let value = iter::repeat(())
.map(|()| OsRng.sample(Alphanumeric))
.take(32)
.collect::<Vec<_>>();
let value = String::from_utf8(value).unwrap_or_default();
// prepare session id cookie
let mut cookie = Cookie::new(self.name.clone(), value.clone());
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(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);
}
// set cookie
let mut jar = CookieJar::new();
jar.signed_mut(&self.key).add(cookie);
(value, Some(jar))
};
let cache_key = (self.cache_keygen)(&value);
let state: HashMap<_, _> = state.collect();
let body = match serde_json::to_string(&state) {
Err(err) => return Err(err.into()),
Ok(body) => body,
};
let cmd = Command(resp_array!["SET", cache_key, body, "EX", &self.ttl]);
self.addr
.send(cmd)
.await
.map_err(error::ErrorInternalServerError)?
.map_err(error::ErrorInternalServerError)?;
if let Some(jar) = jar {
for cookie in jar.delta() {
let val = HeaderValue::from_str(&cookie.to_string())?;
res.headers_mut().append(header::SET_COOKIE, val);
}
}
Ok(res)
}
/// Removes cache entry.
async fn clear_cache(&self, key: String) -> Result<(), Error> {
let cache_key = (self.cache_keygen)(&key);
let res = self
.addr
.send(Command(resp_array!["DEL", cache_key]))
.await
.map_err(error::ErrorInternalServerError)?;
match res {
// redis responds with number of deleted records
Ok(RespValue::Integer(x)) if x > 0 => Ok(()),
_ => Err(error::ErrorInternalServerError(
"failed to remove session from cache",
)),
}
}
/// Invalidates session cookie.
fn remove_cookie<B>(&self, res: &mut ServiceResponse<B>) -> Result<(), Error> {
let mut cookie = Cookie::named(self.name.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()).map_err(error::ErrorInternalServerError)?;
res.headers_mut().append(header::SET_COOKIE, val);
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
use actix_session::Session;
use actix_web::{
middleware, web,
web::{get, post, resource},
App, HttpResponse, Result,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
#[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))
}
#[actix_web::test]
async fn test_session_workflow() {
// 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}
// 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}
// 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}
// Step 4: POST again to do_something, including session cookie #1 in request
// - updates session state in redis: {"counter": 2}
// - response should be: {"counter": 2, "user_id": None}
// Step 5: POST to login, including session cookie #1 in request
// - set-cookie actix-session will be in response (session cookie #2)
// - updates session state in redis: {"counter": 2, "user_id": "ferris"}
// Step 6: GET index, including session cookie #2 in request
// - response should be: {"counter": 2, "user_id": "ferris"}
// Step 7: POST again to do_something, including session cookie #2 in request
// - updates session state in redis: {"counter": 3, "user_id": "ferris"}
// - response should be: {"counter": 3, "user_id": "ferris"}
// Step 8: GET index, including session cookie #1 in request
// - set-cookie actix-session should NOT be in response (session data is empty)
// - response should be: {"counter": 0, "user_id": None}
// Step 9: POST to logout, including session cookie #2
// - set-cookie actix-session will be in response with session cookie #2
// invalidation logic
// Step 10: GET index, including session cookie #2 in request
// - set-cookie actix-session should NOT be in response (session data is empty)
// - response should be: {"counter": 0, "user_id": None}
let srv = actix_test::start(|| {
App::new()
.wrap(RedisSession::new("127.0.0.1:6379", &[0; 32]).cookie_name("test-session"))
.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 resp_2 = req_2.await.unwrap();
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
// - updates session state in redis: {"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
}
);
// Step 5: POST to login, including session cookie #1 in request
// - set-cookie actix-session will be in response (session cookie #2)
// - updates session state in redis: {"counter": 2, "user_id": "ferris"}
let req_5 = srv
.post("/login")
.cookie(cookie_1.clone())
.send_json(&json!({"user_id": "ferris"}));
let mut resp_5 = req_5.await.unwrap();
let cookie_2 = resp_5
.cookies()
.unwrap()
.clone()
.into_iter()
.find(|c| c.name() == "test-session")
.unwrap();
assert_ne!(cookie_1.value(), cookie_2.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 #2 in request
// - response should be: {"counter": 2, "user_id": "ferris"}
let req_6 = srv.get("/").cookie(cookie_2.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 #2 in request
// - updates session state in redis: {"counter": 3, "user_id": "ferris"}
// - response should be: {"counter": 3, "user_id": "ferris"}
let req_7 = srv.post("/do_something").cookie(cookie_2.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 #1 in request
// - set-cookie actix-session should NOT be in response (session data is empty)
// - response should be: {"counter": 0, "user_id": None}
let req_8 = srv.get("/").cookie(cookie_1.clone()).send();
let mut resp_8 = req_8.await.unwrap();
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
}
);
// Step 9: POST to logout, including session cookie #2
// - set-cookie actix-session will be in response with session cookie #2
// invalidation logic
let req_9 = srv.post("/logout").cookie(cookie_2.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_ne!(
OffsetDateTime::now_utc().year(),
cookie_3
.expires()
.map(|t| t.datetime().expect("Expiration is a datetime").year())
.unwrap()
);
// Step 10: GET index, including session cookie #2 in request
// - set-cookie actix-session should NOT be in response (session data is empty)
// - response should be: {"counter": 0, "user_id": None}
let req_10 = srv.get("/").cookie(cookie_2.clone()).send();
let mut resp_10 = req_10.await.unwrap();
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
}
);
}
#[actix_web::test]
async fn test_max_age_session_only() {
//
// Test that removing max_age results in a session-only cookie
//
let srv = actix_test::start(|| {
App::new()
.wrap(
RedisSession::new("127.0.0.1:6379", &[0; 32])
.cookie_name("test-session")
.cookie_max_age(None),
)
.wrap(middleware::Logger::default())
.service(resource("/do_something").route(post().to(do_something)))
});
let req = srv.post("/do_something").send();
let resp = req.await.unwrap();
let cookie = resp
.cookies()
.unwrap()
.clone()
.into_iter()
.find(|c| c.name() == "test-session")
.unwrap();
assert_eq!(cookie.max_age(), None);
}
}

View File

@ -2,6 +2,23 @@
## Unreleased - 2021-xx-xx ## 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 ## 0.5.0 - 2022-03-01
- Update `actix-web` dependency to `4`. - Update `actix-web` dependency to `4`.
@ -27,7 +44,9 @@
## 0.5.0-beta.5 - 2021-12-12 ## 0.5.0-beta.5 - 2021-12-12
- Update `actix-web` dependency to `4.0.0.beta-14`. [#209] - Update `actix-web` dependency to `4.0.0.beta-14`. [#209]
- Remove `UserSession` implementation for `RequestHead`. [#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 [#209]: https://github.com/actix/actix-extras/pull/209

View File

@ -1,7 +1,10 @@
[package] [package]
name = "actix-session" name = "actix-session"
version = "0.5.0" 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" description = "Sessions for Actix Web"
keywords = ["http", "web", "framework", "async", "session"] keywords = ["http", "web", "framework", "async", "session"]
homepage = "https://actix.rs" homepage = "https://actix.rs"
@ -9,25 +12,54 @@ repository = "https://github.com/actix/actix-extras.git"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
edition = "2018" edition = "2018"
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[lib] [lib]
name = "actix_session" name = "actix_session"
path = "src/lib.rs" path = "src/lib.rs"
[features] [features]
default = ["cookie-session"] default = []
cookie-session = ["actix-web/secure-cookies"] 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] [dependencies]
actix-service = "2" actix-service = "2"
actix-utils = "3" 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" derive_more = "0.99.5"
futures-util = { version = "0.3.7", default-features = false } rand = { version = "0.8", optional = true }
log = "0.4" serde = { version = "1" }
serde = "1.0" serde_json = { version = "1" }
serde_json = "1.0"
time = "0.3" 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] [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 # 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) [![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) [![Documentation](https://docs.rs/actix-session/badge.svg?version=0.5.0)](https://docs.rs/actix-session/0.5.0)

View File

@ -1,7 +1,8 @@
use actix_redis::RedisSession; use actix_session::{storage::RedisActorSessionStore, Session, SessionMiddleware};
use actix_session::Session;
use actix_web::{ use actix_web::{
cookie, error::InternalError, middleware, web, App, Error, HttpResponse, HttpServer, Responder, cookie::{Key, SameSite},
error::InternalError,
middleware, web, App, Error, HttpResponse, HttpServer, Responder,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -70,25 +71,33 @@ async fn secret(session: Session) -> Result<impl Responder, Error> {
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
std::env::set_var("RUST_LOG", "actix_web=info,actix_redis=info"); env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
env_logger::init();
HttpServer::new(|| { // 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() App::new()
// enable logger // enable logger
.wrap(middleware::Logger::default()) .wrap(middleware::Logger::default())
// cookie session middleware // cookie session middleware
.wrap( .wrap(
RedisSession::new("127.0.0.1:6379", &[0; 32]) SessionMiddleware::builder(
RedisActorSessionStore::new("127.0.0.1:6379"),
signing_key.clone(),
)
// allow the cookie to be accessed from javascript // allow the cookie to be accessed from javascript
.cookie_http_only(false) .cookie_http_only(false)
// allow the cookie only from the current domain // allow the cookie only from the current domain
.cookie_same_site(cookie::SameSite::Strict), .cookie_same_site(SameSite::Strict)
.build(),
) )
.route("/login", web::post().to(login)) .route("/login", web::post().to(login))
.route("/secret", web::get().to(secret)) .route("/secret", web::get().to(secret))
}) })
.bind("0.0.0.0:8080")? .bind(("127.0.0.1", 8080))?
.run() .run()
.await .await
} }

View File

@ -1,6 +1,5 @@
use actix_redis::RedisSession; use actix_session::{storage::RedisActorSessionStore, Session, SessionMiddleware};
use actix_session::Session; use actix_web::{cookie::Key, middleware, web, App, Error, HttpRequest, HttpServer, Responder};
use actix_web::{middleware, web, App, Error, HttpRequest, HttpServer, Responder};
/// simple handler /// simple handler
async fn index(req: HttpRequest, session: Session) -> Result<impl Responder, Error> { async fn index(req: HttpRequest, session: Session) -> Result<impl Responder, Error> {
@ -19,19 +18,26 @@ async fn index(req: HttpRequest, session: Session) -> Result<impl Responder, Err
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
std::env::set_var("RUST_LOG", "actix_web=info,actix_redis=info"); env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
env_logger::init();
HttpServer::new(|| { // 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() App::new()
// enable logger // enable logger
.wrap(middleware::Logger::default()) .wrap(middleware::Logger::default())
// cookie session middleware // cookie session middleware
.wrap(RedisSession::new("127.0.0.1:6379", &[0; 32])) .wrap(SessionMiddleware::new(
RedisActorSessionStore::new("127.0.0.1:6379"),
signing_key.clone(),
))
// register simple route, handle all methods // register simple route, handle all methods
.service(web::resource("/").to(index)) .service(web::resource("/").to(index))
}) })
.bind("0.0.0.0:8080")? .bind(("127.0.0.1", 8080))?
.run() .run()
.await .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 //! The HTTP protocol, at a first glance, is stateless: the client sends a request, the server
//! implementations which could be accessed via general session API. //! 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. //! Stateless systems are easier to reason about, but they are not quite as powerful as we need to
//! Other backend implementations can be built to use persistent or key-value stores, for example. //! 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. //! There is a solution - **sessions**. Using sessions the server can attach state to a set of
//! To access session data, the [`Session`] extractor must be used. This extractor allows reading //! requests coming from the same client. They are built on top of cookies - the server sets a
//! modifying session data. //! 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 //! ```no_run
//! use actix_web::{web, App, HttpServer, HttpResponse, Error}; //! 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> { //! fn index(session: Session) -> Result<&'static str, Error> {
//! // access session data //! // Access the session state
//! if let Some(count) = session.get::<i32>("counter")? { //! if let Some(count) = session.get::<i32>("counter")? {
//! println!("SESSION value: {}", count); //! println!("SESSION value: {}", count);
//! // Modify the session state
//! session.insert("counter", count + 1)?; //! session.insert("counter", count + 1)?;
//! } else { //! } else {
//! session.insert("counter", 1)?; //! session.insert("counter", 1)?;
@ -25,356 +83,499 @@
//! //!
//! Ok("Welcome!") //! 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)] #![deny(rust_2018_idioms, nonstandard_style)]
#![warn(future_incompatible, missing_docs)] #![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::{ mod middleware;
cell::{Ref, RefCell}, mod session;
collections::HashMap, mod session_ext;
mem, pub mod storage;
rc::Rc,
pub use self::middleware::{
CookieContentSecurity, SessionLength, SessionMiddleware, SessionMiddlewareBuilder,
}; };
pub use self::session::{Session, SessionStatus};
use actix_utils::future::{ok, Ready}; pub use self::session_ext::SessionExt;
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()))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { pub mod test_helpers {
use actix_web::{test, HttpResponse}; use actix_web::cookie::Key;
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use super::*; use crate::{storage::SessionStore, CookieContentSecurity};
#[actix_web::test] /// Generate a random cookie signing/encryption key.
async fn session() { pub fn key() -> Key {
let mut req = test::TestRequest::default().to_srv_request(); let signing_key: String = thread_rng()
.sample_iter(&Alphanumeric)
Session::set_session( .take(64)
&mut req, .map(char::from)
vec![("key".to_string(), serde_json::to_string("value").unwrap())], .collect();
); Key::from(signing_key.as_bytes())
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())]);
} }
#[actix_web::test] /// A ready-to-go acceptance test suite to verify that sessions behave as expected
async fn get_session() { /// regardless of the underlying session store.
let mut req = test::TestRequest::default().to_srv_request(); ///
/// `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;
}
}
Session::set_session( mod acceptance_tests {
&mut req, use actix_web::{
vec![("key".to_string(), serde_json::to_string(&true).unwrap())], dev::Service,
middleware, test,
web::{self, get, post, resource, Bytes},
App, HttpResponse, Result,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use time::Duration;
use crate::{
middleware::SessionLength, storage::SessionStore, test_helpers::key,
CookieContentSecurity, Session, SessionMiddleware,
};
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 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
}
); );
let session = req.get_session(); // Step 2: POST to do_something
let res = session.get("key").unwrap(); // - adds new session state in redis: {"counter": 1}
assert_eq!(res, Some(true)); // - 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
}
);
} }
#[actix_web::test] // Step 9: POST to logout, including session cookie #3
async fn purge_session() { // - set-cookie actix-session will be in response with session cookie #3
let req = test::TestRequest::default().to_srv_request(); // invalidation logic
let session = Session::get_session(&mut *req.extensions_mut()); let req_9 = srv.post("/logout").cookie(cookie_3.clone()).send();
assert_eq!(session.0.borrow().status, SessionStatus::Unchanged); let resp_9 = req_9.await.unwrap();
session.purge(); let cookie_3 = resp_9
assert_eq!(session.0.borrow().status, SessionStatus::Purged); .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
}
);
} }
#[actix_web::test] #[derive(Serialize, Deserialize, Debug, PartialEq)]
async fn renew_session() { pub struct IndexResponse {
let req = test::TestRequest::default().to_srv_request(); user_id: Option<String>,
let session = Session::get_session(&mut *req.extensions_mut()); counter: i32,
assert_eq!(session.0.borrow().status, SessionStatus::Unchanged); }
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(); session.renew();
assert_eq!(session.0.borrow().status, SessionStatus::Renewed);
let counter: i32 = session
.get::<i32>("counter")
.unwrap_or(Some(0))
.unwrap_or(0);
Ok(HttpResponse::Ok().json(&IndexResponse {
user_id: Some(id),
counter,
}))
} }
#[actix_web::test] async fn logout(session: Session) -> Result<HttpResponse> {
async fn session_entries() { let id: Option<String> = session.get("user_id")?;
let session = Session(Rc::new(RefCell::new(SessionInner::default())));
session.insert("test_str", "val").unwrap();
session.insert("test_num", 1).unwrap();
let map = session.entries(); let body = if let Some(x) = id {
map.contains_key("test_str"); session.purge();
map.contains_key("test_num"); 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");
}