diff --git a/actix-cors/CHANGES.md b/actix-cors/CHANGES.md index f950c315f5..9584f84c8d 100644 --- a/actix-cors/CHANGES.md +++ b/actix-cors/CHANGES.md @@ -2,6 +2,12 @@ ## Unreleased +## 0.8.0 + +- Added support for DynamoDB. + +[#391](https://github.com/actix/actix-extras/pull/391) + ## 0.7.0 - `Cors` is now marked `#[must_use]`. diff --git a/actix-session/.idea/workspace.xml b/actix-session/.idea/workspace.xml new file mode 100644 index 0000000000..3e899d8d90 --- /dev/null +++ b/actix-session/.idea/workspace.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + { + "associatedIndex": 4 +} + + + + + + + + + + + + + + + 1706382231167 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/actix-session/Cargo.toml b/actix-session/Cargo.toml index 371345e7a6..55c699d6e5 100644 --- a/actix-session/Cargo.toml +++ b/actix-session/Cargo.toml @@ -23,6 +23,7 @@ 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"] +dynamo-db = ["hyper-rustls", "aws-config", "aws-smithy-runtime", "aws-sdk-dynamodb"] [dependencies] actix-service = "2" @@ -44,8 +45,15 @@ futures-core = { version = "0.3.7", default-features = false, optional = true } # redis-rs-session redis = { version = "0.24", default-features = false, features = ["tokio-comp", "connection-manager"], optional = true } +# dynamo-db +hyper = {version="1", features=["client"], default-features = false, optional = true } +hyper-rustls = { version = "0.24.2", features=["webpki-roots", "http1", "http2"], default-features = true, optional = true } +aws-config = { version = "1", default-features = false, optional = true, features = ["rt-tokio", "rustls"] } +aws-smithy-runtime = { version = "1", optional = true, features = ["connector-hyper-0-14-x", "tls-rustls"] } +aws-sdk-dynamodb = { version = "1", optional = true } + [dev-dependencies] -actix-session = { path = ".", features = ["cookie-session", "redis-actor-session", "redis-rs-session"] } +actix-session = { path = ".", features = ["cookie-session", "redis-actor-session", "redis-rs-session", "dynamo-db"] } actix-test = "0.1.0-beta.10" actix-web = { version = "4", default-features = false, features = ["cookies", "secure-cookies", "macros"] } env_logger = "0.11" diff --git a/actix-session/src/lib.rs b/actix-session/src/lib.rs index 31665e524f..53f5a7b492 100644 --- a/actix-session/src/lib.rs +++ b/actix-session/src/lib.rs @@ -28,7 +28,7 @@ provided by `actix-session`; it takes care of all the session cookie handling an against the active [`Session`]. `actix-session` provides some built-in storage backends: ([`CookieSessionStore`], -[`RedisSessionStore`], and [`RedisActorSessionStore`]) - you can create a custom storage backend +[`RedisSessionStore`], [`RedisActorSessionStore`], and [`DynamoDbSessionStore`]) - you can create a custom storage backend by implementing the [`SessionStore`] trait. Further reading on sessions: @@ -133,12 +133,22 @@ attached to your sessions. You can enable: actix-session = { version = "...", features = ["redis-rs-session", "redis-rs-tls-session"] } ``` +- a DynamoDB-based backend via [`dynamo-db`](https://docs.rs/aws-sdk-dynamodb), [`DynamoDbSessionStore`], using + the `dynamo-db` feature flag. + + ```toml + [dependencies] + # ... + actix-session = { version = "...", features = ["dynamo-db"] } + ``` + You can implement your own session storage backend using the [`SessionStore`] trait. [`SessionStore`]: storage::SessionStore [`CookieSessionStore`]: storage::CookieSessionStore [`RedisSessionStore`]: storage::RedisSessionStore [`RedisActorSessionStore`]: storage::RedisActorSessionStore +[`DynamoDbSessionStore`]: storage::DynamoDbSessionStore */ #![forbid(unsafe_code)] diff --git a/actix-session/src/storage/dynamo_db.rs b/actix-session/src/storage/dynamo_db.rs new file mode 100644 index 0000000000..fee351720c --- /dev/null +++ b/actix-session/src/storage/dynamo_db.rs @@ -0,0 +1,529 @@ +use std::{ + ops::Add, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use actix_web::cookie::time::Duration; +use aws_config::{ + default_provider::credentials::DefaultCredentialsChain, meta::region::RegionProviderChain, + BehaviorVersion, +}; +use aws_sdk_dynamodb::{ + config::{Credentials, ProvideCredentials, Region}, + error::SdkError, + types::AttributeValue, + Client, Config, +}; +use aws_smithy_runtime::client::http::hyper_014::HyperClientBuilder; + +use super::SessionKey; +use crate::storage::{ + interface::{LoadError, SaveError, SessionState, UpdateError}, + utils::generate_session_key, + SessionStore, +}; + +/// Use DynamoDB as session storage backend. +/// +/// ```no_run +/// use actix_web::{web, App, HttpServer, HttpResponse, Error}; +/// use actix_session::{SessionMiddleware, storage::DynamoDbSessionStore}; +/// use actix_web::cookie::Key; +/// use aws_config::meta::region::RegionProviderChain; +/// use aws_config::Region; +/// use aws_config::default_provider::credentials::DefaultCredentialsChain; +/// use aws_sdk_dynamodb::config::ProvideCredentials; +/// use aws_sdk_dynamodb::Client; +/// +/// +/// // 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 dynamo_db_store = DynamoDbSessionStore::builder() +/// .table_name(String::from("MyTableName")) +/// .key_name("PK".to_string()) +/// .build() +/// .await?; +/// +/// HttpServer::new(move || +/// App::new() +/// .wrap(SessionMiddleware::new( +/// dynamo_db_store.clone(), +/// secret_key.clone() +/// )) +/// .default_service(web::to(|| HttpResponse::Ok()))) +/// .bind(("127.0.0.1", 8080))? +/// .run() +/// .await +/// } +/// ``` +/// +/// # Implementation notes +/// `DynamoDbSessionStore` leverages [`aws-sdk-dynamodb`] as a DynamoDB client. +/// +/// [`aws-sdk-dynamodb`]: https://github.com/awslabs/aws-sdk-rust +#[derive(Clone)] +pub struct DynamoDbSessionStore { + configuration: CacheConfiguration, + client: Client, +} + + +/// Struct for configuring the DynamoDB Session Store. To add custom configuration, use the `DynamoDbSessionStoreBuilder` +/// +/// [`DynamoDbSessionStoreBuilder`]: crate::storage::DynamoDbSessionStoreBuilder +#[derive(Clone)] +pub struct CacheConfiguration { + cache_keygen: Arc String + Send + Sync>, + table_name: String, + key_name: String, + ttl_name: String, + session_data_name: String, + use_dynamo_db_local: bool, + dynamo_db_local_endpoint: String, + sdk_config: Option, + region: Option, + credentials: Option, +} + +impl Default for CacheConfiguration { + /// Default values for the cache configuration + /// + /// ```no_run + /// Self { + /// cache_keygen: Arc::new(str::to_owned), + /// table_name: "sessions".to_string(), + /// use_dynamo_db_local: false, + /// key_name: "SessionId".to_string(), + /// ttl_name: "session_ttl".to_string(), + /// session_data_name: "session_data".to_string(), + /// dynamo_db_local_endpoint: "http://localhost:8000".to_string(), + /// sdk_config: None, + /// region: None, + /// credentials: None, + /// } + /// ``` + fn default() -> Self { + Self { + cache_keygen: Arc::new(str::to_owned), + table_name: "sessions".to_string(), + use_dynamo_db_local: false, + key_name: "SessionId".to_string(), + ttl_name: "session_ttl".to_string(), + session_data_name: "session_data".to_string(), + dynamo_db_local_endpoint: "http://localhost:8000".to_string(), + sdk_config: None, + region: None, + credentials: None, + } + } +} + +impl DynamoDbSessionStore { + /// A fluent API to configure [`DynamoDbSessionStore`]. + /// It takes as input the only required input to create a new instance of [`DynamoDbSessionStore`]. + /// As a default, it expects a DynamoDB table name of 'sessions', with a single partition key of 'SessionId' this can be overridden using the [`DynamoDbSessionStoreBuilder`]. + pub fn builder() -> DynamoDbSessionStoreBuilder { + DynamoDbSessionStoreBuilder { + configuration: CacheConfiguration::default(), + } + } + + /// Create a new instance of [`DynamoDbSessionStore`] using the default configuration.. + pub async fn new() -> Result { + Self::builder().build().await + } +} + +/// A fluent builder to construct a [`DynamoDbSessionStore`] instance with custom configuration +/// parameters. +/// +/// [`DynamoDbSessionStore`]: crate::storage::DynamoDbSessionStore +#[must_use] +pub struct DynamoDbSessionStoreBuilder { + configuration: CacheConfiguration, +} + +impl DynamoDbSessionStoreBuilder { + /// Set a custom cache key generation strategy, expecting a session key as input. + pub fn cache_keygen(mut self, keygen: F) -> Self + where + F: Fn(&str) -> String + 'static + Send + Sync, + { + self.configuration.cache_keygen = Arc::new(keygen); + self + } + /// Set the DynamoDB table name to use. + pub fn table_name(mut self, table_name: String) -> Self { + self.configuration.table_name = table_name; + self + } + /// Set if DynamoDB local should be used, useful for local testing. + pub fn use_dynamo_db_local(mut self, should_use: bool) -> Self { + self.configuration.use_dynamo_db_local = should_use; + self.configuration.dynamo_db_local_endpoint = "http://localhost:8000".to_string(); + self + } + + /// Set the credentials to use for the DynamoDB client. + pub fn with_credentials(mut self, credentials: Credentials) -> Self { + self.configuration.credentials = Some(credentials); + + self + } + + /// Set the SDK config to use for the DynamoDB client. + pub fn with_sdk_config(mut self, config: Config) -> Self { + self.configuration.sdk_config = Some(config); + + self + } + + /// Set the region to use for the DynamoDB client. + pub fn with_region(mut self, region: Region) -> Self { + self.configuration.region = Some(region); + + self + } + + /// Set the endpoint to use if using DynamoDB Local. Defaults to 'http://localhost:8000'. + pub fn dynamo_db_local_endpoint(mut self, dynamo_db_local_endpoint: String) -> Self { + self.configuration.dynamo_db_local_endpoint = dynamo_db_local_endpoint; + self + } + + /// Set the name of the DynamoDB partition key. + pub fn key_name(mut self, key_name: String) -> Self { + self.configuration.key_name = key_name; + self + } + + /// Set the name of the DynamoDB column to use for the ttl. Defaults to 'ttl'. + pub fn ttl_name(mut self, ttl_name: String) -> Self { + self.configuration.ttl_name = ttl_name; + self + } + + /// Set the name of the DynamoDB column to use for the session data. Defaults to 'session_data'. + pub fn session_data_name(mut self, session_data_name: String) -> Self { + self.configuration.session_data_name = session_data_name; + self + } + + /// Finalise the builder and return a [`DynamoDbSessionStore`] instance. + /// + /// [`DynamoDbSessionStore`]: crate::storage::DynamoDbSessionStore + pub async fn build(self) -> Result { + let region = match &self.configuration.region { + None => make_region_provider().region().await.unwrap(), + Some(r) => r.clone(), + }; + + let credentials = match &self.configuration.credentials { + None => make_credentials_provider(region.clone()).await?, + Some(c) => c.clone(), + }; + + let conf = match &self.configuration.sdk_config { + None => make_config(®ion, &credentials, &self.configuration).await?, + Some(config) => config.clone(), + }; + + let dynamodb_client = aws_sdk_dynamodb::Client::from_conf(conf.clone()); + Ok(DynamoDbSessionStore { + configuration: self.configuration, + client: dynamodb_client, + }) + } +} + +impl SessionStore for DynamoDbSessionStore { + async fn load(&self, session_key: &SessionKey) -> Result, LoadError> { + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + let cache_value = self + .client + .get_item() + .table_name(&self.configuration.table_name) + .key(&self.configuration.key_name, AttributeValue::S(cache_key)) + .send() + .await + .map_err(Into::into) + .map_err(LoadError::Other)?; + + match cache_value.item { + None => Ok(None), + Some(item) => match item["session_data"].as_s() { + Ok(value) => Ok(serde_json::from_str(value) + .map_err(Into::into) + .map_err(LoadError::Deserialization)?), + Err(_) => Ok(None), + }, + } + } + + async fn save( + &self, + session_state: SessionState, + ttl: &Duration, + ) -> Result { + 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 _ = self + .client + .put_item() + .table_name(&self.configuration.table_name) + .item(&self.configuration.key_name, AttributeValue::S(cache_key)) + .item("session_data", AttributeValue::S(body)) + .item( + &self.configuration.ttl_name, + AttributeValue::N(get_epoch_ms(*ttl).to_string()), + ) + .condition_expression( + format!("attribute_not_exists({})", self.configuration.key_name).to_string(), + ) + .send() + .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 { + 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 put_res = self + .client + .put_item() + .table_name(&self.configuration.table_name) + .item(&self.configuration.key_name, AttributeValue::S(cache_key)) + .item("session_data", AttributeValue::S(body)) + .item( + &self.configuration.ttl_name, + AttributeValue::N(get_epoch_ms(*ttl).to_string()), + ) + .condition_expression( + format!("attribute_exists({})", self.configuration.key_name).to_string(), + ) + .send() + .await; + + match put_res { + Ok(_) => Ok(session_key), + Err(err) => { + match err { + // A response error can occur if the condition expression checking the session exists fails + // // 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. + SdkError::ResponseError(_resp_err) => self + .save(session_state, ttl) + .await + .map_err(|err| match err { + SaveError::Serialization(err) => UpdateError::Serialization(err), + SaveError::Other(err) => UpdateError::Other(err), + }), + SdkError::ServiceError(_resp_err) => self + .save(session_state, ttl) + .await + .map_err(|err| match err { + SaveError::Serialization(err) => UpdateError::Serialization(err), + SaveError::Other(err) => UpdateError::Other(err), + }), + _ => Err(UpdateError::Other(anyhow::anyhow!( + "Failed to update session state. {:?}", + err + ))), + } + } + } + } + + async fn update_ttl( + &self, + session_key: &SessionKey, + ttl: &Duration, + ) -> Result<(), anyhow::Error> { + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + let _update_res = self + .client + .update_item() + .table_name(&self.configuration.table_name) + .key(&self.configuration.key_name, AttributeValue::S(cache_key)) + .update_expression("SET ttl = :value") + .expression_attribute_values( + ":value", + AttributeValue::N(get_epoch_ms(*ttl).to_string()), + ) + .send() + .await; + + Ok(()) + } + + async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error> { + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + self.client + .delete_item() + .table_name(&self.configuration.table_name) + .key(&self.configuration.key_name, AttributeValue::S(cache_key)) + .send() + .await + .map_err(Into::into) + .map_err(UpdateError::Other)?; + + Ok(()) + } +} + +fn get_epoch_ms(duration: Duration) -> u128 { + SystemTime::now() + .add(duration) + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() +} + +async fn make_credentials_provider(region: Region) -> Result { + Ok(DefaultCredentialsChain::builder() + .region(region.clone()) + .build() + .await + .provide_credentials() + .await + .unwrap()) +} + +async fn make_config( + region: &Region, + credentials: &Credentials, + configuration: &CacheConfiguration, +) -> Result { + let https_connector = hyper_rustls::HttpsConnectorBuilder::new() + .with_webpki_roots() + .https_or_http() + .enable_http1() + .enable_http2() + .build(); + + let hyper_client = HyperClientBuilder::new().build(https_connector); + + let conf_builder = Config::builder() + .behavior_version(BehaviorVersion::v2023_11_09()) + .credentials_provider(credentials.clone()) + .http_client(hyper_client) + .region(region.clone()); + + Ok(match configuration.use_dynamo_db_local { + true => conf_builder + .endpoint_url(configuration.dynamo_db_local_endpoint.clone()) + .build(), + false => conf_builder.build(), + }) +} + +fn make_region_provider() -> RegionProviderChain { + RegionProviderChain::default_provider().or_else(Region::new("us-east-1")) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use actix_web::cookie::time; + + use super::*; + use crate::test_helpers::acceptance_test_suite; + + async fn dynamo_store() -> DynamoDbSessionStore { + DynamoDbSessionStore::builder() + .table_name("auth".to_string()) + .key_name("PK".to_string()) + .ttl_name("session_ttl".to_string()) + .use_dynamo_db_local(true) + .dynamo_db_local_endpoint("http://localhost:8000".to_string()) + .build() + .await + .unwrap() + } + + #[actix_web::test] + async fn test_session_workflow() { + let dynamo_store = dynamo_store().await; + acceptance_test_suite(move || dynamo_store.clone(), true).await; + } + + #[actix_web::test] + async fn loading_a_missing_session_returns_none() { + let store = dynamo_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 = dynamo_store().await; + let session_key = generate_session_key(); + store + .client + .clone() + .put_item() + .table_name(&store.configuration.table_name) + .item( + &store.configuration.key_name, + AttributeValue::S(session_key.as_ref().to_string()), + ) + .item( + "session_data", + AttributeValue::S("random-thing-that-is-not-json".to_string()), + ) + .item( + &store.configuration.ttl_name, + AttributeValue::N(get_epoch_ms(Duration::seconds(10)).to_string()), + ) + .send() + .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 = dynamo_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()); + } +} diff --git a/actix-session/src/storage/mod.rs b/actix-session/src/storage/mod.rs index 17508850f8..23aa87dfa2 100644 --- a/actix-session/src/storage/mod.rs +++ b/actix-session/src/storage/mod.rs @@ -20,8 +20,13 @@ mod redis_rs; #[cfg(any(feature = "redis-actor-session", feature = "redis-rs-session"))] mod utils; +#[cfg(feature = "dynamo-db")] +mod dynamo_db; + #[cfg(feature = "cookie-session")] pub use cookie::CookieSessionStore; +#[cfg(feature = "dynamo-db")] +pub use dynamo_db::{DynamoDbSessionStore, DynamoDbSessionStoreBuilder}; #[cfg(feature = "redis-actor-session")] pub use redis_actor::{RedisActorSessionStore, RedisActorSessionStoreBuilder}; #[cfg(feature = "redis-rs-session")]