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:
parent
a1d0f051b7
commit
7e6335a09f
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@ -22,10 +22,15 @@ jobs:
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:5.0.7
|
||||
image: redis:6
|
||||
ports:
|
||||
- 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:
|
||||
- uses: actions/checkout@v2
|
||||
@ -126,7 +131,7 @@ jobs:
|
||||
timeout-minutes: 40
|
||||
with:
|
||||
command: ci-test
|
||||
args: --exclude=actix-redis -- --nocapture
|
||||
args: --exclude=actix-redis --exclude=actix-session -- --nocapture
|
||||
|
||||
- name: Clear the cargo caches
|
||||
run: |
|
||||
|
@ -12,8 +12,12 @@ members = [
|
||||
]
|
||||
|
||||
[patch.crates-io]
|
||||
actix-cors = { path = "actix-cors" }
|
||||
actix-session = { path = "actix-session" }
|
||||
actix-cors = { path = "./actix-cors" }
|
||||
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
|
||||
# actix-http = { path = "../actix-web/actix-http" }
|
||||
|
@ -1,6 +1,13 @@
|
||||
# Changes
|
||||
|
||||
## 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
|
||||
|
@ -2,7 +2,7 @@
|
||||
name = "actix-redis"
|
||||
version = "0.10.0"
|
||||
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"
|
||||
keywords = ["actix", "redis", "async", "session"]
|
||||
homepage = "https://actix.rs"
|
||||
@ -19,14 +19,7 @@ path = "src/lib.rs"
|
||||
default = ["web"]
|
||||
|
||||
# actix-web integration
|
||||
web = [
|
||||
"actix-web/cookies",
|
||||
"actix-web/secure-cookies",
|
||||
"actix-session/cookie-session",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json"
|
||||
]
|
||||
web = ["actix-web"]
|
||||
|
||||
[dependencies]
|
||||
actix = { version = "0.12", default-features = false }
|
||||
@ -35,32 +28,17 @@ actix-service = "2"
|
||||
actix-tls = { version = "3", default-features = false, features = ["connect"] }
|
||||
|
||||
log = "0.4.6"
|
||||
backoff = "0.2.1"
|
||||
backoff = "0.4.0"
|
||||
derive_more = "0.99.5"
|
||||
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.8", default-features = false, features = ["tokio10"] }
|
||||
redis-async = { version = "0.12", default-features = false, features = ["tokio10"] }
|
||||
time = "0.3"
|
||||
tokio = { version = "1.13.1", features = ["sync"] }
|
||||
tokio-util = "0.6"
|
||||
|
||||
# web
|
||||
tokio-util = "0.6.1"
|
||||
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]
|
||||
actix-test = "0.1.0-beta.12"
|
||||
actix-web = { version = "4", default_features = false, features = ["macros"] }
|
||||
env_logger = "0.9"
|
||||
serde = { version = "1.0.101", features = ["derive"] }
|
||||
|
||||
[[example]]
|
||||
name = "basic"
|
||||
required-features = ["web"]
|
||||
|
||||
[[example]]
|
||||
name = "authentication"
|
||||
required-features = ["web"]
|
||||
|
@ -1,6 +1,6 @@
|
||||
# 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)
|
||||
[![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)
|
||||
- [Example Project](https://github.com/actix/examples/tree/master/auth/redis-session)
|
||||
- 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
|
||||
}
|
||||
```
|
||||
|
@ -1,4 +1,4 @@
|
||||
//! Redis integration for Actix and session store for Actix Web.
|
||||
//! Redis integration for `actix`.
|
||||
|
||||
#![deny(rust_2018_idioms, nonstandard_style)]
|
||||
#![warn(future_incompatible)]
|
||||
@ -8,14 +8,7 @@ pub use redis::{Command, RedisActor};
|
||||
|
||||
use derive_more::{Display, Error, From};
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
mod session;
|
||||
#[cfg(feature = "web")]
|
||||
pub use actix_web::cookie::SameSite;
|
||||
#[cfg(feature = "web")]
|
||||
pub use session::RedisSession;
|
||||
|
||||
/// General purpose actix redis error
|
||||
/// General purpose `actix-redis` error.
|
||||
#[derive(Debug, Display, Error, From)]
|
||||
pub enum Error {
|
||||
#[display(fmt = "Redis error {}", _0)]
|
||||
@ -34,3 +27,4 @@ impl actix_web::ResponseError for Error {}
|
||||
// re-export
|
||||
pub use redis_async::error::Error as RespError;
|
||||
pub use redis_async::resp::RespValue;
|
||||
pub use redis_async::resp_array;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -2,6 +2,23 @@
|
||||
|
||||
## Unreleased - 2021-xx-xx
|
||||
|
||||
### Added
|
||||
- `SessionMiddleware`, a middleware to provide support for saving/updating/deleting session state against a pluggable storage backend (see `SessionStore` trait) [#212]
|
||||
- `CookieSessionStore`, a cookie-based backend to store session state [#212]
|
||||
- `RedisActorSessionStore`, a Redis-based backend to store session state powered by `actix-redis` [#212]
|
||||
- `RedisSessionStore`, a Redis-based backend to store session state powered by `redis-rs` [#212]
|
||||
- Add TLS support for Redis via `RedisSessionStore` [#212]
|
||||
- Implement `SessionExt` for `ServiceResponse` [#212]
|
||||
|
||||
### Changed
|
||||
- Rename `UserSession` to `SessionExt` [#212]
|
||||
|
||||
### Removed
|
||||
- `CookieSession` has been removed in favour of `CookieSessionStore`, a storage backend for `SessionMiddleware` [#212]
|
||||
- `Session::set_session` has been removed. Use `Session::insert` to modify the session state. [#212]
|
||||
|
||||
[#212]: https://github.com/actix/actix-extras/pull/212
|
||||
|
||||
|
||||
## 0.5.0 - 2022-03-01
|
||||
- Update `actix-web` dependency to `4`.
|
||||
@ -27,7 +44,9 @@
|
||||
## 0.5.0-beta.5 - 2021-12-12
|
||||
- Update `actix-web` dependency to `4.0.0.beta-14`. [#209]
|
||||
- Remove `UserSession` implementation for `RequestHead`. [#209]
|
||||
- A session will be created in the storage backend if and only if there is some data inside the session state. This reduces the performance impact of `SessionMiddleware` on routes that do not leverage sessions. [#207]
|
||||
|
||||
[#207]: https://github.com/actix/actix-extras/pull/207
|
||||
[#209]: https://github.com/actix/actix-extras/pull/209
|
||||
|
||||
|
||||
|
@ -1,7 +1,10 @@
|
||||
[package]
|
||||
name = "actix-session"
|
||||
version = "0.5.0"
|
||||
authors = ["Nikolay Kim <fafhrd91@gmail.com>"]
|
||||
authors = [
|
||||
"Nikolay Kim <fafhrd91@gmail.com>",
|
||||
"Luca Palmieri <rust@lpalmieri.com>",
|
||||
]
|
||||
description = "Sessions for Actix Web"
|
||||
keywords = ["http", "web", "framework", "async", "session"]
|
||||
homepage = "https://actix.rs"
|
||||
@ -9,25 +12,54 @@ repository = "https://github.com/actix/actix-extras.git"
|
||||
license = "MIT OR Apache-2.0"
|
||||
edition = "2018"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
|
||||
[lib]
|
||||
name = "actix_session"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = ["cookie-session"]
|
||||
cookie-session = ["actix-web/secure-cookies"]
|
||||
default = []
|
||||
cookie-session = []
|
||||
redis-actor-session = ["actix-redis", "actix", "futures-core", "rand"]
|
||||
redis-rs-session = ["redis", "rand"]
|
||||
redis-rs-tls-session = ["redis-rs-session", "redis/tokio-native-tls-comp"]
|
||||
|
||||
[dependencies]
|
||||
actix-service = "2"
|
||||
actix-utils = "3"
|
||||
actix-web = { version = "4", default_features = false, features = ["cookies"] }
|
||||
actix-web = { version = "4", default_features = false, features = ["cookies", "secure-cookies"] }
|
||||
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
derive_more = "0.99.5"
|
||||
futures-util = { version = "0.3.7", default-features = false }
|
||||
log = "0.4"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
rand = { version = "0.8", optional = true }
|
||||
serde = { version = "1" }
|
||||
serde_json = { version = "1" }
|
||||
time = "0.3"
|
||||
tracing = { version = "0.1.30", default-features = false, features = ["log"] }
|
||||
|
||||
# redis-actor-session
|
||||
actix = { version = "0.12.0", default-features = false, optional = true }
|
||||
actix-redis = { version = "0.10", optional = true }
|
||||
futures-core = { version = "0.3.7", default-features = false, optional = true }
|
||||
|
||||
# redis-rs-session
|
||||
redis = { version = "0.21", default-features = false, features = ["aio", "tokio-comp", "connection-manager"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
actix-web = { version = "4", default_features = false, features = ["macros", "cookies"] }
|
||||
actix-session = { path = ".", features = ["cookie-session", "redis-actor-session", "redis-rs-session"] }
|
||||
actix-test = "0.1.0-beta.10"
|
||||
actix-web = { version = "4", default_features = false, features = ["cookies", "secure-cookies", "macros"] }
|
||||
env_logger = "0.9"
|
||||
log = "0.4"
|
||||
|
||||
[[example]]
|
||||
name = "basic"
|
||||
required-features = ["redis-actor-session"]
|
||||
|
||||
[[example]]
|
||||
name = "authentication"
|
||||
required-features = ["redis-actor-session"]
|
||||
|
@ -1,6 +1,6 @@
|
||||
# actix-session
|
||||
|
||||
> Sessions for Actix Web.
|
||||
> Session management for Actix Web applications.
|
||||
|
||||
[![crates.io](https://img.shields.io/crates/v/actix-session?label=latest)](https://crates.io/crates/actix-session)
|
||||
[![Documentation](https://docs.rs/actix-session/badge.svg?version=0.5.0)](https://docs.rs/actix-session/0.5.0)
|
||||
|
@ -1,7 +1,8 @@
|
||||
use actix_redis::RedisSession;
|
||||
use actix_session::Session;
|
||||
use actix_session::{storage::RedisActorSessionStore, Session, SessionMiddleware};
|
||||
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};
|
||||
|
||||
@ -70,25 +71,33 @@ async fn secret(session: Session) -> Result<impl Responder, Error> {
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
std::env::set_var("RUST_LOG", "actix_web=info,actix_redis=info");
|
||||
env_logger::init();
|
||||
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
|
||||
|
||||
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()
|
||||
// enable logger
|
||||
.wrap(middleware::Logger::default())
|
||||
// cookie session middleware
|
||||
.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
|
||||
.cookie_http_only(false)
|
||||
// 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("/secret", web::get().to(secret))
|
||||
})
|
||||
.bind("0.0.0.0:8080")?
|
||||
.bind(("127.0.0.1", 8080))?
|
||||
.run()
|
||||
.await
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
use actix_redis::RedisSession;
|
||||
use actix_session::Session;
|
||||
use actix_web::{middleware, web, App, Error, HttpRequest, HttpServer, Responder};
|
||||
use actix_session::{storage::RedisActorSessionStore, Session, SessionMiddleware};
|
||||
use actix_web::{cookie::Key, middleware, web, App, Error, HttpRequest, HttpServer, Responder};
|
||||
|
||||
/// simple handler
|
||||
async fn index(req: HttpRequest, session: Session) -> Result<impl Responder, Error> {
|
||||
@ -19,19 +18,26 @@ async fn index(req: HttpRequest, session: Session) -> Result<impl Responder, Err
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
std::env::set_var("RUST_LOG", "actix_web=info,actix_redis=info");
|
||||
env_logger::init();
|
||||
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
|
||||
|
||||
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()
|
||||
// enable logger
|
||||
.wrap(middleware::Logger::default())
|
||||
// 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
|
||||
.service(web::resource("/").to(index))
|
||||
})
|
||||
.bind("0.0.0.0:8080")?
|
||||
.bind(("127.0.0.1", 8080))?
|
||||
.run()
|
||||
.await
|
||||
}
|
@ -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> {}
|
@ -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));
|
||||
}
|
||||
}
|
@ -1,23 +1,81 @@
|
||||
//! Sessions for Actix Web.
|
||||
//! Session management for Actix Web
|
||||
//!
|
||||
//! Provides a general solution for session management. Session middleware could provide different
|
||||
//! implementations which could be accessed via general session API.
|
||||
//! The HTTP protocol, at a first glance, is stateless: the client sends a request, the server
|
||||
//! parses its content, performs some processing and returns a response. The outcome is only
|
||||
//! influenced by the provided inputs (i.e. the request content) and whatever state the server
|
||||
//! queries while performing its processing.
|
||||
//!
|
||||
//! This crate provides a general solution for session management and includes a cookie backend.
|
||||
//! Other backend implementations can be built to use persistent or key-value stores, for example.
|
||||
//! Stateless systems are easier to reason about, but they are not quite as powerful as we need to
|
||||
//! be - e.g. how do you authenticate a user? The user would be forced to authenticate **for every
|
||||
//! single request**. That is, for example, how 'Basic' Authentication works. While it may work for
|
||||
//! a machine user (i.e. an API client), it is impractical for a person—you do not want a login
|
||||
//! prompt on every single page you navigate to!
|
||||
//!
|
||||
//! In general, some session middleware, such as a [`CookieSession`] is initialized and applied.
|
||||
//! To access session data, the [`Session`] extractor must be used. This extractor allows reading
|
||||
//! modifying session data.
|
||||
//! There is a solution - **sessions**. Using sessions the server can attach state to a set of
|
||||
//! requests coming from the same client. They are built on top of cookies - the server sets a
|
||||
//! cookie in the HTTP response (`Set-Cookie` header), the client (e.g. the browser) will store the
|
||||
//! cookie and play it back to the server when sending new requests (using the `Cookie` header).
|
||||
//!
|
||||
//! We refer to the cookie used for sessions as a **session cookie**. Its content is called
|
||||
//! **session key** (or **session ID**), while the state attached to the session is referred to as
|
||||
//! **session state**.
|
||||
//!
|
||||
//! `actix-session` provides an easy-to-use framework to manage sessions in applications built on
|
||||
//! top of Actix Web. [`SessionMiddleware`] is the middleware underpinning the functionality
|
||||
//! provided by `actix-session`; it takes care of all the session cookie handling and instructs the
|
||||
//! **storage backend** to create/delete/update the session state based on the operations performed
|
||||
//! against the active [`Session`].
|
||||
//!
|
||||
//! `actix-session` provides some built-in storage backends: ([`storage::CookieSessionStore`],
|
||||
//! [`storage::RedisSessionStore`], and [`storage::RedisActorSessionStore`]) - you can create a
|
||||
//! custom storage backend by implementing the [`SessionStore`](storage::SessionStore) trait.
|
||||
//!
|
||||
//! Further reading on sessions:
|
||||
//! - [RFC6265](https://datatracker.ietf.org/doc/html/rfc6265);
|
||||
//! - [OWASP's session management cheat-sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html).
|
||||
//!
|
||||
//! # Getting started
|
||||
//! To start using sessions in your Actix Web application you must register [`SessionMiddleware`]
|
||||
//! as a middleware on your `App`:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use actix_web::{web, App, HttpServer, HttpResponse, Error};
|
||||
//! use actix_session::{Session, CookieSession};
|
||||
//! use actix_session::{Session, SessionMiddleware, storage::RedisActorSessionStore};
|
||||
//! use actix_web::cookie::Key;
|
||||
//!
|
||||
//! #[actix_web::main]
|
||||
//! async fn main() -> std::io::Result<()> {
|
||||
//! // The secret key would usually be read from a configuration file/environment variables.
|
||||
//! let secret_key = Key::generate();
|
||||
//! let redis_connection_string = "127.0.0.1:6379";
|
||||
//! HttpServer::new(move ||
|
||||
//! App::new()
|
||||
//! // Add session management to your application using Redis for session state storage
|
||||
//! .wrap(
|
||||
//! SessionMiddleware::new(
|
||||
//! RedisActorSessionStore::new(redis_connection_string),
|
||||
//! secret_key.clone()
|
||||
//! )
|
||||
//! )
|
||||
//! .default_service(web::to(|| HttpResponse::Ok())))
|
||||
//! .bind(("127.0.0.1", 8080))?
|
||||
//! .run()
|
||||
//! .await
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! The session state can be accessed and modified by your request handlers using the [`Session`]
|
||||
//! extractor.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use actix_web::Error;
|
||||
//! use actix_session::Session;
|
||||
//!
|
||||
//! fn index(session: Session) -> Result<&'static str, Error> {
|
||||
//! // access session data
|
||||
//! // Access the session state
|
||||
//! if let Some(count) = session.get::<i32>("counter")? {
|
||||
//! println!("SESSION value: {}", count);
|
||||
//! // Modify the session state
|
||||
//! session.insert("counter", count + 1)?;
|
||||
//! } else {
|
||||
//! session.insert("counter", 1)?;
|
||||
@ -25,356 +83,499 @@
|
||||
//!
|
||||
//! Ok("Welcome!")
|
||||
//! }
|
||||
//!
|
||||
//! #[actix_web::main]
|
||||
//! async fn main() -> std::io::Result<()> {
|
||||
//! HttpServer::new(
|
||||
//! || App::new()
|
||||
//! // create cookie based session middleware
|
||||
//! .wrap(CookieSession::signed(&[0; 32]).secure(false))
|
||||
//! .default_service(web::to(|| HttpResponse::Ok())))
|
||||
//! .bind(("127.0.0.1", 8080))?
|
||||
//! .run()
|
||||
//! .await
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Choosing A Backend
|
||||
//!
|
||||
//! By default, `actix-session` does not provide any storage backend to retrieve and save the state
|
||||
//! attached to your sessions. You can enable:
|
||||
//!
|
||||
//! - a purely cookie-based "backend", [`storage::CookieSessionStore`], using the `cookie-session`
|
||||
//! feature flag.
|
||||
//!
|
||||
//! ```toml
|
||||
//! [dependencies]
|
||||
//! # ...
|
||||
//! actix-session = { version = "...", features = ["cookie-session"] }
|
||||
//! ```
|
||||
//!
|
||||
//! - a Redis-based backend via `actix-redis`, [`storage::RedisActorSessionStore`], using the
|
||||
//! `redis-actor-session` feature flag.
|
||||
//!
|
||||
//! ```toml
|
||||
//! [dependencies]
|
||||
//! # ...
|
||||
//! actix-session = { version = "...", features = ["redis-actor-session"] }
|
||||
//! ```
|
||||
//!
|
||||
//! - a Redis-based backend via [`redis-rs`](https://github.com/mitsuhiko/redis-rs),
|
||||
//! [`storage::RedisSessionStore`], using the `redis-rs-session` feature flag.
|
||||
//!
|
||||
//! ```toml
|
||||
//! [dependencies]
|
||||
//! # ...
|
||||
//! actix-session = { version = "...", features = ["redis-rs-session"] }
|
||||
//! ```
|
||||
//!
|
||||
//! Add the `redis-rs-tls-session` feature flag if you want to connect to Redis using a secured
|
||||
//! connection:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [dependencies]
|
||||
//! # ...
|
||||
//! actix-session = { version = "...", features = ["redis-rs-session", "redis-rs-tls-session"] }
|
||||
//! ```
|
||||
//!
|
||||
//! You can provide a different session store by implementing the [`storage::SessionStore`] trait.
|
||||
|
||||
#![deny(rust_2018_idioms, nonstandard_style)]
|
||||
#![warn(future_incompatible, missing_docs)]
|
||||
#![doc(html_logo_url = "https://actix.rs/img/logo.png")]
|
||||
#![doc(html_favicon_url = "https://actix.rs/favicon.ico")]
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
|
||||
use std::{
|
||||
cell::{Ref, RefCell},
|
||||
collections::HashMap,
|
||||
mem,
|
||||
rc::Rc,
|
||||
mod middleware;
|
||||
mod session;
|
||||
mod session_ext;
|
||||
pub mod storage;
|
||||
|
||||
pub use self::middleware::{
|
||||
CookieContentSecurity, SessionLength, SessionMiddleware, SessionMiddlewareBuilder,
|
||||
};
|
||||
|
||||
use actix_utils::future::{ok, Ready};
|
||||
use actix_web::{
|
||||
dev::{Extensions, Payload, ServiceRequest, ServiceResponse},
|
||||
Error, FromRequest, HttpMessage, HttpRequest,
|
||||
};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
|
||||
#[cfg(feature = "cookie-session")]
|
||||
mod cookie;
|
||||
#[cfg(feature = "cookie-session")]
|
||||
pub use self::cookie::CookieSession;
|
||||
|
||||
/// The high-level interface you use to modify session data.
|
||||
///
|
||||
/// Session object is obtained with [`UserSession::get_session`]. The [`UserSession`] trait is
|
||||
/// implemented for `HttpRequest`, `ServiceRequest`, and `RequestHead`.
|
||||
///
|
||||
/// ```
|
||||
/// use actix_session::Session;
|
||||
/// use actix_web::Result;
|
||||
///
|
||||
/// async fn index(session: Session) -> Result<&'static str> {
|
||||
/// // access session data
|
||||
/// if let Some(count) = session.get::<i32>("counter")? {
|
||||
/// session.insert("counter", count + 1)?;
|
||||
/// } else {
|
||||
/// session.insert("counter", 1)?;
|
||||
/// }
|
||||
///
|
||||
/// Ok("Welcome!")
|
||||
/// }
|
||||
/// ```
|
||||
pub struct Session(Rc<RefCell<SessionInner>>);
|
||||
|
||||
/// Extraction of a [`Session`] object.
|
||||
pub trait UserSession {
|
||||
/// Extract the [`Session`] object
|
||||
fn get_session(&self) -> Session;
|
||||
}
|
||||
|
||||
impl UserSession for HttpRequest {
|
||||
fn get_session(&self) -> Session {
|
||||
Session::get_session(&mut *self.extensions_mut())
|
||||
}
|
||||
}
|
||||
|
||||
impl UserSession for ServiceRequest {
|
||||
fn get_session(&self) -> Session {
|
||||
Session::get_session(&mut *self.extensions_mut())
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of a [`Session`].
|
||||
#[derive(PartialEq, Clone, Debug)]
|
||||
pub enum SessionStatus {
|
||||
/// Session has been updated and requires a new persist operation.
|
||||
Changed,
|
||||
|
||||
/// Session is flagged for deletion and should be removed from client and server.
|
||||
///
|
||||
/// Most operations on the session after purge flag is set should have no effect.
|
||||
Purged,
|
||||
|
||||
/// Session is flagged for refresh.
|
||||
///
|
||||
/// For example, when using a backend that has a TTL (time-to-live) expiry on the session entry,
|
||||
/// the session will be refreshed even if no data inside it has changed. The client may also
|
||||
/// be notified of the refresh.
|
||||
Renewed,
|
||||
|
||||
/// Session is unchanged from when last seen (if exists).
|
||||
///
|
||||
/// This state also captures new (previously unissued) sessions such as a user's first
|
||||
/// site visit.
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
impl Default for SessionStatus {
|
||||
fn default() -> SessionStatus {
|
||||
SessionStatus::Unchanged
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SessionInner {
|
||||
state: HashMap<String, String>,
|
||||
status: SessionStatus,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Get a `value` from the session.
|
||||
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, Error> {
|
||||
if let Some(s) = self.0.borrow().state.get(key) {
|
||||
Ok(Some(serde_json::from_str(s)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all raw key-value data from the session.
|
||||
///
|
||||
/// Note that values are JSON encoded.
|
||||
pub fn entries(&self) -> Ref<'_, HashMap<String, String>> {
|
||||
Ref::map(self.0.borrow(), |inner| &inner.state)
|
||||
}
|
||||
|
||||
/// Inserts a key-value pair into the session.
|
||||
///
|
||||
/// Any serializable value can be used and will be encoded as JSON in session data, hence why
|
||||
/// only a reference to the value is taken.
|
||||
pub fn insert(&self, key: impl Into<String>, value: impl Serialize) -> Result<(), Error> {
|
||||
let mut inner = self.0.borrow_mut();
|
||||
|
||||
if inner.status != SessionStatus::Purged {
|
||||
inner.status = SessionStatus::Changed;
|
||||
let val = serde_json::to_string(&value)?;
|
||||
inner.state.insert(key.into(), val);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove value from the session.
|
||||
///
|
||||
/// If present, the JSON encoded value is returned.
|
||||
pub fn remove(&self, key: &str) -> Option<String> {
|
||||
let mut inner = self.0.borrow_mut();
|
||||
|
||||
if inner.status != SessionStatus::Purged {
|
||||
inner.status = SessionStatus::Changed;
|
||||
return inner.state.remove(key);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Remove value from the session and deserialize.
|
||||
///
|
||||
/// Returns None if key was not present in session. Returns T if deserialization succeeds,
|
||||
/// otherwise returns un-deserialized JSON string.
|
||||
pub fn remove_as<T: DeserializeOwned>(&self, key: &str) -> Option<Result<T, String>> {
|
||||
self.remove(key)
|
||||
.map(|val_str| match serde_json::from_str(&val_str) {
|
||||
Ok(val) => Ok(val),
|
||||
Err(_err) => {
|
||||
log::debug!(
|
||||
"removed value (key: {}) could not be deserialized as {}",
|
||||
key,
|
||||
std::any::type_name::<T>()
|
||||
);
|
||||
Err(val_str)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear the session.
|
||||
pub fn clear(&self) {
|
||||
let mut inner = self.0.borrow_mut();
|
||||
|
||||
if inner.status != SessionStatus::Purged {
|
||||
inner.status = SessionStatus::Changed;
|
||||
inner.state.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes session both client and server side.
|
||||
pub fn purge(&self) {
|
||||
let mut inner = self.0.borrow_mut();
|
||||
inner.status = SessionStatus::Purged;
|
||||
inner.state.clear();
|
||||
}
|
||||
|
||||
/// Renews the session key, assigning existing session state to new key.
|
||||
pub fn renew(&self) {
|
||||
let mut inner = self.0.borrow_mut();
|
||||
|
||||
if inner.status != SessionStatus::Purged {
|
||||
inner.status = SessionStatus::Renewed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the given key-value pairs to the session on the request.
|
||||
///
|
||||
/// Values that match keys already existing on the session will be overwritten. Values should
|
||||
/// already be JSON serialized.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// # use actix_session::Session;
|
||||
/// # use actix_web::test;
|
||||
/// let mut req = test::TestRequest::default().to_srv_request();
|
||||
///
|
||||
/// Session::set_session(
|
||||
/// &mut req,
|
||||
/// vec![("counter".to_string(), serde_json::to_string(&0).unwrap())],
|
||||
/// );
|
||||
/// ```
|
||||
pub fn set_session(req: &mut ServiceRequest, data: impl IntoIterator<Item = (String, String)>) {
|
||||
let session = Session::get_session(&mut *req.extensions_mut());
|
||||
let mut inner = session.0.borrow_mut();
|
||||
inner.state.extend(data);
|
||||
}
|
||||
|
||||
/// Returns session status and iterator of key-value pairs of changes.
|
||||
pub fn get_changes<B>(
|
||||
res: &mut ServiceResponse<B>,
|
||||
) -> (SessionStatus, impl Iterator<Item = (String, String)>) {
|
||||
if let Some(s_impl) = res
|
||||
.request()
|
||||
.extensions()
|
||||
.get::<Rc<RefCell<SessionInner>>>()
|
||||
{
|
||||
let state = mem::take(&mut s_impl.borrow_mut().state);
|
||||
(s_impl.borrow().status.clone(), state.into_iter())
|
||||
} else {
|
||||
(SessionStatus::Unchanged, HashMap::new().into_iter())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_session(extensions: &mut Extensions) -> Session {
|
||||
if let Some(s_impl) = extensions.get::<Rc<RefCell<SessionInner>>>() {
|
||||
return Session(Rc::clone(s_impl));
|
||||
}
|
||||
let inner = Rc::new(RefCell::new(SessionInner::default()));
|
||||
extensions.insert(inner.clone());
|
||||
Session(inner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extractor implementation for Session type.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// # use actix_web::*;
|
||||
/// use actix_session::Session;
|
||||
///
|
||||
/// #[get("/")]
|
||||
/// async fn index(session: Session) -> Result<impl Responder> {
|
||||
/// // access session data
|
||||
/// if let Some(count) = session.get::<i32>("counter")? {
|
||||
/// session.insert("counter", count + 1)?;
|
||||
/// } else {
|
||||
/// session.insert("counter", 1)?;
|
||||
/// }
|
||||
///
|
||||
/// let count = session.get::<i32>("counter")?.unwrap();
|
||||
/// Ok(format!("Counter: {}", count))
|
||||
/// }
|
||||
/// ```
|
||||
impl FromRequest for Session {
|
||||
type Error = Error;
|
||||
type Future = Ready<Result<Session, Error>>;
|
||||
|
||||
#[inline]
|
||||
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
|
||||
ok(Session::get_session(&mut *req.extensions_mut()))
|
||||
}
|
||||
}
|
||||
pub use self::session::{Session, SessionStatus};
|
||||
pub use self::session_ext::SessionExt;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::{test, HttpResponse};
|
||||
pub mod test_helpers {
|
||||
use actix_web::cookie::Key;
|
||||
use rand::{distributions::Alphanumeric, thread_rng, Rng};
|
||||
|
||||
use super::*;
|
||||
use crate::{storage::SessionStore, CookieContentSecurity};
|
||||
|
||||
#[actix_web::test]
|
||||
async fn session() {
|
||||
let mut req = test::TestRequest::default().to_srv_request();
|
||||
|
||||
Session::set_session(
|
||||
&mut req,
|
||||
vec![("key".to_string(), serde_json::to_string("value").unwrap())],
|
||||
);
|
||||
let session = Session::get_session(&mut *req.extensions_mut());
|
||||
let res = session.get::<String>("key").unwrap();
|
||||
assert_eq!(res, Some("value".to_string()));
|
||||
|
||||
session.insert("key2", "value2").unwrap();
|
||||
session.remove("key");
|
||||
|
||||
let mut res = req.into_response(HttpResponse::Ok().finish());
|
||||
let (_status, state) = Session::get_changes(&mut res);
|
||||
let changes: Vec<_> = state.collect();
|
||||
assert_eq!(changes, [("key2".to_string(), "\"value2\"".to_string())]);
|
||||
/// Generate a random cookie signing/encryption key.
|
||||
pub fn key() -> Key {
|
||||
let signing_key: String = thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(64)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
Key::from(signing_key.as_bytes())
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn get_session() {
|
||||
let mut req = test::TestRequest::default().to_srv_request();
|
||||
/// A ready-to-go acceptance test suite to verify that sessions behave as expected
|
||||
/// regardless of the underlying session store.
|
||||
///
|
||||
/// `is_invalidation_supported` must be set to `true` if the backend supports
|
||||
/// "remembering" that a session has been invalidated (e.g. by logging out).
|
||||
/// It should be to `false` if the backend allows multiple cookies to be active
|
||||
/// at the same time (e.g. cookie store backend).
|
||||
pub async fn acceptance_test_suite<F, Store>(store_builder: F, is_invalidation_supported: bool)
|
||||
where
|
||||
Store: SessionStore + 'static,
|
||||
F: Fn() -> Store + Clone + Send + 'static,
|
||||
{
|
||||
for policy in &[
|
||||
CookieContentSecurity::Signed,
|
||||
CookieContentSecurity::Private,
|
||||
] {
|
||||
println!("Using {:?} as cookie content security policy.", policy);
|
||||
acceptance_tests::basic_workflow(store_builder.clone(), *policy).await;
|
||||
acceptance_tests::expiration_is_refreshed_on_changes(store_builder.clone(), *policy)
|
||||
.await;
|
||||
acceptance_tests::complex_workflow(
|
||||
store_builder.clone(),
|
||||
is_invalidation_supported,
|
||||
*policy,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Session::set_session(
|
||||
&mut req,
|
||||
vec![("key".to_string(), serde_json::to_string(&true).unwrap())],
|
||||
mod acceptance_tests {
|
||||
use actix_web::{
|
||||
dev::Service,
|
||||
middleware, test,
|
||||
web::{self, get, post, resource, Bytes},
|
||||
App, HttpResponse, Result,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use time::Duration;
|
||||
|
||||
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();
|
||||
let res = session.get("key").unwrap();
|
||||
assert_eq!(res, Some(true));
|
||||
// Step 2: POST to do_something
|
||||
// - adds new session state in redis: {"counter": 1}
|
||||
// - set-cookie actix-session should be in response (session cookie #1)
|
||||
// - response should be: {"counter": 1, "user_id": None}
|
||||
let req_2 = srv.post("/do_something").send();
|
||||
let mut resp_2 = req_2.await.unwrap();
|
||||
let result_2 = resp_2.json::<IndexResponse>().await.unwrap();
|
||||
assert_eq!(
|
||||
result_2,
|
||||
IndexResponse {
|
||||
user_id: None,
|
||||
counter: 1
|
||||
}
|
||||
);
|
||||
let cookie_1 = resp_2
|
||||
.cookies()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.find(|c| c.name() == "test-session")
|
||||
.unwrap();
|
||||
assert_eq!(cookie_1.max_age(), Some(Duration::days(7)));
|
||||
|
||||
// Step 3: GET index, including session cookie #1 in request
|
||||
// - set-cookie will *not* be in response
|
||||
// - response should be: {"counter": 1, "user_id": None}
|
||||
let req_3 = srv.get("/").cookie(cookie_1.clone()).send();
|
||||
let mut resp_3 = req_3.await.unwrap();
|
||||
assert!(resp_3.cookies().unwrap().is_empty());
|
||||
let result_3 = resp_3.json::<IndexResponse>().await.unwrap();
|
||||
assert_eq!(
|
||||
result_3,
|
||||
IndexResponse {
|
||||
user_id: None,
|
||||
counter: 1
|
||||
}
|
||||
);
|
||||
|
||||
// Step 4: POST again to do_something, including session cookie #1 in request
|
||||
// - set-cookie will be in response (session cookie #2)
|
||||
// - updates session state: {"counter": 2}
|
||||
// - response should be: {"counter": 2, "user_id": None}
|
||||
let req_4 = srv.post("/do_something").cookie(cookie_1.clone()).send();
|
||||
let mut resp_4 = req_4.await.unwrap();
|
||||
let result_4 = resp_4.json::<IndexResponse>().await.unwrap();
|
||||
assert_eq!(
|
||||
result_4,
|
||||
IndexResponse {
|
||||
user_id: None,
|
||||
counter: 2
|
||||
}
|
||||
);
|
||||
let cookie_2 = resp_4
|
||||
.cookies()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.find(|c| c.name() == "test-session")
|
||||
.unwrap();
|
||||
assert_eq!(cookie_2.max_age(), Some(Duration::days(7)));
|
||||
|
||||
// Step 5: POST to login, including session cookie #2 in request
|
||||
// - set-cookie actix-session will be in response (session cookie #3)
|
||||
// - updates session state: {"counter": 2, "user_id": "ferris"}
|
||||
let req_5 = srv
|
||||
.post("/login")
|
||||
.cookie(cookie_2.clone())
|
||||
.send_json(&json!({"user_id": "ferris"}));
|
||||
let mut resp_5 = req_5.await.unwrap();
|
||||
let cookie_3 = resp_5
|
||||
.cookies()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.find(|c| c.name() == "test-session")
|
||||
.unwrap();
|
||||
assert_ne!(cookie_2.value(), cookie_3.value());
|
||||
|
||||
let result_5 = resp_5.json::<IndexResponse>().await.unwrap();
|
||||
assert_eq!(
|
||||
result_5,
|
||||
IndexResponse {
|
||||
user_id: Some("ferris".into()),
|
||||
counter: 2
|
||||
}
|
||||
);
|
||||
|
||||
// Step 6: GET index, including session cookie #3 in request
|
||||
// - response should be: {"counter": 2, "user_id": "ferris"}
|
||||
let req_6 = srv.get("/").cookie(cookie_3.clone()).send();
|
||||
let mut resp_6 = req_6.await.unwrap();
|
||||
let result_6 = resp_6.json::<IndexResponse>().await.unwrap();
|
||||
assert_eq!(
|
||||
result_6,
|
||||
IndexResponse {
|
||||
user_id: Some("ferris".into()),
|
||||
counter: 2
|
||||
}
|
||||
);
|
||||
|
||||
// Step 7: POST again to do_something, including session cookie #3 in request
|
||||
// - updates session state: {"counter": 3, "user_id": "ferris"}
|
||||
// - response should be: {"counter": 3, "user_id": "ferris"}
|
||||
let req_7 = srv.post("/do_something").cookie(cookie_3.clone()).send();
|
||||
let mut resp_7 = req_7.await.unwrap();
|
||||
let result_7 = resp_7.json::<IndexResponse>().await.unwrap();
|
||||
assert_eq!(
|
||||
result_7,
|
||||
IndexResponse {
|
||||
user_id: Some("ferris".into()),
|
||||
counter: 3
|
||||
}
|
||||
);
|
||||
|
||||
// Step 8: GET index, including session cookie #2 in request
|
||||
// If invalidation is supported, no state will be found associated to this session.
|
||||
// If invalidation is not supported, the old state will still be retrieved.
|
||||
let req_8 = srv.get("/").cookie(cookie_2.clone()).send();
|
||||
let mut resp_8 = req_8.await.unwrap();
|
||||
if is_invalidation_supported {
|
||||
assert!(resp_8.cookies().unwrap().is_empty());
|
||||
let result_8 = resp_8.json::<IndexResponse>().await.unwrap();
|
||||
assert_eq!(
|
||||
result_8,
|
||||
IndexResponse {
|
||||
user_id: None,
|
||||
counter: 0
|
||||
}
|
||||
);
|
||||
} else {
|
||||
let result_8 = resp_8.json::<IndexResponse>().await.unwrap();
|
||||
assert_eq!(
|
||||
result_8,
|
||||
IndexResponse {
|
||||
user_id: None,
|
||||
counter: 2
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn purge_session() {
|
||||
let req = test::TestRequest::default().to_srv_request();
|
||||
let session = Session::get_session(&mut *req.extensions_mut());
|
||||
assert_eq!(session.0.borrow().status, SessionStatus::Unchanged);
|
||||
session.purge();
|
||||
assert_eq!(session.0.borrow().status, SessionStatus::Purged);
|
||||
// Step 9: POST to logout, including session cookie #3
|
||||
// - set-cookie actix-session will be in response with session cookie #3
|
||||
// invalidation logic
|
||||
let req_9 = srv.post("/logout").cookie(cookie_3.clone()).send();
|
||||
let resp_9 = req_9.await.unwrap();
|
||||
let cookie_3 = resp_9
|
||||
.cookies()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.find(|c| c.name() == "test-session")
|
||||
.unwrap();
|
||||
assert_eq!(0, cookie_3.max_age().map(|t| t.whole_seconds()).unwrap());
|
||||
assert_eq!("/", cookie_3.path().unwrap());
|
||||
|
||||
// Step 10: GET index, including session cookie #3 in request
|
||||
// - set-cookie actix-session should NOT be in response if invalidation is supported
|
||||
// - response should be: {"counter": 0, "user_id": None}
|
||||
let req_10 = srv.get("/").cookie(cookie_3.clone()).send();
|
||||
let mut resp_10 = req_10.await.unwrap();
|
||||
if is_invalidation_supported {
|
||||
assert!(resp_10.cookies().unwrap().is_empty());
|
||||
}
|
||||
let result_10 = resp_10.json::<IndexResponse>().await.unwrap();
|
||||
assert_eq!(
|
||||
result_10,
|
||||
IndexResponse {
|
||||
user_id: None,
|
||||
counter: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn renew_session() {
|
||||
let req = test::TestRequest::default().to_srv_request();
|
||||
let session = Session::get_session(&mut *req.extensions_mut());
|
||||
assert_eq!(session.0.borrow().status, SessionStatus::Unchanged);
|
||||
#[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();
|
||||
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 session_entries() {
|
||||
let session = Session(Rc::new(RefCell::new(SessionInner::default())));
|
||||
session.insert("test_str", "val").unwrap();
|
||||
session.insert("test_num", 1).unwrap();
|
||||
async fn logout(session: Session) -> Result<HttpResponse> {
|
||||
let id: Option<String> = session.get("user_id")?;
|
||||
|
||||
let map = session.entries();
|
||||
map.contains_key("test_str");
|
||||
map.contains_key("test_num");
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
646
actix-session/src/middleware.rs
Normal file
646
actix-session/src/middleware.rs
Normal 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(())
|
||||
}
|
256
actix-session/src/session.rs
Normal file
256
actix-session/src/session.rs
Normal 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())))
|
||||
}
|
||||
}
|
31
actix-session/src/session_ext.rs
Normal file
31
actix-session/src/session_ext.rs
Normal 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()
|
||||
}
|
||||
}
|
116
actix-session/src/storage/cookie.rs
Normal file
116
actix-session/src/storage/cookie.rs
Normal 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(_),
|
||||
));
|
||||
}
|
||||
}
|
104
actix-session/src/storage/interface.rs
Normal file
104
actix-session/src/storage/interface.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
28
actix-session/src/storage/mod.rs
Normal file
28
actix-session/src/storage/mod.rs
Normal 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};
|
294
actix-session/src/storage/redis_actor.rs
Normal file
294
actix-session/src/storage/redis_actor.rs
Normal 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());
|
||||
}
|
||||
}
|
297
actix-session/src/storage/redis_rs.rs
Normal file
297
actix-session/src/storage/redis_rs.rs
Normal 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());
|
||||
}
|
||||
}
|
59
actix-session/src/storage/session_key.rs
Normal file
59
actix-session/src/storage/session_key.rs
Normal 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())
|
||||
}
|
||||
}
|
19
actix-session/src/storage/utils.rs
Normal file
19
actix-session/src/storage/utils.rs
Normal 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()
|
||||
}
|
56
actix-session/tests/middleware.rs
Normal file
56
actix-session/tests/middleware.rs
Normal 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(())
|
||||
}
|
70
actix-session/tests/session.rs
Normal file
70
actix-session/tests/session.rs
Normal 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");
|
||||
}
|
Loading…
Reference in New Issue
Block a user