Skip to content

Commit

Permalink
Add option to always save session
Browse files Browse the repository at this point in the history
This will allow to prolong session validity period with every valid
request at the cost of higher session store write activity and adding
set-cookie header to each response.
  • Loading branch information
0rzech committed Aug 26, 2024
1 parent 02e4c55 commit f48c6a2
Show file tree
Hide file tree
Showing 2 changed files with 186 additions and 4 deletions.
188 changes: 185 additions & 3 deletions src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ struct SessionConfig<'a> {
secure: bool,
path: Cow<'a, str>,
domain: Option<Cow<'a, str>>,
always_save: bool,
}

impl<'a> SessionConfig<'a> {
Expand Down Expand Up @@ -135,6 +136,7 @@ impl<'a> Default for SessionConfig<'a> {
secure: true,
path: "/".into(),
domain: None,
always_save: false,
}
}
}
Expand Down Expand Up @@ -223,7 +225,12 @@ where
let modified = session.is_modified();
let empty = session.is_empty().await;

tracing::trace!(modified = modified, empty = empty, "session response state");
tracing::trace!(
modified = modified,
empty = empty,
always_save = session_config.always_save,
"session response state",

Check warning on line 232 in src/service.rs

View check run for this annotation

Codecov / codecov/patch

src/service.rs#L229-L232

Added lines #L229 - L232 were not covered by tests
);

