From 09221c11477a6199be5905f2fc740ffd4ab7b975 Mon Sep 17 00:00:00 2001 From: James Eastham Date: Sat, 27 Jan 2024 19:27:12 +0000 Subject: [PATCH 1/7] Add DynamoDB implementation --- actix-session/Cargo.toml | 10 +- actix-session/src/storage/dynamo_db.rs | 345 +++++++++++++++++++++++++ actix-session/src/storage/mod.rs | 5 + 3 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 actix-session/src/storage/dynamo_db.rs diff --git a/actix-session/Cargo.toml b/actix-session/Cargo.toml index 371345e7a6..bd8cb0ee5e 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 } +aws-smithy-runtime = { version = "1", default-features = false, optional = true, features = ["connector-hyper-0-14-x"] } +aws-sdk-dynamodb = { version = "1", default-features = false, 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/storage/dynamo_db.rs b/actix-session/src/storage/dynamo_db.rs new file mode 100644 index 0000000000..98a6a94257 --- /dev/null +++ b/actix-session/src/storage/dynamo_db.rs @@ -0,0 +1,345 @@ +use actix_web::cookie::time::Duration; +use aws_config::default_provider::credentials::DefaultCredentialsChain; +use aws_config::BehaviorVersion; +use aws_sdk_dynamodb::config::{Credentials, ProvideCredentials, Region}; +use aws_sdk_dynamodb::{Client, Config}; +use aws_smithy_runtime::client::http::hyper_014::HyperClientBuilder; +use std::ops::Add; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use aws_config::meta::region::RegionProviderChain; +use aws_sdk_dynamodb::error::SdkError; +use aws_sdk_dynamodb::types::AttributeValue; +use super::SessionKey; +use crate::storage::{ + interface::{LoadError, SaveError, SessionState, UpdateError}, + utils::generate_session_key, + SessionStore, +}; + +#[derive(Clone)] +pub struct DynamoDbSessionStore { + configuration: CacheConfiguration, + client: Client, +} + +#[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 { + 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: "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 + } + + /// 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), + }) + } + _ => 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 + .map_err(Into::into) + .map_err(SaveError::Other)?; + 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")) +} diff --git a/actix-session/src/storage/mod.rs b/actix-session/src/storage/mod.rs index 17508850f8..33046733ff 100644 --- a/actix-session/src/storage/mod.rs +++ b/actix-session/src/storage/mod.rs @@ -20,9 +20,14 @@ mod redis_rs; #[cfg(any(feature = "redis-actor-session", feature = "redis-rs-session"))] mod utils; +#[cfg(any(feature = "dynamo-db"))] +mod dynamo_db; + #[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}; +#[cfg(feature = "dynamo-db")] +pub use dynamo_db::{DynamoDbSessionStore, DynamoDbSessionStoreBuilder}; From 7f4437b38d7512ab4435be23d1819f9173d055c3 Mon Sep 17 00:00:00 2001 From: James Eastham Date: Sun, 28 Jan 2024 09:07:33 +0000 Subject: [PATCH 2/7] Fix CI build errors --- actix-session/src/lib.rs | 12 ++++- actix-session/src/storage/dynamo_db.rs | 71 +++++++++++++++++++++++++- actix-session/src/storage/mod.rs | 2 +- 3 files changed, 81 insertions(+), 4 deletions(-) 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 index 98a6a94257..0475cb41ff 100644 --- a/actix-session/src/storage/dynamo_db.rs +++ b/actix-session/src/storage/dynamo_db.rs @@ -17,6 +17,52 @@ use crate::storage::{ 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, @@ -60,7 +106,7 @@ impl 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(), + configuration: CacheConfiguration::default() } } @@ -76,7 +122,7 @@ impl DynamoDbSessionStore { /// [`DynamoDbSessionStore`]: crate::storage::DynamoDbSessionStore #[must_use] pub struct DynamoDbSessionStoreBuilder { - configuration: CacheConfiguration, + configuration: CacheConfiguration } impl DynamoDbSessionStoreBuilder { @@ -99,6 +145,27 @@ impl DynamoDbSessionStoreBuilder { 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; diff --git a/actix-session/src/storage/mod.rs b/actix-session/src/storage/mod.rs index 33046733ff..a194813d62 100644 --- a/actix-session/src/storage/mod.rs +++ b/actix-session/src/storage/mod.rs @@ -20,7 +20,7 @@ mod redis_rs; #[cfg(any(feature = "redis-actor-session", feature = "redis-rs-session"))] mod utils; -#[cfg(any(feature = "dynamo-db"))] +#[cfg(feature = "dynamo-db")] mod dynamo_db; #[cfg(feature = "cookie-session")] From 427c3bbe884d89b3288aa0598140c8e83383d80b Mon Sep 17 00:00:00 2001 From: James Eastham Date: Sun, 28 Jan 2024 09:53:18 +0000 Subject: [PATCH 3/7] Fix tests --- actix-session/Cargo.toml | 6 +- actix-session/src/storage/dynamo_db.rs | 89 ++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/actix-session/Cargo.toml b/actix-session/Cargo.toml index bd8cb0ee5e..55c699d6e5 100644 --- a/actix-session/Cargo.toml +++ b/actix-session/Cargo.toml @@ -48,9 +48,9 @@ redis = { version = "0.24", default-features = false, features = ["tokio-comp", # 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 } -aws-smithy-runtime = { version = "1", default-features = false, optional = true, features = ["connector-hyper-0-14-x"] } -aws-sdk-dynamodb = { version = "1", default-features = false, 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", "dynamo-db"] } diff --git a/actix-session/src/storage/dynamo_db.rs b/actix-session/src/storage/dynamo_db.rs index 0475cb41ff..3f3f7a6f14 100644 --- a/actix-session/src/storage/dynamo_db.rs +++ b/actix-session/src/storage/dynamo_db.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use aws_config::meta::region::RegionProviderChain; use aws_sdk_dynamodb::error::SdkError; +use aws_sdk_dynamodb::operation::update_item::UpdateItemError; use aws_sdk_dynamodb::types::AttributeValue; use super::SessionKey; use crate::storage::{ @@ -90,7 +91,7 @@ impl Default for CacheConfiguration { table_name: "sessions".to_string(), use_dynamo_db_local: false, key_name: "SessionId".to_string(), - ttl_name: "ttl".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, @@ -142,6 +143,7 @@ impl DynamoDbSessionStoreBuilder { /// 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 } @@ -316,6 +318,14 @@ impl SessionStore for DynamoDbSessionStore { 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. {:?}", @@ -339,9 +349,8 @@ impl SessionStore for DynamoDbSessionStore { AttributeValue::N(get_epoch_ms(*ttl).to_string()), ) .send() - .await - .map_err(Into::into) - .map_err(SaveError::Other)?; + .await; + Ok(()) } @@ -410,3 +419,75 @@ async fn make_config( 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()); + } +} \ No newline at end of file From 81dcea6e62c67866a538f07cc5b8b6116cfad115 Mon Sep 17 00:00:00 2001 From: James Eastham Date: Sun, 28 Jan 2024 09:56:15 +0000 Subject: [PATCH 4/7] Update changelog --- actix-cors/CHANGES.md | 6 ++ actix-session/.idea/workspace.xml | 94 +++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 actix-session/.idea/workspace.xml 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..780045bdb2 --- /dev/null +++ b/actix-session/.idea/workspace.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + { + "associatedIndex": 4 +} + + + + { + "keyToString": { + "RunOnceActivity.OpenProjectViewOnStart": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "git-widget-placeholder": "master", + "last_opened_file_path": "/Users/jameseastham/source/github/actix-extras/actix-session", + "nodejs_package_manager_path": "npm", + "org.rust.cargo.project.model.PROJECT_DISCOVERY": "true", + "vue.rearranger.settings.migration": "true" + } +} + + + + + + + + + + + 1706382231167 + + + + + + \ No newline at end of file From 95e78861dc7d34f7670a84c4cd39cf6ee54a393e Mon Sep 17 00:00:00 2001 From: James Eastham Date: Sun, 28 Jan 2024 09:56:42 +0000 Subject: [PATCH 5/7] Run cargo fmt --- actix-session/src/storage/dynamo_db.rs | 89 +++++++++++++++----------- actix-session/src/storage/mod.rs | 4 +- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/actix-session/src/storage/dynamo_db.rs b/actix-session/src/storage/dynamo_db.rs index 3f3f7a6f14..04f850dc5c 100644 --- a/actix-session/src/storage/dynamo_db.rs +++ b/actix-session/src/storage/dynamo_db.rs @@ -1,16 +1,23 @@ +use std::{ + ops::Add, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + use actix_web::cookie::time::Duration; -use aws_config::default_provider::credentials::DefaultCredentialsChain; -use aws_config::BehaviorVersion; -use aws_sdk_dynamodb::config::{Credentials, ProvideCredentials, Region}; -use aws_sdk_dynamodb::{Client, Config}; +use aws_config::{ + default_provider::credentials::DefaultCredentialsChain, meta::region::RegionProviderChain, + BehaviorVersion, +}; +use aws_sdk_dynamodb::{ + config::{Credentials, ProvideCredentials, Region}, + error::SdkError, + operation::update_item::UpdateItemError, + types::AttributeValue, + Client, Config, +}; use aws_smithy_runtime::client::http::hyper_014::HyperClientBuilder; -use std::ops::Add; -use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; -use aws_config::meta::region::RegionProviderChain; -use aws_sdk_dynamodb::error::SdkError; -use aws_sdk_dynamodb::operation::update_item::UpdateItemError; -use aws_sdk_dynamodb::types::AttributeValue; + use super::SessionKey; use crate::storage::{ interface::{LoadError, SaveError, SessionState, UpdateError}, @@ -107,7 +114,7 @@ impl 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() + configuration: CacheConfiguration::default(), } } @@ -123,14 +130,14 @@ impl DynamoDbSessionStore { /// [`DynamoDbSessionStore`]: crate::storage::DynamoDbSessionStore #[must_use] pub struct DynamoDbSessionStoreBuilder { - configuration: CacheConfiguration + 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, + where + F: Fn(&str) -> String + 'static + Send + Sync, { self.configuration.cache_keygen = Arc::new(keygen); self @@ -306,36 +313,40 @@ impl SessionStore for DynamoDbSessionStore { 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) + 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) + }), + 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 + ))), } - _ => Err(UpdateError::Other(anyhow::anyhow!( - "Failed to update session state. {:?}", - err - ))), - }, + } } } - async fn update_ttl(&self, session_key: &SessionKey, ttl: &Duration) -> Result<(), anyhow::Error> { + 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 @@ -463,8 +474,14 @@ mod tests { .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.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()), @@ -490,4 +507,4 @@ mod tests { .unwrap(); assert_ne!(initial_session_key, updated_session_key.as_ref()); } -} \ No newline at end of file +} diff --git a/actix-session/src/storage/mod.rs b/actix-session/src/storage/mod.rs index a194813d62..23aa87dfa2 100644 --- a/actix-session/src/storage/mod.rs +++ b/actix-session/src/storage/mod.rs @@ -25,9 +25,9 @@ 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")] pub use redis_rs::{RedisSessionStore, RedisSessionStoreBuilder}; -#[cfg(feature = "dynamo-db")] -pub use dynamo_db::{DynamoDbSessionStore, DynamoDbSessionStoreBuilder}; From 54e9de22a34ff904e5fb033d3624f95430a55805 Mon Sep 17 00:00:00 2001 From: James Eastham Date: Sun, 28 Jan 2024 09:59:53 +0000 Subject: [PATCH 6/7] Update docs for cache configuration --- actix-session/.idea/workspace.xml | 63 ++++++++++++++++++++------ actix-session/src/storage/dynamo_db.rs | 20 ++++++++ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/actix-session/.idea/workspace.xml b/actix-session/.idea/workspace.xml index 780045bdb2..3e899d8d90 100644 --- a/actix-session/.idea/workspace.xml +++ b/actix-session/.idea/workspace.xml @@ -7,11 +7,7 @@ - - - - - + - { - "keyToString": { - "RunOnceActivity.OpenProjectViewOnStart": "true", - "RunOnceActivity.ShowReadmeOnStart": "true", - "git-widget-placeholder": "master", - "last_opened_file_path": "/Users/jameseastham/source/github/actix-extras/actix-session", - "nodejs_package_manager_path": "npm", - "org.rust.cargo.project.model.PROJECT_DISCOVERY": "true", - "vue.rearranger.settings.migration": "true" + +}]]> + + + + + \ No newline at end of file diff --git a/actix-session/src/storage/dynamo_db.rs b/actix-session/src/storage/dynamo_db.rs index 04f850dc5c..6edb9e513b 100644 --- a/actix-session/src/storage/dynamo_db.rs +++ b/actix-session/src/storage/dynamo_db.rs @@ -77,6 +77,10 @@ pub struct DynamoDbSessionStore { 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>, @@ -92,6 +96,22 @@ pub struct CacheConfiguration { } 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), From a7af8385f351e87aa78d4fc04a032f85ccb803cc Mon Sep 17 00:00:00 2001 From: James Eastham Date: Sun, 28 Jan 2024 10:00:21 +0000 Subject: [PATCH 7/7] Removed unused import --- actix-session/src/storage/dynamo_db.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/actix-session/src/storage/dynamo_db.rs b/actix-session/src/storage/dynamo_db.rs index 6edb9e513b..fee351720c 100644 --- a/actix-session/src/storage/dynamo_db.rs +++ b/actix-session/src/storage/dynamo_db.rs @@ -12,7 +12,6 @@ use aws_config::{ use aws_sdk_dynamodb::{ config::{Credentials, ProvideCredentials, Region}, error::SdkError, - operation::update_item::UpdateItemError, types::AttributeValue, Client, Config, };