Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/server/src/console/pages/auth_pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 46 additions & 10 deletions crates/server/src/console/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
}

Expand All @@ -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)
Expand Down Expand Up @@ -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;
}
Expand Down
104 changes: 102 additions & 2 deletions crates/server/src/console/session_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/manuals/01-architecture-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/manuals/05-admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/manuals/10-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading