diff --git a/crates/server/src/console/pages/auth_pages.rs b/crates/server/src/console/pages/auth_pages.rs index 7021544d6..62bb4c52b 100755 --- a/crates/server/src/console/pages/auth_pages.rs +++ b/crates/server/src/console/pages/auth_pages.rs @@ -14,7 +14,7 @@ use serde::Deserialize; use crate::console::ConsoleState; use crate::console::html; -use crate::console::session::CredentialStamp; +use crate::console::session::{CredentialStamp, SESSION_MAX_LIFETIME_SECS}; use crate::management::CallerIdentity; use crate::management::auth::log_password_check_error; use crate::rate_limit::{self, LoginAttempt, LoginRefusal}; @@ -58,7 +58,7 @@ pub async fn login_submit( }; let (token, _csrf) = state.sessions.create(identity, stamp).await; - let cookie = session_cookie(&token, 28800, state.secure_cookies); + let cookie = session_cookie(&token, SESSION_MAX_LIFETIME_SECS, state.secure_cookies); ( StatusCode::SEE_OTHER, diff --git a/crates/server/src/console/session.rs b/crates/server/src/console/session.rs index cc71ff2b2..51e61c176 100755 --- a/crates/server/src/console/session.rs +++ b/crates/server/src/console/session.rs @@ -5,8 +5,10 @@ //! //! Sessions are random 32-byte hex tokens stored in a cookie. They map to a //! `CallerIdentity` (admin or IAM user) and a CSRF token for form protection. -//! Sessions expire after 8 hours of inactivity. A background reaper is not -//! needed: expired sessions are pruned lazily on lookup. +//! Sessions end 8 hours after login, however active they are, which is also +//! when the browser drops the session cookie. A session idle for 8 hours ends +//! too. A background reaper is not needed: expired sessions are pruned lazily +//! on lookup. //! //! Each session also holds a [`CredentialStamp`] of the password hash its //! principal logged in with. [`SessionStore::validate`] reads the principal's @@ -26,8 +28,25 @@ use tokio::sync::Mutex; use crate::management::CallerIdentity; /// How long a session remains valid without activity. +/// +/// No longer than [`SESSION_MAX_LIFETIME`]: activity always follows login, so +/// with the two equal the lifetime ends every session first. The idle check +/// stays so that a shorter idle timeout takes effect if one is set. const SESSION_TTL: Duration = Duration::from_secs(8 * 3600); +/// How long a session remains valid after login, in seconds, however active +/// it is. The login cookie's `Max-Age` is this value, so a browser drops the +/// cookie when the server ends the session. +pub(crate) const SESSION_MAX_LIFETIME_SECS: u32 = 8 * 3600; + +/// How long a session remains valid after login, however active it is. +/// +/// Activity refreshes the idle timeout, so without this cap a session token +/// that is used at least every [`SESSION_TTL`] would stay valid until the +/// process restarts. The cookie's `Max-Age` does not bound it: the server +/// never sees that attribute, and a stolen token can be sent without it. +const SESSION_MAX_LIFETIME: Duration = Duration::from_secs(SESSION_MAX_LIFETIME_SECS as u64); + /// Maximum sessions before lazy pruning triggers on insert. const MAX_SESSIONS: usize = 10_000; @@ -51,9 +70,19 @@ struct SessionEntry { identity: CallerIdentity, csrf_token: String, stamp: CredentialStamp, + created_at: Instant, last_active: Instant, } +impl SessionEntry { + /// Whether the session has been idle longer than `ttl`, or has existed + /// longer than `max_lifetime`, as of `now`. + fn is_expired(&self, now: Instant, ttl: Duration, max_lifetime: Duration) -> bool { + now.saturating_duration_since(self.last_active) > ttl + || now.saturating_duration_since(self.created_at) > max_lifetime + } +} + /// Result of [`SessionStore::validate`]. pub enum SessionCheck { /// The session is live and its principal still has the password hash the @@ -81,6 +110,9 @@ pub struct SessionStore { /// How long a session lasts without activity: [`SESSION_TTL`], except in /// tests. ttl: Duration, + /// How long a session lasts after login: [`SESSION_MAX_LIFETIME`], except + /// in tests. + max_lifetime: Duration, } impl Default for SessionStore { @@ -96,16 +128,18 @@ impl SessionStore { Self { sessions: Mutex::new(HashMap::new()), ttl: SESSION_TTL, + max_lifetime: SESSION_MAX_LIFETIME, } } /// Create an empty session store whose sessions last `ttl` without - /// activity. + /// activity and `max_lifetime` after login. #[cfg(test)] - fn with_ttl(ttl: Duration) -> Self { + fn with_limits(ttl: Duration, max_lifetime: Duration) -> Self { Self { sessions: Mutex::new(HashMap::new()), ttl, + max_lifetime, } } @@ -118,18 +152,19 @@ impl SessionStore { ) -> (String, String) { let token = generate_token(); let csrf = generate_token(); + let now = Instant::now(); let entry = SessionEntry { identity, csrf_token: csrf.clone(), stamp, - last_active: Instant::now(), + created_at: now, + last_active: now, }; let mut map = self.sessions.lock().await; // Lazy prune if too many sessions. if map.len() >= MAX_SESSIONS { - let now = Instant::now(); - let ttl = self.ttl; - map.retain(|_, e| now.duration_since(e.last_active) < ttl); + let (ttl, max_lifetime) = (self.ttl, self.max_lifetime); + map.retain(|_, e| !e.is_expired(now, ttl, max_lifetime)); } map.insert(token.clone(), entry); (token, csrf) @@ -166,11 +201,12 @@ impl SessionStore { } /// Look up a session by token. Returns `(identity, csrf_token, stamp)` if - /// it has not expired. Does not refresh the last-active timestamp. + /// it has neither been idle too long nor outlived its maximum lifetime. + /// Does not refresh the last-active timestamp. async fn lookup(&self, token: &str) -> Option<(CallerIdentity, String, CredentialStamp)> { let mut map = self.sessions.lock().await; let entry = map.get(token)?; - if entry.last_active.elapsed() > self.ttl { + if entry.is_expired(Instant::now(), self.ttl, self.max_lifetime) { map.remove(token); return None; } diff --git a/crates/server/src/console/session_tests.rs b/crates/server/src/console/session_tests.rs index 2dce1eddc..10867ab22 100644 --- a/crates/server/src/console/session_tests.rs +++ b/crates/server/src/console/session_tests.rs @@ -11,7 +11,10 @@ use std::time::{Duration, Instant}; use extenddb_storage::management_store::{OpError, OpResult, PasswordHashStore}; use futures::future::BoxFuture; -use super::{CredentialStamp, SESSION_TTL, SessionCheck, SessionStore}; +use super::{ + CredentialStamp, SESSION_MAX_LIFETIME, SESSION_MAX_LIFETIME_SECS, SESSION_TTL, SessionCheck, + SessionStore, +}; use crate::management::CallerIdentity; const ACCOUNT: &str = "111111111111"; @@ -256,9 +259,106 @@ fn sessions_last_eight_hours_without_activity() { assert_eq!(SessionStore::new().ttl, SESSION_TTL); } +#[test] +fn sessions_last_at_most_eight_hours_after_login() { + assert_eq!(SESSION_MAX_LIFETIME, Duration::from_secs(8 * 3600)); + assert_eq!(SESSION_MAX_LIFETIME_SECS, 8 * 3600); + assert_eq!(SessionStore::new().max_lifetime, SESSION_MAX_LIFETIME); + // The idle timeout is never longer than the lifetime it sits inside. + assert!(SESSION_TTL <= SESSION_MAX_LIFETIME); +} + +/// Move a session's login time `by` into the past. +async fn backdate_login(store: &SessionStore, token: &str, by: Duration) -> Instant { + let then = Instant::now() + .checked_sub(by) + .expect("the monotonic clock is past the backdate"); + store + .sessions + .lock() + .await + .get_mut(token) + .expect("session exists") + .created_at = then; + then +} + +#[tokio::test] +async fn an_active_session_ends_at_its_maximum_lifetime_and_stays_ended() { + let store = SessionStore::with_limits(Duration::from_secs(60), Duration::from_secs(120)); + let catalog = FakeCatalog::default(); + let alice = admin_session(&store, &catalog, "alice", "hash-alice").await; + let bob = admin_session(&store, &catalog, "bob", "hash-bob").await; + backdate_login(&store, &alice, Duration::from_secs(121)).await; + backdate_login(&store, &bob, Duration::from_secs(110)).await; + // Both were active just now, so only the lifetime can end them. + assert!(last_active(&store, &alice).await.elapsed() < Duration::from_secs(60)); + + assert!(is_invalid(&store, &catalog, &alice).await); + assert!(!store.sessions.lock().await.contains_key(&alice)); + // A later request with the same token does not bring the session back. + assert!(is_invalid(&store, &catalog, &alice).await); + + assert!(is_valid(&store, &catalog, &bob).await); +} + +#[tokio::test] +async fn activity_does_not_extend_the_maximum_lifetime() { + let store = SessionStore::new(); + let catalog = FakeCatalog::default(); + let alice = admin_session(&store, &catalog, "alice", "hash-alice").await; + let login = backdate_login(&store, &alice, Duration::from_secs(60)).await; + backdate(&store, &alice, Duration::from_secs(30)).await; + + assert!(is_valid(&store, &catalog, &alice).await); + + let map = store.sessions.lock().await; + assert_eq!(map[&alice].created_at, login); + assert!(map[&alice].last_active > login + Duration::from_secs(59)); +} + +#[tokio::test] +async fn creating_a_session_prunes_idle_and_outlived_sessions() { + let store = SessionStore::with_limits(Duration::from_secs(60), Duration::from_secs(120)); + let catalog = FakeCatalog::default(); + // Active just now, but logged in past the lifetime. + let outlived = admin_session(&store, &catalog, "alice", "hash-alice").await; + backdate_login(&store, &outlived, Duration::from_secs(121)).await; + // Within the lifetime, but idle past the idle timeout. + let idle = admin_session(&store, &catalog, "bob", "hash-bob").await; + backdate_login(&store, &idle, Duration::from_secs(61)).await; + backdate(&store, &idle, Duration::from_secs(61)).await; + { + // Fill the store to the pruning threshold with live sessions. + let mut map = store.sessions.lock().await; + let template = map[&outlived].identity.clone(); + let now = Instant::now(); + for i in map.len()..super::MAX_SESSIONS { + map.insert( + format!("filler-{i}"), + super::SessionEntry { + identity: template.clone(), + csrf_token: String::new(), + stamp: CredentialStamp::of("hash-alice"), + created_at: now, + last_active: now, + }, + ); + } + } + + let fresh = admin_session(&store, &catalog, "alice", "hash-alice").await; + + let map = store.sessions.lock().await; + assert!(!map.contains_key(&outlived)); + assert!(!map.contains_key(&idle)); + assert!(map.contains_key(&fresh)); + assert_eq!(map.len(), super::MAX_SESSIONS - 1); +} + #[tokio::test] async fn a_session_idle_longer_than_the_ttl_ends_and_stays_ended() { - let store = SessionStore::with_ttl(Duration::from_secs(60)); + let store = SessionStore::with_limits(Duration::from_secs(60), SESSION_MAX_LIFETIME); let catalog = FakeCatalog::default(); let alice = admin_session(&store, &catalog, "alice", "hash-alice").await; let bob = admin_session(&store, &catalog, "bob", "hash-bob").await; diff --git a/docs/getting-started.md b/docs/getting-started.md index c5e2ea3fb..d86090d4f 100755 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1281,7 +1281,7 @@ Navigate to `https://127.0.0.1:18443/console/` in your browser (adjust the host Admin users have full access to all management operations. IAM users can view their own details and manage their own access keys. -Sessions expire after 8 hours of inactivity. Click "Logout" to end a session immediately. Deleting a user or changing its password ends that user's sessions at their next request. +Sessions end 8 hours after login. Click "Logout" to end a session immediately. Deleting a user or changing its password ends that user's sessions at their next request. ## 9. Audit logging diff --git a/docs/manuals/01-architecture-guide.md b/docs/manuals/01-architecture-guide.md index 36ccd41c7..2da7280df 100755 --- a/docs/manuals/01-architecture-guide.md +++ b/docs/manuals/01-architecture-guide.md @@ -247,7 +247,7 @@ TLS is supported via rustls. `extenddb init` generates a self-signed certificate - CSRF tokens on all state-changing POST handlers - HttpOnly session cookies - Security headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy -- Sessions expire after 8 hours of inactivity +- Sessions end 8 hours after login, however active they are, when the session cookie also expires ### Input Validation @@ -277,7 +277,7 @@ The console reads all data through four of the traits that `CatalogStore` combin ### Authentication Model -- Session-based authentication; sessions expire after 8 hours of inactivity +- Session-based authentication; sessions end 8 hours after login, however active they are, when the session cookie also expires - Sessions stored in-memory with random tokens in HttpOnly cookies (`SameSite=Strict`, `Path=/console`) - Each request reads the principal's password hash from the catalog and ends the session if the principal was deleted or its password changed - CSRF tokens on all state-changing POST handlers diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index aa2b9b10b..eee29ebe0 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -829,7 +829,7 @@ The management web console is served at `/console/` on the same port as the Dyna - Admin users: enter username and password - IAM users: enter `account_id/user_name` as username, console password as password -Sessions expire after 8 hours of inactivity. Click "Logout" to end immediately. Deleting an admin or IAM user, or changing its password, ends its console sessions on every server at their next request. +Sessions end 8 hours after login. Click "Logout" to end immediately. Deleting an admin or IAM user, or changing its password, ends its console sessions on every server at their next request. ## Monitoring diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index e8b663d28..5f1f6b943 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -227,7 +227,7 @@ An admin session can read and change every account. Every page and form under `/ The management web console (`/console/*`) implements: - **CSRF protection**: Unique tokens generated per session, injected into all forms via JavaScript, validated on every POST handler -- **Session management**: Server-side sessions that expire after 8 hours of inactivity, HttpOnly and `Secure` cookies with `SameSite=Strict` and `Path=/console` (a dev-mode build serves plain HTTP and leaves `Secure` off) +- **Session management**: Server-side sessions that end 8 hours after login however active they are (the cookie's `Max-Age` is the same 8 hours), HttpOnly and `Secure` cookies with `SameSite=Strict` and `Path=/console` (a dev-mode build serves plain HTTP and leaves `Secure` off) - **Session revocation**: A session is bound to the password hash its admin or IAM user logged in with. Every console request reads that hash from the catalog again. When the principal has been deleted, or its password has been changed, the session is removed and the request is redirected to the login page. This happens at the session's next request on every server that shares the catalog, because no server keeps a copy of the hash between requests. On Cassandra the read and the writes that change the hash use `LOCAL_QUORUM`, so a server in another datacenter sees the change once it has replicated there. If the catalog cannot be read, the request gets HTTP 503 and the session is kept. A refused request does not count as activity, so the 8 hour idle timeout keeps running during a catalog outage. - **Security headers**: X-Content-Type-Options (nosniff), X-Frame-Options (DENY), Referrer-Policy (strict-origin-when-cross-origin) - **HSTS**: Sent automatically (TLS is always enabled) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index df803d1cd..42ba8e9b7 100755 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -854,7 +854,7 @@ The error log line names the exact relations for one leaked table and gives this ### Console redirects to login page on every request -**Cause:** The session cookie has expired (sessions last 8 hours of inactivity), the user's password was changed or the user was deleted after the session began, or cookies are blocked by the browser. +**Cause:** The session cookie has expired (sessions end 8 hours after login, however active they are, and the browser drops the cookie at the same time), the user's password was changed or the user was deleted after the session began, or cookies are blocked by the browser. **Fix:** Log in again, with the new password if it was changed. Ensure cookies are enabled for the extenddb server address. If using a reverse proxy, ensure it forwards the `Cookie` and `Set-Cookie` headers.