match session_cookie {
Some(mut cookie) if empty => {
Expand All @@ -241,8 +248,10 @@ where
cookie_controller.remove(&cookies, cookie);
}

// TODO: We can consider an "always save" configuration option:
_ if modified && !empty && !res.status().is_server_error() => {
_ if (modified || session_config.always_save)
&& !empty
&& !res.status().is_server_error() =>
{
tracing::debug!("saving session");
if let Err(err) = session.save().await {
tracing::error!(err = %err, "failed to save session");
Expand Down Expand Up @@ -407,6 +416,37 @@ impl<Store: SessionStore, C: CookieController> SessionManagerLayer<Store, C> {
self
}

/// Configures whether unmodified session should be saved on read or not.
/// When the value is `true`, the session will be saved even if it was not
/// changed.
///
/// This is useful when you want to reset [`Session`] expiration time
/// on any valid request at the cost of higher [`SessionStore`] write
/// activity and transmitting `set-cookie` header with each response.
///
/// It makes sense to use this setting with relative session expiration
/// values, such as `Expiry::OnInactivity(Duration)`. This setting will
/// _not_ cause session id to be cycled on save.
///
/// The default value is `false`.
///
/// # Examples
///
/// ```rust
/// use time::Duration;
/// use tower_sessions::{Expiry, MemoryStore, SessionManagerLayer};
///
/// let session_store = MemoryStore::default();
/// let session_expiry = Expiry::OnInactivity(Duration::hours(1));
/// let session_service = SessionManagerLayer::new(session_store)
/// .with_expiry(session_expiry)
/// .with_always_save(true);
/// ```
pub fn with_always_save(mut self, always_save: bool) -> Self {
self.session_config.always_save = always_save;
self
}

/// Manages the session cookie via a signed interface.
///
/// See [`SignedCookies`](tower_cookies::SignedCookies).
Expand Down Expand Up @@ -500,11 +540,15 @@ impl<S, Store: SessionStore, C: CookieController> Layer<S> for SessionManagerLay

#[cfg(test)]
mod tests {
use std::str::FromStr;

use anyhow::anyhow;
use axum::body::Body;
use tower::{ServiceBuilder, ServiceExt};
use tower_sessions_memory_store::MemoryStore;

use crate::session::Id;

use super::*;

async fn handler(req: Request<Body>) -> anyhow::Result<Response<Body>> {
Expand Down Expand Up @@ -767,6 +811,144 @@ mod tests {
Ok(())
}

#[tokio::test]
async fn always_save_test() -> anyhow::Result<()> {
let get_session_id = |res: &Response<Body>| {
res.headers()
.get(http::header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap()
.split("id=")
.nth(1)
.unwrap()
.split(";")
.next()
.unwrap()
.to_string()
};
let has_expected_max_age_value = |res: &Response<Body>, expected_value: i64| {
res.headers()
.get(http::header::SET_COOKIE)
.is_some_and(|set_cookie| {
set_cookie.to_str().is_ok_and(|s| {
let max_age_value = s
.split("Max-Age=")
.nth(1)
.unwrap_or_default()
.split(';')
.next()
.unwrap_or_default()
.parse::<i64>()
.unwrap_or_default();
(max_age_value - expected_value).abs() <= 1
})
})
};

// on-session-end expiry

let session_store = MemoryStore::default();
let session_layer = SessionManagerLayer::new(session_store.clone())
.with_expiry(Expiry::OnSessionEnd)
.with_always_save(true);
let mut svc = ServiceBuilder::new()
.layer(session_layer)
.service_fn(handler);

let req1 = Request::builder().body(Body::empty())?;
let res1 = svc.call(req1).await?;
let sid1 = get_session_id(&res1);
let rec1 = session_store
.load(&Id::from_str(&sid1).unwrap())
.await?
.unwrap();
let req2 = Request::builder()
.header(http::header::COOKIE, &format!("id={}", sid1))
.body(Body::empty())?;
let res2 = svc.call(req2).await?;
let sid2 = get_session_id(&res2);
let rec2 = session_store
.load(&Id::from_str(&sid2).unwrap())
.await?
.unwrap();

assert!(res2
.headers()
.get(http::header::SET_COOKIE)
.is_some_and(|set_cookie| set_cookie.to_str().is_ok_and(|s| !s.contains("Max-Age"))));
assert!(sid1 == sid2);
assert!(rec1.expiry_date < rec2.expiry_date);

// on-inactivity expiry

let session_store = MemoryStore::default();
let inactivity_duration = time::Duration::hours(2);
let session_layer = SessionManagerLayer::new(session_store.clone())
.with_expiry(Expiry::OnInactivity(inactivity_duration))
.with_always_save(true);
let mut svc = ServiceBuilder::new()
.layer(session_layer)
.service_fn(handler);

let req1 = Request::builder().body(Body::empty())?;
let res1 = svc.call(req1).await?;
let sid1 = get_session_id(&res1);
let rec1 = session_store
.load(&Id::from_str(&sid1).unwrap())
.await?
.unwrap();
let req2 = Request::builder()
.header(http::header::COOKIE, &format!("id={}", sid1))
.body(Body::empty())?;
let res2 = svc.call(req2).await?;
let sid2 = get_session_id(&res2);
let rec2 = session_store
.load(&Id::from_str(&sid2).unwrap())
.await?
.unwrap();

let expected_max_age = inactivity_duration.whole_seconds();
assert!(has_expected_max_age_value(&res2, expected_max_age));
assert!(sid1 == sid2);
assert!(rec1.expiry_date < rec2.expiry_date);

// at-date-time expiry

let session_store = MemoryStore::default();
let expiry_time = time::OffsetDateTime::now_utc() + time::Duration::weeks(1);
let session_layer = SessionManagerLayer::new(session_store.clone())
.with_expiry(Expiry::AtDateTime(expiry_time))
.with_always_save(true);
let mut svc = ServiceBuilder::new()
.layer(session_layer)
.service_fn(handler);

let req1 = Request::builder().body(Body::empty())?;
let res1 = svc.call(req1).await?;
let sid1 = get_session_id(&res1);
let rec1 = session_store
.load(&Id::from_str(&sid1).unwrap())
.await?
.unwrap();
let req2 = Request::builder()
.header(http::header::COOKIE, &format!("id={}", sid1))
.body(Body::empty())?;
let res2 = svc.call(req2).await?;
let sid2 = get_session_id(&res2);
let rec2 = session_store
.load(&Id::from_str(&sid2).unwrap())
.await?
.unwrap();

let expected_max_age = (expiry_time - time::OffsetDateTime::now_utc()).whole_seconds();
assert!(has_expected_max_age_value(&res2, expected_max_age));
assert!(sid1 == sid2);
assert!(rec1.expiry_date == rec2.expiry_date);

Ok(())
}

#[tokio::test]
async fn secure_test() -> anyhow::Result<()> {
let session_store = MemoryStore::default();
Expand Down
2 changes: 1 addition & 1 deletion tower-sessions-core/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -953,7 +953,7 @@ pub enum Expiry {
/// Expire on [current session end][current-session-end], as defined by the
/// browser.
///
/// [current-session-end]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_the_lifetime_of_a_cookie
/// [current-session-end]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#removal_defining_the_lifetime_of_a_cookie
OnSessionEnd,

/// Expire on inactivity.
Expand Down

0 comments on commit f48c6a2

Please sign in to comment.