mirror of
https://github.com/actix/actix-extras.git
synced 2025-06-27 02:37:42 +02:00
Rework actix session (#212)
Co-authored-by: Rob Ede <robjtede@icloud.com> Co-authored-by: Luca P <rust@lpalmieri.com> Co-authored-by: Sebastian Rollén <38324289+SebRollen@users.noreply.github.com>
This commit is contained in:
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()
|
||||
}
|
Reference in New Issue
Block a user