1
0
mirror of https://github.com/actix/actix-extras.git synced 2025-06-26 18:37:41 +02:00

Rework actix session (#212)

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

View File

@ -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

View File

@ -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"]

View File

@ -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
}
```

View File

@ -1,94 +0,0 @@
use actix_redis::RedisSession;
use actix_session::Session;
use actix_web::{
cookie, error::InternalError, middleware, web, App, Error, HttpResponse, HttpServer, Responder,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Credentials {
username: String,
password: String,
}
#[derive(Serialize)]
struct User {
id: i64,
username: String,
password: String,
}
impl User {
fn authenticate(credentials: Credentials) -> Result<Self, HttpResponse> {
// TODO: figure out why I keep getting hacked
if &credentials.password != "hunter2" {
return Err(HttpResponse::Unauthorized().json("Unauthorized"));
}
Ok(User {
id: 42,
username: credentials.username,
password: credentials.password,
})
}
}
pub fn validate_session(session: &Session) -> Result<i64, HttpResponse> {
let user_id: Option<i64> = session.get("user_id").unwrap_or(None);
match user_id {
Some(id) => {
// keep the user's session alive
session.renew();
Ok(id)
}
None => Err(HttpResponse::Unauthorized().json("Unauthorized")),
}
}
async fn login(
credentials: web::Json<Credentials>,
session: Session,
) -> Result<impl Responder, Error> {
let credentials = credentials.into_inner();
match User::authenticate(credentials) {
Ok(user) => session.insert("user_id", user.id).unwrap(),
Err(err) => return Err(InternalError::from_response("", err).into()),
};
Ok("Welcome!")
}
/// some protected resource
async fn secret(session: Session) -> Result<impl Responder, Error> {
// only allow access to this resource if the user has an active session
validate_session(&session).map_err(|err| InternalError::from_response("", err))?;
Ok("secret revealed")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
std::env::set_var("RUST_LOG", "actix_web=info,actix_redis=info");
env_logger::init();
HttpServer::new(|| {
App::new()
// enable logger
.wrap(middleware::Logger::default())
// cookie session middleware
.wrap(
RedisSession::new("127.0.0.1:6379", &[0; 32])
// 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),
)
.route("/login", web::post().to(login))
.route("/secret", web::get().to(secret))
})
.bind("0.0.0.0:8080")?
.run()
.await
}

View File

@ -1,37 +0,0 @@
use actix_redis::RedisSession;
use actix_session::Session;
use actix_web::{middleware, web, App, Error, HttpRequest, HttpServer, Responder};
/// simple handler
async fn index(req: HttpRequest, session: Session) -> Result<impl Responder, Error> {
println!("{:?}", req);
// session
if let Some(count) = session.get::<i32>("counter")? {
println!("SESSION value: {}", count);
session.insert("counter", count + 1)?;
} else {
session.insert("counter", 1)?;
}
Ok("Welcome!")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
std::env::set_var("RUST_LOG", "actix_web=info,actix_redis=info");
env_logger::init();
HttpServer::new(|| {
App::new()
// enable logger
.wrap(middleware::Logger::default())
// cookie session middleware
.wrap(RedisSession::new("127.0.0.1:6379", &[0; 32]))
// register simple route, handle all methods
.service(web::resource("/").to(index))
})
.bind("0.0.0.0:8080")?
.run()
.await
}

View File

@ -1,4 +1,4 @@
//! Redis integration for Actix and session store for Actix Web.
//! Redis integration for `actix`.
#![deny(rust_2018_idioms, nonstandard_style)]
#![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;

View File

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