austin-sapien (100.70.96.88) sat dead for over two hours after taking v1.7.122 — 'server starting' in the UI, service inactive, exit status 0/SUCCESS. It did not crash: the in-process updater replaces the binary and exits cleanly for systemd to restart it, and that node's unit still carried Restart=on-failure from an older install. systemd read the clean exit as success and left it stopped. Every node with the old unit has this waiting for it on the next update. self-update.sh does refresh units, but the in-process update path never runs it, so nothing was repairing them. The daemon now checks its own unit at boot and rewrites only the Restart= line, so a node that starts even once ends up with a policy that survives the next update. Also carries the session-policy wiring: validate() now honours the configured idle and absolute limits and the per-device class, instead of the single hard-coded 24h constant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
751 lines
28 KiB
Rust
751 lines
28 KiB
Rust
use hmac::{Hmac, Mac};
|
||
use rand::RngCore;
|
||
use sha2::{Digest, Sha256};
|
||
use std::collections::HashMap;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
use tokio::sync::{OnceCell, RwLock};
|
||
use zeroize::Zeroize;
|
||
|
||
/// Cached remember secret — loaded once, never regenerated within a process.
|
||
static REMEMBER_SECRET: OnceCell<Vec<u8>> = OnceCell::const_new();
|
||
|
||
type HmacSha256 = Hmac<Sha256>;
|
||
|
||
const FULL_SESSION_TTL: u64 = 86400; // 24 hours of inactivity
|
||
const PENDING_SESSION_TTL: u64 = 300; // 5 minutes
|
||
const MAX_TOTP_ATTEMPTS: u8 = 5;
|
||
const MAX_CONCURRENT_SESSIONS: usize = 50;
|
||
const SESSIONS_FILE: &str = "/var/lib/archipelago/sessions.json";
|
||
const REMEMBER_SECRET_FILE: &str = "/var/lib/archipelago/remember_secret";
|
||
pub const REMEMBER_TTL: u64 = 30 * 24 * 3600; // 30 days
|
||
|
||
#[derive(Clone)]
|
||
enum SessionType {
|
||
Full,
|
||
PendingTotp { totp_secret: Vec<u8>, attempts: u8 },
|
||
}
|
||
|
||
impl Drop for SessionType {
|
||
fn drop(&mut self) {
|
||
if let SessionType::PendingTotp { totp_secret, .. } = self {
|
||
totp_secret.zeroize();
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct Session {
|
||
created_at: SystemTime,
|
||
last_activity: SystemTime,
|
||
session_type: SessionType,
|
||
/// What kind of screen this login came from. A TV on the wall must not
|
||
/// be signed out for sitting still — nobody is there to type a password
|
||
/// back in — while a browser must be.
|
||
device_class: crate::settings::session_policy::DeviceClass,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub struct SessionStore {
|
||
sessions: Arc<RwLock<HashMap<[u8; 32], Session>>>,
|
||
persist_path: PathBuf,
|
||
/// Where the session policy lives. Held rather than looked up globally
|
||
/// so tests can point at a temp dir.
|
||
data_dir: PathBuf,
|
||
}
|
||
|
||
/// On-disk representation of a persisted session (only Full sessions, no TOTP secrets).
|
||
#[derive(serde::Serialize, serde::Deserialize)]
|
||
struct PersistedSession {
|
||
hash_hex: String,
|
||
created_at: u64, // Unix timestamp
|
||
last_activity: u64, // Unix timestamp
|
||
}
|
||
|
||
impl SessionStore {
|
||
pub async fn new() -> Self {
|
||
let persist_path = PathBuf::from(SESSIONS_FILE);
|
||
let sessions = Self::load_from_disk(&persist_path).await;
|
||
let count = sessions.len();
|
||
if count > 0 {
|
||
tracing::info!("Restored {} sessions from disk", count);
|
||
}
|
||
Self {
|
||
sessions: Arc::new(RwLock::new(sessions)),
|
||
persist_path,
|
||
data_dir: PathBuf::from("/var/lib/archipelago"),
|
||
}
|
||
}
|
||
|
||
/// Construct an empty SessionStore that persists to a caller-supplied
|
||
/// path. Used by tests so they don't pick up sessions from the dev
|
||
/// machine's real /var/lib/archipelago/sessions.json.
|
||
#[cfg(test)]
|
||
pub fn new_for_tests(persist_path: PathBuf) -> Self {
|
||
// data_dir shares the temp path's parent so a test that writes a
|
||
// policy file is honoured, and one that doesn't gets the defaults
|
||
// rather than the dev machine's real configuration.
|
||
let data_dir = persist_path
|
||
.parent()
|
||
.map(PathBuf::from)
|
||
.unwrap_or_else(|| PathBuf::from("."));
|
||
Self {
|
||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||
persist_path,
|
||
data_dir,
|
||
}
|
||
}
|
||
|
||
/// Load persisted sessions from disk (only Full sessions).
|
||
async fn load_from_disk(path: &Path) -> HashMap<[u8; 32], Session> {
|
||
let mut map = HashMap::new();
|
||
let data = match tokio::fs::read_to_string(path).await {
|
||
Ok(d) => d,
|
||
Err(_) => return map,
|
||
};
|
||
let persisted: Vec<PersistedSession> = match serde_json::from_str(&data) {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
tracing::warn!("Failed to parse sessions file: {}", e);
|
||
return map;
|
||
}
|
||
};
|
||
let now_unix = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs();
|
||
for p in persisted {
|
||
// Skip expired sessions
|
||
if now_unix.saturating_sub(p.last_activity) >= FULL_SESSION_TTL {
|
||
continue;
|
||
}
|
||
let hash = match hex::decode(&p.hash_hex) {
|
||
Ok(h) if h.len() == 32 => {
|
||
let mut arr = [0u8; 32];
|
||
arr.copy_from_slice(&h);
|
||
arr
|
||
}
|
||
_ => continue,
|
||
};
|
||
let created_at = UNIX_EPOCH + std::time::Duration::from_secs(p.created_at);
|
||
let last_activity = UNIX_EPOCH + std::time::Duration::from_secs(p.last_activity);
|
||
map.insert(
|
||
hash,
|
||
Session {
|
||
created_at,
|
||
last_activity,
|
||
session_type: SessionType::Full,
|
||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||
},
|
||
);
|
||
}
|
||
map
|
||
}
|
||
|
||
/// Save all Full sessions to disk. Called after mutations.
|
||
async fn save_to_disk(sessions: &HashMap<[u8; 32], Session>, path: &Path) {
|
||
let persisted: Vec<PersistedSession> = sessions
|
||
.iter()
|
||
.filter(|(_, s)| matches!(s.session_type, SessionType::Full))
|
||
.map(|(hash, s)| PersistedSession {
|
||
hash_hex: hex::encode(hash),
|
||
created_at: s
|
||
.created_at
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs(),
|
||
last_activity: s
|
||
.last_activity
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs(),
|
||
})
|
||
.collect();
|
||
if let Ok(json) = serde_json::to_string(&persisted) {
|
||
let _ = tokio::fs::write(path, json).await;
|
||
}
|
||
}
|
||
|
||
/// Create a full (authenticated) session. Returns the plaintext token.
|
||
/// Enforces max concurrent sessions by evicting the oldest if limit reached.
|
||
pub async fn create(&self) -> String {
|
||
let token = fresh_session_token();
|
||
let hash = hash_token(&token);
|
||
let now = SystemTime::now();
|
||
let session = Session {
|
||
created_at: now,
|
||
last_activity: now,
|
||
session_type: SessionType::Full,
|
||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||
};
|
||
|
||
let mut sessions = self.sessions.write().await;
|
||
self.evict_if_over_limit(&mut sessions);
|
||
sessions.insert(hash, session);
|
||
// Sync save — must complete before returning the token to the client.
|
||
// Async save risks losing the session if the process is killed (e.g., deploy restart).
|
||
Self::save_to_disk(&sessions, &self.persist_path).await;
|
||
token
|
||
}
|
||
|
||
/// Create a pending TOTP session (password verified, awaiting TOTP).
|
||
/// Caches the decrypted TOTP secret in memory for verification.
|
||
pub async fn create_pending(&self, totp_secret: Vec<u8>) -> String {
|
||
let token = fresh_session_token();
|
||
let hash = hash_token(&token);
|
||
let now = SystemTime::now();
|
||
let session = Session {
|
||
created_at: now,
|
||
last_activity: now,
|
||
session_type: SessionType::PendingTotp {
|
||
totp_secret,
|
||
attempts: 0,
|
||
},
|
||
// A half-finished login is always treated as a browser: it lives
|
||
// for PENDING_SESSION_TTL either way, and a kiosk exemption on a
|
||
// session that has not passed 2FA yet would be the wrong default.
|
||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||
};
|
||
self.sessions.write().await.insert(hash, session);
|
||
token
|
||
}
|
||
|
||
/// Validate a full session token. Returns true if the session exists and hasn't expired.
|
||
/// Updates last_activity on successful validation (inactivity-based expiry).
|
||
pub async fn validate(&self, token: &str) -> bool {
|
||
let policy = self.policy().await;
|
||
let hash = hash_token(token);
|
||
let mut sessions = self.sessions.write().await;
|
||
if let Some(session) = sessions.get_mut(&hash) {
|
||
if !matches!(session.session_type, SessionType::Full) {
|
||
return false;
|
||
}
|
||
let idle = session
|
||
.last_activity
|
||
.elapsed()
|
||
.unwrap_or_default()
|
||
.as_secs();
|
||
let age = session.created_at.elapsed().unwrap_or_default().as_secs();
|
||
// Both limits, not just idleness: the dashboard polls, so an
|
||
// idle timeout alone would never fire on an open tab. The
|
||
// absolute cap is what actually guarantees a login ends.
|
||
if policy.is_expired(session.device_class, age, idle) {
|
||
sessions.remove(&hash);
|
||
return false;
|
||
}
|
||
session.last_activity = SystemTime::now();
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
/// The operator's session policy, re-read from disk rather than cached
|
||
/// for the process lifetime so a change in Settings takes effect on the
|
||
/// next request instead of the next restart.
|
||
pub async fn policy(&self) -> crate::settings::session_policy::SessionPolicy {
|
||
crate::settings::session_policy::load(&self.data_dir).await
|
||
}
|
||
|
||
/// Get the TOTP secret from a pending session. Returns None if not a valid pending session.
|
||
/// Increments the attempt counter.
|
||
pub async fn get_pending_secret(&self, token: &str) -> Option<Vec<u8>> {
|
||
let hash = hash_token(token);
|
||
let mut sessions = self.sessions.write().await;
|
||
if let Some(session) = sessions.get_mut(&hash) {
|
||
if session.created_at.elapsed().unwrap_or_default().as_secs() >= PENDING_SESSION_TTL {
|
||
sessions.remove(&hash);
|
||
return None;
|
||
}
|
||
if let SessionType::PendingTotp {
|
||
ref totp_secret,
|
||
ref mut attempts,
|
||
} = session.session_type
|
||
{
|
||
*attempts += 1;
|
||
if *attempts > MAX_TOTP_ATTEMPTS {
|
||
sessions.remove(&hash); // Too many attempts, force re-login
|
||
return None;
|
||
}
|
||
return Some(totp_secret.clone());
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Upgrade a pending session to a full session with token rotation.
|
||
/// Deletes the old pending session and creates a new full session with a fresh token.
|
||
/// Returns the new plaintext token so the caller can set it as the new cookie.
|
||
pub async fn upgrade_to_full(&self, token: &str) -> Option<String> {
|
||
let old_hash = hash_token(token);
|
||
let mut sessions = self.sessions.write().await;
|
||
// Only upgrade if the old session exists and is pending
|
||
if sessions.remove(&old_hash).is_some() {
|
||
let new_token = fresh_session_token();
|
||
let new_hash = hash_token(&new_token);
|
||
let now = SystemTime::now();
|
||
self.evict_if_over_limit(&mut sessions);
|
||
sessions.insert(
|
||
new_hash,
|
||
Session {
|
||
created_at: now,
|
||
last_activity: now,
|
||
session_type: SessionType::Full,
|
||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||
},
|
||
);
|
||
Self::save_to_disk(&sessions, &self.persist_path).await;
|
||
Some(new_token)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
pub async fn remove(&self, token: &str) {
|
||
let hash = hash_token(token);
|
||
let mut sessions = self.sessions.write().await;
|
||
sessions.remove(&hash);
|
||
Self::save_to_disk(&sessions, &self.persist_path).await;
|
||
}
|
||
|
||
/// Invalidate all sessions except the one matching the given token.
|
||
/// Used after sensitive operations like password change.
|
||
pub async fn invalidate_all_except(&self, keep_token: &str) {
|
||
let keep_hash = hash_token(keep_token);
|
||
let mut sessions = self.sessions.write().await;
|
||
sessions.retain(|hash, _| *hash == keep_hash);
|
||
Self::save_to_disk(&sessions, &self.persist_path).await;
|
||
}
|
||
|
||
/// Rotate a session: invalidate the old token and create a new one.
|
||
/// Returns the new plaintext token.
|
||
pub async fn rotate(&self, old_token: &str) -> String {
|
||
let old_hash = hash_token(old_token);
|
||
let new_token = fresh_session_token();
|
||
let new_hash = hash_token(&new_token);
|
||
let now = SystemTime::now();
|
||
|
||
let mut sessions = self.sessions.write().await;
|
||
sessions.remove(&old_hash);
|
||
sessions.insert(
|
||
new_hash,
|
||
Session {
|
||
created_at: now,
|
||
last_activity: now,
|
||
session_type: SessionType::Full,
|
||
device_class: crate::settings::session_policy::DeviceClass::Browser,
|
||
},
|
||
);
|
||
Self::save_to_disk(&sessions, &self.persist_path).await;
|
||
new_token
|
||
}
|
||
|
||
/// Remove all expired sessions (cleanup).
|
||
#[cfg(test)]
|
||
pub async fn cleanup_expired(&self) {
|
||
let mut sessions = self.sessions.write().await;
|
||
sessions.retain(|_, session| match &session.session_type {
|
||
SessionType::Full => {
|
||
session
|
||
.last_activity
|
||
.elapsed()
|
||
.unwrap_or_default()
|
||
.as_secs()
|
||
< FULL_SESSION_TTL
|
||
}
|
||
SessionType::PendingTotp { .. } => {
|
||
session.created_at.elapsed().unwrap_or_default().as_secs() < PENDING_SESSION_TTL
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Evict the oldest full session if at or over the concurrent limit.
|
||
fn evict_if_over_limit(&self, sessions: &mut HashMap<[u8; 32], Session>) {
|
||
let full_count = sessions
|
||
.values()
|
||
.filter(|s| matches!(s.session_type, SessionType::Full))
|
||
.count();
|
||
|
||
if full_count >= MAX_CONCURRENT_SESSIONS {
|
||
// Find the oldest full session by last_activity
|
||
if let Some(oldest_hash) = sessions
|
||
.iter()
|
||
.filter(|(_, s)| matches!(s.session_type, SessionType::Full))
|
||
.min_by_key(|(_, s)| s.last_activity)
|
||
.map(|(h, _)| *h)
|
||
{
|
||
sessions.remove(&oldest_hash);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Get the number of active full sessions.
|
||
#[cfg(test)]
|
||
pub async fn active_session_count(&self) -> usize {
|
||
let sessions = self.sessions.read().await;
|
||
sessions
|
||
.values()
|
||
.filter(|s| {
|
||
matches!(s.session_type, SessionType::Full)
|
||
&& s.last_activity.elapsed().unwrap_or_default().as_secs() < FULL_SESSION_TTL
|
||
})
|
||
.count()
|
||
}
|
||
|
||
// ── Remember-me token ──────────────────────────────────────────────
|
||
// HMAC-signed token that survives backend restarts. Secret is on disk.
|
||
// Format: "timestamp_hex:hmac_hex"
|
||
|
||
/// Create a remember-me token. Returns the cookie value.
|
||
pub async fn create_remember_token(&self) -> String {
|
||
let secret = Self::load_or_create_remember_secret().await;
|
||
let now = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs();
|
||
let ts_hex = hex::encode(now.to_be_bytes());
|
||
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
|
||
mac.update(format!("remember:{}", ts_hex).as_bytes());
|
||
let sig = hex::encode(mac.finalize().into_bytes());
|
||
format!("{}:{}", ts_hex, sig)
|
||
}
|
||
|
||
/// Validate a remember-me token. Returns true if valid and not expired.
|
||
pub async fn validate_remember_token(token: &str) -> bool {
|
||
let secret = match tokio::fs::read(REMEMBER_SECRET_FILE).await {
|
||
Ok(s) if s.len() == 32 => s,
|
||
_ => return false,
|
||
};
|
||
let parts: Vec<&str> = token.splitn(2, ':').collect();
|
||
if parts.len() != 2 {
|
||
return false;
|
||
}
|
||
let ts_hex = parts[0];
|
||
let sig_hex = parts[1];
|
||
|
||
// Verify HMAC
|
||
let mut mac = match HmacSha256::new_from_slice(&secret) {
|
||
Ok(m) => m,
|
||
Err(_) => return false,
|
||
};
|
||
mac.update(format!("remember:{}", ts_hex).as_bytes());
|
||
let expected_sig = match hex::decode(sig_hex) {
|
||
Ok(s) => s,
|
||
Err(_) => return false,
|
||
};
|
||
if mac.verify_slice(&expected_sig).is_err() {
|
||
return false;
|
||
}
|
||
|
||
// Check expiry
|
||
let ts_bytes = match hex::decode(ts_hex) {
|
||
Ok(b) if b.len() == 8 => {
|
||
let mut arr = [0u8; 8];
|
||
arr.copy_from_slice(&b);
|
||
u64::from_be_bytes(arr)
|
||
}
|
||
_ => return false,
|
||
};
|
||
let now = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs();
|
||
now.saturating_sub(ts_bytes) < REMEMBER_TTL
|
||
}
|
||
|
||
pub async fn load_or_create_remember_secret() -> Vec<u8> {
|
||
REMEMBER_SECRET
|
||
.get_or_init(|| async {
|
||
// Try existing secret file first
|
||
if let Ok(secret) = tokio::fs::read(REMEMBER_SECRET_FILE).await {
|
||
if secret.len() == 32 {
|
||
return secret;
|
||
}
|
||
}
|
||
// Generate a cryptographically random 32-byte secret on first boot
|
||
let mut secret = [0u8; 32];
|
||
rand::rngs::OsRng.fill_bytes(&mut secret);
|
||
// Ensure parent directory exists
|
||
if let Some(parent) = std::path::Path::new(REMEMBER_SECRET_FILE).parent() {
|
||
let _ = tokio::fs::create_dir_all(parent).await;
|
||
}
|
||
let _ = tokio::fs::write(REMEMBER_SECRET_FILE, &secret).await;
|
||
secret.to_vec()
|
||
})
|
||
.await
|
||
.clone()
|
||
}
|
||
}
|
||
|
||
/// Mint a fresh 32-byte session token, hex-encoded.
|
||
///
|
||
/// KEY-05: the entropy source is named (`OsRng`) rather than inherited from a
|
||
/// dependency default, and the draw is inspected by the degenerate-entropy
|
||
/// predicate before it becomes a bearer credential.
|
||
///
|
||
/// **This aborts rather than returning on a degenerate draw, and that is
|
||
/// deliberate.** `create`, `create_pending` and `rotate` return a bare `String`,
|
||
/// and their callers live in `api/rpc/mod.rs` and `api/rpc/totp.rs` — files plan
|
||
/// 10-06 does not own; widening them to `Result` is an API change this plan is
|
||
/// explicitly not permitted to make. The only two behaviours available at this
|
||
/// seam are therefore "mint a predictable session token" and "refuse loudly",
|
||
/// and only the second is defensible: reaching this branch means the kernel
|
||
/// CSPRNG returned 32 bytes that are all-zero, all-identical or a ±1 counter,
|
||
/// i.e. the machine has no usable entropy and must not be issuing credentials at
|
||
/// all. It cannot be driven by attacker-supplied input — the predicate reads only
|
||
/// `OsRng` output — and the false-trip bound at 32 bytes is `3 · 2^−248`
|
||
/// (`docs/security/KEY-05-ENTROPY-ENFORCEMENT.md`).
|
||
fn fresh_session_token() -> String {
|
||
let mut token_bytes = [0u8; 32];
|
||
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut token_bytes).unwrap_or_else(|e| {
|
||
panic!("refusing to mint a session token from degenerate entropy: {e} (KEY-05)")
|
||
});
|
||
hex::encode(token_bytes)
|
||
}
|
||
|
||
fn hash_token(token: &str) -> [u8; 32] {
|
||
let mut hasher = Sha256::new();
|
||
hasher.update(token.as_bytes());
|
||
hasher.finalize().into()
|
||
}
|
||
|
||
/// Extract the session token from a Cookie header value.
|
||
pub fn extract_session_cookie(headers: &hyper::HeaderMap) -> Option<String> {
|
||
headers
|
||
.get("cookie")
|
||
.and_then(|v| v.to_str().ok())
|
||
.and_then(|cookies| {
|
||
cookies.split(';').find_map(|c| {
|
||
let c = c.trim();
|
||
c.strip_prefix("session=").map(|v| v.to_string())
|
||
})
|
||
})
|
||
.filter(|v| !v.is_empty())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Unique suffix for a per-test temp file path.
|
||
///
|
||
/// KEY-05 migrates test fixtures for the same reason it migrates production
|
||
/// code: CI runs clippy with `--all-targets`, so a `rand::random()` left in a
|
||
/// test is a build failure once the ban is live. This is not key material —
|
||
/// it is a filename component — so it is drawn unguarded, and it names its
|
||
/// source like everything else.
|
||
fn uniq() -> u64 {
|
||
rand::RngCore::next_u64(&mut rand::rngs::OsRng)
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_session_create_and_validate() {
|
||
let store = SessionStore::new_for_tests(
|
||
std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())),
|
||
);
|
||
let token = store.create().await;
|
||
|
||
assert!(store.validate(&token).await);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_session_invalid_token() {
|
||
let store = SessionStore::new_for_tests(
|
||
std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())),
|
||
);
|
||
assert!(!store.validate("nonexistent_token").await);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_session_remove() {
|
||
let store = SessionStore::new_for_tests(
|
||
std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())),
|
||
);
|
||
let token = store.create().await;
|
||
|
||
assert!(store.validate(&token).await);
|
||
store.remove(&token).await;
|
||
assert!(!store.validate(&token).await);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_pending_session_upgrade() {
|
||
let store = SessionStore::new_for_tests(
|
||
std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())),
|
||
);
|
||
let secret = vec![1, 2, 3, 4];
|
||
let token = store.create_pending(secret.clone()).await;
|
||
|
||
// Pending session should not validate as full
|
||
assert!(!store.validate(&token).await);
|
||
|
||
// Can get the TOTP secret
|
||
let got = store.get_pending_secret(&token).await;
|
||
assert_eq!(got, Some(secret));
|
||
|
||
// Upgrade to full — returns a new rotated token
|
||
let new_token = store.upgrade_to_full(&token).await;
|
||
assert!(new_token.is_some());
|
||
let new_token = new_token.unwrap();
|
||
|
||
// Old token should be invalid (rotated)
|
||
assert!(!store.validate(&token).await);
|
||
// New token should be valid
|
||
assert!(store.validate(&new_token).await);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_pending_session_max_attempts() {
|
||
let store = SessionStore::new_for_tests(
|
||
std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())),
|
||
);
|
||
let secret = vec![1, 2, 3];
|
||
let token = store.create_pending(secret).await;
|
||
|
||
// Exhaust MAX_TOTP_ATTEMPTS (5) + 1 to trigger removal
|
||
for _ in 0..MAX_TOTP_ATTEMPTS {
|
||
assert!(store.get_pending_secret(&token).await.is_some());
|
||
}
|
||
// 6th attempt should fail (session removed)
|
||
assert!(store.get_pending_secret(&token).await.is_none());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_extract_session_cookie() {
|
||
let mut headers = hyper::HeaderMap::new();
|
||
headers.insert("cookie", "session=abc123; other=xyz".parse().unwrap());
|
||
|
||
assert_eq!(extract_session_cookie(&headers), Some("abc123".to_string()));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_extract_session_cookie_missing() {
|
||
let headers = hyper::HeaderMap::new();
|
||
assert_eq!(extract_session_cookie(&headers), None);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_session_activity_updates_on_validate() {
|
||
let store = SessionStore::new_for_tests(
|
||
std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())),
|
||
);
|
||
let token = store.create().await;
|
||
|
||
// First validation should succeed and touch last_activity
|
||
assert!(store.validate(&token).await);
|
||
|
||
// Still valid after another validation
|
||
assert!(store.validate(&token).await);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_invalidate_all_except() {
|
||
let store = SessionStore::new_for_tests(
|
||
std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())),
|
||
);
|
||
let token1 = store.create().await;
|
||
let token2 = store.create().await;
|
||
let token3 = store.create().await;
|
||
|
||
// Invalidate all except token2
|
||
store.invalidate_all_except(&token2).await;
|
||
|
||
assert!(!store.validate(&token1).await);
|
||
assert!(store.validate(&token2).await);
|
||
assert!(!store.validate(&token3).await);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_session_rotate() {
|
||
let store = SessionStore::new_for_tests(
|
||
std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())),
|
||
);
|
||
let old_token = store.create().await;
|
||
|
||
assert!(store.validate(&old_token).await);
|
||
|
||
let new_token = store.rotate(&old_token).await;
|
||
|
||
// Old token should be invalid
|
||
assert!(!store.validate(&old_token).await);
|
||
// New token should be valid
|
||
assert!(store.validate(&new_token).await);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_max_concurrent_sessions() {
|
||
let store = SessionStore::new_for_tests(
|
||
std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())),
|
||
);
|
||
let mut tokens = Vec::new();
|
||
|
||
// Create MAX_CONCURRENT_SESSIONS sessions
|
||
for _ in 0..MAX_CONCURRENT_SESSIONS {
|
||
tokens.push(store.create().await);
|
||
}
|
||
|
||
// All should be valid
|
||
for token in &tokens {
|
||
assert!(store.validate(token).await);
|
||
}
|
||
|
||
// Creating one more should evict the oldest
|
||
let new_token = store.create().await;
|
||
assert!(store.validate(&new_token).await);
|
||
|
||
// The first token (oldest) should have been evicted
|
||
assert!(!store.validate(&tokens[0]).await);
|
||
|
||
// The rest should still be valid
|
||
for token in &tokens[1..] {
|
||
assert!(store.validate(token).await);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_active_session_count() {
|
||
let store = SessionStore::new_for_tests(
|
||
std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())),
|
||
);
|
||
assert_eq!(store.active_session_count().await, 0);
|
||
|
||
let token1 = store.create().await;
|
||
assert_eq!(store.active_session_count().await, 1);
|
||
|
||
let _token2 = store.create().await;
|
||
assert_eq!(store.active_session_count().await, 2);
|
||
|
||
store.remove(&token1).await;
|
||
assert_eq!(store.active_session_count().await, 1);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_cleanup_expired_removes_stale() {
|
||
let store = SessionStore::new_for_tests(
|
||
std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())),
|
||
);
|
||
let token = store.create().await;
|
||
|
||
assert!(store.validate(&token).await);
|
||
assert_eq!(store.active_session_count().await, 1);
|
||
|
||
// Cleanup shouldn't remove active sessions
|
||
store.cleanup_expired().await;
|
||
assert_eq!(store.active_session_count().await, 1);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_rotate_preserves_session_count() {
|
||
let store = SessionStore::new_for_tests(
|
||
std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", uniq())),
|
||
);
|
||
let token = store.create().await;
|
||
assert_eq!(store.active_session_count().await, 1);
|
||
|
||
let new_token = store.rotate(&token).await;
|
||
assert_eq!(store.active_session_count().await, 1);
|
||
assert!(store.validate(&new_token).await);
|
||
}
|
||
}
|