diff --git a/Cargo.toml b/Cargo.toml index 13ef5a6..48353ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ authors = [ "Yoshua Wuyts ", "Irina Shestak ", "Anton Whalley ", + "Javier Viola " ] [features] @@ -24,6 +25,7 @@ async-trait = "0.1.36" serde_json = "1.0.56" [dev-dependencies] -async-std = { version = "1.6.2", features = ["attributes"] } +async-std = { version = "1.8.0", features = ["attributes"] } rand = {version = "0.7.3"} lazy_static = "1" +tide = "0.15" diff --git a/README.md b/README.md index f121775..ebc4214 100644 --- a/README.md +++ b/README.md @@ -46,12 +46,66 @@ $ cargo add async-mongodb-session ``` -## Configuration +## Overview +By default this library utilises the document expiration feature based on [specific clock time](https://docs.mongodb.com/manual/tutorial/expire-data/#expire-documents-at-a-specific-clock-time) supported by mongodb to auto-expire the session. + +For other option to offloading the session expiration to the mongodb layer check the [Advance options](#advance-options). + +## Example with tide +Create an HTTP server that keep track of user visits in the session. + +```rust +#[async_std::main] +async fn main() -> tide::Result<()> { + tide::log::start(); + let mut app = tide::new(); + + app.with(tide::sessions::SessionMiddleware::new( + MongodbSessionStore::new("mongodb://127.0.0.1:27017", "db_name", "collection").await?, + std::env::var("TIDE_SECRET") + .expect( + "Please provide a TIDE_SECRET value of at \ + least 32 bytes in order to run this example", + ) + .as_bytes(), + )); + + app.with(tide::utils::Before( + |mut request: tide::Request<()>| async move { + let session = request.session_mut(); + let visits: usize = session.get("visits").unwrap_or_default(); + session.insert("visits", visits + 1).unwrap(); + request + }, + )); + + app.at("/").get(|req: tide::Request<()>| async move { + let visits: usize = req.session().get("visits").unwrap(); + Ok(format!("you have visited this website {} times", visits)) + }); + + app.at("/reset") + .get(|mut req: tide::Request<()>| async move { + req.session_mut().destroy(); + Ok(tide::Redirect::new("/")) + }); + + app.listen("127.0.0.1:8080").await?; + + Ok(()) +} +``` +## Advance options +a [specified number of seconds](https://docs.mongodb.com/manual/tutorial/expire-data/#expire-documents-after-a-specified-number-of-seconds) or in a + +The specified number of seconds approach is designed to enable the session time out to be managed at the mongodb layer. This approach provides a globally consistent session timeout across multiple processes but has the downside that all services using the same session collection must use the same timeout value. -This library utilises the document [expiry feature](https://docs.mongodb.com/manual/tutorial/expire-data/#expire-documents-after-a-specified-number-of-seconds) in mongodb to expire the session. +The specific clock time clock time approach is where you require more flexibility on your session timeouts such as a different session timeout per running service or you would prefer to manage the session time out at the process level. This is more flexible but might lead to some perceived inconsistency in session timeout depending on your upgrade/rollout strategy. The management of the expiry feature fits into the 12 factor [admin process definintion](https://12factor.net/admin-processes) so it's recommended to use an process outside of your web application to manage the expiry parameters. +## Manual configuration + A `created` property is available on the root of the session document that so the [expiry feature](https://docs.mongodb.com/manual/tutorial/expire-data/#expire-documents-after-a-specified-number-of-seconds) can be used in the configuration. If your application code to create a session store is something like: @@ -62,14 +116,25 @@ let store = MongodbSessionStore::connect("mongodb://127.0.0.1:27017", "db_name", Then the script to create the expiry would be: ``` use db_name; -db.coll_session.createIndex( { created": 1 } , { expireAfterSeconds: 300 } ) +db.coll_session.createIndex( { "created": 1 } , { expireAfterSeconds: 300 } ); ``` If you wish to redefine the session duration then the index must be dropped first using: ``` use db_name; db.coll_session.dropIndex( { "created": 1 }) -db.coll_session.createIndex( { created": 1 } , { expireAfterSeconds: 300 } ) +db.coll_session.createIndex( { "created": 1 } , { expireAfterSeconds: 300 } ); +``` + +Other way to set create the index is using `index_on_created` passing the amount of seconds to expiry after the session. + +Also, an `expireAt` property is available on the root of the session document IFF the session expire is set. Note that [async-session doesn't set by default](https://github.com/http-rs/async-session/blob/main/src/session.rs#L98). + +To enable this [expiry feature](https://docs.mongodb.com/manual/tutorial/expire-data/#expire-documents-at-a-specific-clock-time) at `index` for `expireAt` should be created calling `index_on_expiry_at` function or with this script ( following the above example ) + +``` +use db_name; +db.coll_session.createIndex( { "expireAt": 1 } , { expireAfterSeconds: 0 } ); ``` ## Test diff --git a/examples/mongodb_session_store.rs b/examples/mongodb_session_store.rs new file mode 100644 index 0000000..355040e --- /dev/null +++ b/examples/mongodb_session_store.rs @@ -0,0 +1,44 @@ +extern crate async_mongodb_session; +extern crate tide; + +use async_mongodb_session::MongodbSessionStore; + +#[async_std::main] +async fn main() -> tide::Result<()> { + tide::log::start(); + let mut app = tide::new(); + + app.with(tide::sessions::SessionMiddleware::new( + MongodbSessionStore::new("mongodb://127.0.0.1:27017", "db_name", "collection").await?, + std::env::var("TIDE_SECRET") + .expect( + "Please provide a TIDE_SECRET value of at \ + least 32 bytes in order to run this example", + ) + .as_bytes(), + )); + + app.with(tide::utils::Before( + |mut request: tide::Request<()>| async move { + let session = request.session_mut(); + let visits: usize = session.get("visits").unwrap_or_default(); + session.insert("visits", visits + 1).unwrap(); + request + }, + )); + + app.at("/").get(|req: tide::Request<()>| async move { + let visits: usize = req.session().get("visits").unwrap(); + Ok(format!("you have visited this website {} times", visits)) + }); + + app.at("/reset") + .get(|mut req: tide::Request<()>| async move { + req.session_mut().destroy(); + Ok(tide::Redirect::new("/")) + }); + + app.listen("127.0.0.1:8080").await?; + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 431dc4f..ab639db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,7 +7,7 @@ //! use async_session::{Session, SessionStore}; //! //! # fn main() -> async_session::Result { async_std::task::block_on(async { -//! let store = MongodbSessionStore::connect("mongodb://127.0.0.1:27017", "db_name", "collection"); +//! let store = MongodbSessionStore::new("mongodb://127.0.0.1:27017", "db_name", "collection"); //! # Ok(()) }) } //! ``` @@ -15,12 +15,12 @@ #![deny(missing_debug_implementations, nonstandard_style)] #![warn(missing_docs, missing_doc_code_examples, unreachable_pub)] -use async_session::chrono::Utc; +use async_session::chrono::{Duration, Utc}; use async_session::{Result, Session, SessionStore}; use async_trait::async_trait; use mongodb::bson; use mongodb::bson::doc; -use mongodb::options::ReplaceOptions; +use mongodb::options::{ReplaceOptions, SelectionCriteria}; use mongodb::Client; /// A MongoDB session store. @@ -29,21 +29,24 @@ pub struct MongodbSessionStore { client: mongodb::Client, db: String, coll_name: String, + ttl: usize, } impl MongodbSessionStore { - /// Create a new instance of `MongodbSessionStore`. + /// Create a new instance of `MongodbSessionStore` after stablish the connection to monngodb. /// ```rust /// # fn main() -> async_session::Result { async_std::task::block_on(async { /// # use async_mongodb_session::MongodbSessionStore; /// let store = - /// MongodbSessionStore::connect("mongodb://127.0.0.1:27017", "db_name", "collection") + /// MongodbSessionStore::new("mongodb://127.0.0.1:27017", "db_name", "collection") /// .await?; /// # Ok(()) }) } /// ``` - pub async fn connect(uri: &str, db: &str, coll_name: &str) -> mongodb::error::Result { + pub async fn new(uri: &str, db: &str, coll_name: &str) -> mongodb::error::Result { let client = Client::with_uri_str(uri).await?; - Ok(Self::from_client(client, db, coll_name)) + let middleware = Self::from_client(client, db, coll_name); + middleware.create_expire_index("expireAt", 0).await?; + Ok(middleware) } /// Create a new instance of `MongodbSessionStore` from an open client. @@ -70,8 +73,120 @@ impl MongodbSessionStore { client, db: db.to_string(), coll_name: coll_name.to_string(), + ttl: 1200, // 20 mins by default. } } + + /// Initialize the default expiration mechanism, based on the document expiration + /// that mongodb provides https://docs.mongodb.com/manual/tutorial/expire-data/#expire-documents-at-a-specific-clock-time. + /// The default ttl applyed to sessions without expiry is 20 minutes. + /// If the `expireAt` date field contains a date in the past, mongodb considers the document expired and will be deleted. + /// Note: mongodb runs the expiration logic every 60 seconds. + /// ```rust + /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use async_mongodb_session::MongodbSessionStore; + /// let store = + /// MongodbSessionStore::new("mongodb://127.0.0.1:27017", "db_name", "collection") + /// .await?; + /// store.initialize().await?; + /// # Ok(()) }) } + /// ``` + pub async fn initialize(&self) -> Result { + &self.index_on_expiry_at().await?; + Ok(()) + } + + /// Get the default ttl value in seconds. + /// ```rust + /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use async_mongodb_session::MongodbSessionStore; + /// let store = + /// MongodbSessionStore::new("mongodb://127.0.0.1:27017", "db_name", "collection") + /// .await?; + /// let ttl = store.ttl(); + /// # Ok(()) }) } + /// ``` + pub fn ttl(&self) -> usize { + self.ttl + } + + /// Set the default ttl value in seconds. + /// ```rust + /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use async_mongodb_session::MongodbSessionStore; + /// let mut store = + /// MongodbSessionStore::new("mongodb://127.0.0.1:27017", "db_name", "collection") + /// .await?; + /// store.set_ttl(300); + /// # Ok(()) }) } + /// ``` + pub fn set_ttl(&mut self, ttl: usize) { + self.ttl = ttl; + } + + /// private associated function + /// Create an `expire after seconds` index in the provided field. + /// Testing is covered by initialize test. + async fn create_expire_index( + &self, + field_name: &str, + expire_after_seconds: u32, + ) -> mongodb::error::Result<()> { + let create_index = doc! { + "createIndexes": &self.coll_name, + "indexes": [ + { + "key" : { field_name: 1 }, + "name": format!("session_expire_index_{}", field_name), + "expireAfterSeconds": expire_after_seconds, + } + ] + }; + self.client + .database(&self.db) + .run_command( + create_index, + SelectionCriteria::ReadPreference(mongodb::options::ReadPreference::Primary), + ) + .await?; + Ok(()) + } + + /// Create a new index for the `created` property and set the expiry ttl (in secods). + /// The session will expire when the number of seconds in the expireAfterSeconds field has passed + /// since the time specified in its created field. + /// https://docs.mongodb.com/manual/tutorial/expire-data/#expire-documents-after-a-specified-number-of-seconds + /// ```rust + /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use async_mongodb_session::MongodbSessionStore; + /// let store = + /// MongodbSessionStore::new("mongodb://127.0.0.1:27017", "db_name", "collection") + /// .await?; + /// store.index_on_created(300).await?; + /// # Ok(()) }) } + /// ``` + pub async fn index_on_created(&self, expire_after_seconds: u32) -> Result { + self.create_expire_index("created", expire_after_seconds) + .await?; + Ok(()) + } + + /// Create a new index for the `expireAt` property, allowing to expire sessions at a specific clock time. + /// If the `expireAt` date field contains a date in the past, mongodb considers the document expired and will be deleted. + /// https://docs.mongodb.com/manual/tutorial/expire-data/#expire-documents-at-a-specific-clock-time + /// ```rust + /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use async_mongodb_session::MongodbSessionStore; + /// let store = + /// MongodbSessionStore::new("mongodb://127.0.0.1:27017", "db_name", "collection") + /// .await?; + /// store.index_on_expiry_at().await?; + /// # Ok(()) }) } + /// ``` + pub async fn index_on_expiry_at(&self) -> Result { + self.create_expire_index("expireAt", 0).await?; + Ok(()) + } } #[async_trait] @@ -82,7 +197,13 @@ impl SessionStore for MongodbSessionStore { let value = bson::to_bson(&session)?; let id = session.id(); let query = doc! { "session_id": id }; - let replacement = doc! { "session_id": id, "session": value, "created": Utc::now() }; + let expire_at = match session.expiry() { + None => Utc::now() + Duration::from_std(std::time::Duration::from_secs(1200)).unwrap(), + + Some(expiry) => *{ expiry }, + }; + let replacement = doc! { "session_id": id, "session": value, "expireAt": expire_at, "created": Utc::now() }; + let opts = ReplaceOptions::builder().upsert(true).build(); coll.replace_one(query, replacement, Some(opts)).await?; @@ -114,7 +235,8 @@ impl SessionStore for MongodbSessionStore { async fn clear_store(&self) -> Result { let coll = self.client.database(&self.db).collection(&self.coll_name); - coll.drop(None).await?; // does this need to be followed by a create? + coll.drop(None).await?; + self.initialize().await?; Ok(()) } } diff --git a/tests/test.rs b/tests/test.rs index a390569..f1a477f 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -44,10 +44,10 @@ mod tests { } #[test] - fn test_connect() -> async_session::Result { + fn test_new() -> async_session::Result { async_std::task::block_on(async { let store = - MongodbSessionStore::connect(&CONNECTION_STRING, "db_name", "collection").await?; + MongodbSessionStore::new(&CONNECTION_STRING, "db_name", "collection").await?; let mut rng = rand::thread_rng(); let n2: u16 = rng.gen(); @@ -63,4 +63,59 @@ mod tests { Ok(()) }) } + + #[test] + fn test_with_expire() -> async_session::Result { + async_std::task::block_on(async { + let store = + MongodbSessionStore::new(&CONNECTION_STRING, "db_name", "collection").await?; + + store.initialize().await?; + + let mut rng = rand::thread_rng(); + let n2: u16 = rng.gen(); + let key = format!("key-{}", n2); + let value = format!("value-{}", n2); + let mut session = Session::new(); + session.expire_in(std::time::Duration::from_secs(5)); + session.insert(&key, &value)?; + + let cookie_value = store.store_session(session).await?.unwrap(); + let session = store.load_session(cookie_value).await?.unwrap(); + assert_eq!(&session.get::(&key).unwrap(), &value); + + Ok(()) + }) + } + + #[test] + fn test_check_expired() -> async_session::Result { + use async_std::task; + use std::time::Duration; + async_std::task::block_on(async { + let store = + MongodbSessionStore::new(&CONNECTION_STRING, "db_name", "collection").await?; + + store.initialize().await?; + + let mut rng = rand::thread_rng(); + let n2: u16 = rng.gen(); + let key = format!("key-{}", n2); + let value = format!("value-{}", n2); + let mut session = Session::new(); + session.expire_in(Duration::from_secs(1)); + session.insert(&key, &value)?; + + let cookie_value = store.store_session(session).await?.unwrap(); + + // mongodb runs the background task that removes expired documents runs every 60 seconds. + // https://docs.mongodb.com/manual/core/index-ttl/#timing-of-the-delete-operation + task::sleep(Duration::from_secs(60)).await; + let session_to_recover = store.load_session(cookie_value).await?; + + assert!(&session_to_recover.is_none()); + + Ok(()) + }) + } }