web: persist login sessions in SQLite so a restart doesn't log everyone out
Sessions were an in-memory HashMap seeded empty at startup, so every restart or upgrade invalidated every browser's cookie (the cause of the recent "settings save → error" report: a restart out from under a logged-in tab). Mirror sessions to a new `sessions(token, issued_at)` table: - store issued-at as a UNIX timestamp (the map switches from Instant→u64 so it can round-trip the DB; is_authed prunes by wall-clock age vs SESSION_TTL), - insert on login, delete on logout, clear on password change, - rehydrate the map at startup via load_sessions(), which also prunes rows past the TTL. DB writes are best-effort — a failure just means that token won't survive a restart, not that login breaks. Verified end-to-end: set a password, log in, restart the server against the same DB, and the original cookie still authenticates (200) while a cookieless request is still 401. 128 unit + 11 integration tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dd48e899fa
commit
816da05962
2 changed files with 71 additions and 8 deletions
|
|
@ -198,6 +198,10 @@ impl Database {
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
issued_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS channel_options (
|
CREATE TABLE IF NOT EXISTS channel_options (
|
||||||
platform TEXT NOT NULL,
|
platform TEXT NOT NULL,
|
||||||
handle TEXT NOT NULL,
|
handle TEXT NOT NULL,
|
||||||
|
|
@ -624,6 +628,46 @@ impl Database {
|
||||||
Ok(rows.next()?.map(|r| r.get(0)).transpose()?)
|
Ok(rows.next()?.map(|r| r.get(0)).transpose()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Web sessions ────────────────────────────────────────────────────────
|
||||||
|
// Login tokens are mirrored here so they survive a restart/upgrade instead
|
||||||
|
// of logging everyone out. `issued_at` is a UNIX timestamp (seconds); the
|
||||||
|
// in-memory map in web.rs stays the runtime source of truth and this is its
|
||||||
|
// durable backing store. See `web::auth_middleware` / `post_login`.
|
||||||
|
|
||||||
|
/// Persist (or refresh) a session token with its issued-at time.
|
||||||
|
pub fn insert_session(&self, token: &str, issued_at: u64) -> Result<()> {
|
||||||
|
self.conn().execute(
|
||||||
|
"INSERT OR REPLACE INTO sessions (token, issued_at) VALUES (?1, ?2)",
|
||||||
|
rusqlite::params![token, issued_at as i64],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a single session (logout).
|
||||||
|
pub fn delete_session(&self, token: &str) -> Result<()> {
|
||||||
|
self.conn().execute("DELETE FROM sessions WHERE token = ?1", [token])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every session (password change / disable).
|
||||||
|
pub fn clear_sessions(&self) -> Result<()> {
|
||||||
|
self.conn().execute("DELETE FROM sessions", [])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prune sessions issued before `min_issued` (i.e. older than the TTL) and
|
||||||
|
/// return the survivors as `(token, issued_at)`. Called once at startup to
|
||||||
|
/// rehydrate the in-memory session map.
|
||||||
|
pub fn load_sessions(&self, min_issued: u64) -> Result<Vec<(String, u64)>> {
|
||||||
|
let conn = self.conn();
|
||||||
|
conn.execute("DELETE FROM sessions WHERE issued_at < ?1", [min_issued as i64])?;
|
||||||
|
let mut stmt = conn.prepare("SELECT token, issued_at FROM sessions")?;
|
||||||
|
let rows = stmt.query_map([], |r| {
|
||||||
|
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? as u64))
|
||||||
|
})?;
|
||||||
|
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_setting(&self, key: &str, value: Option<&str>) -> Result<()> {
|
pub fn set_setting(&self, key: &str, value: Option<&str>) -> Result<()> {
|
||||||
let conn = self.conn();
|
let conn = self.conn();
|
||||||
match value {
|
match value {
|
||||||
|
|
|
||||||
35
src/web.rs
35
src/web.rs
|
|
@ -107,9 +107,11 @@ pub struct WebState {
|
||||||
pub config: Mutex<Config>,
|
pub config: Mutex<Config>,
|
||||||
/// Whether to transcode MKV→mp4 on the fly for playback (requires ffmpeg).
|
/// Whether to transcode MKV→mp4 on the fly for playback (requires ffmpeg).
|
||||||
pub transcode: AtomicBool,
|
pub transcode: AtomicBool,
|
||||||
/// Active session tokens mapped to their issued-at `Instant`. Tokens older
|
/// Active session tokens mapped to their issued-at UNIX time (seconds).
|
||||||
/// than [`SESSION_TTL`] are rejected and pruned lazily on each touch.
|
/// Tokens older than [`SESSION_TTL`] are rejected and pruned lazily on each
|
||||||
pub sessions: Mutex<HashMap<String, std::time::Instant>>,
|
/// touch. Mirrored to the `sessions` DB table so logins survive a restart
|
||||||
|
/// (the map is the runtime source of truth; the DB is its durable backing).
|
||||||
|
pub sessions: Mutex<HashMap<String, u64>>,
|
||||||
/// When the last scheduled channel check ran; used to compute the next due time.
|
/// When the last scheduled channel check ran; used to compute the next due time.
|
||||||
pub last_scheduled_check: Mutex<Option<std::time::Instant>>,
|
pub last_scheduled_check: Mutex<Option<std::time::Instant>>,
|
||||||
/// Cached "is password required" — refreshed when the password is changed.
|
/// Cached "is password required" — refreshed when the password is changed.
|
||||||
|
|
@ -1010,9 +1012,11 @@ fn session_token_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||||
fn is_authed(state: &WebState, headers: &HeaderMap) -> bool {
|
fn is_authed(state: &WebState, headers: &HeaderMap) -> bool {
|
||||||
let Some(token) = session_token_from_headers(headers) else { return false };
|
let Some(token) = session_token_from_headers(headers) else { return false };
|
||||||
let mut sessions = state.sessions.lock_recover();
|
let mut sessions = state.sessions.lock_recover();
|
||||||
let now = std::time::Instant::now();
|
let now = crate::stats::now_unix();
|
||||||
// Lazy prune: drop anything older than the TTL.
|
let ttl = SESSION_TTL.as_secs();
|
||||||
sessions.retain(|_, issued| now.duration_since(*issued) < SESSION_TTL);
|
// Lazy prune: drop anything older than the TTL (in-memory only; the DB rows
|
||||||
|
// are pruned at startup in load_sessions and on the next login).
|
||||||
|
sessions.retain(|_, issued| now.saturating_sub(*issued) < ttl);
|
||||||
sessions.contains_key(&token)
|
sessions.contains_key(&token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1115,7 +1119,13 @@ async fn post_login(
|
||||||
state.login_attempts.lock_recover().remove(&ip);
|
state.login_attempts.lock_recover().remove(&ip);
|
||||||
|
|
||||||
let token = generate_session_token();
|
let token = generate_session_token();
|
||||||
state.sessions.lock_recover().insert(token.clone(), now);
|
let issued = crate::stats::now_unix();
|
||||||
|
state.sessions.lock_recover().insert(token.clone(), issued);
|
||||||
|
// Mirror to the DB so the session survives a restart. Best-effort: a failed
|
||||||
|
// write only means this token won't outlive a restart, not that login fails.
|
||||||
|
if let Err(e) = state.db.insert_session(&token, issued) {
|
||||||
|
eprintln!("warning: could not persist session: {e}");
|
||||||
|
}
|
||||||
let cookie = session_cookie(&token, &headers, SESSION_TTL.as_secs());
|
let cookie = session_cookie(&token, &headers, SESSION_TTL.as_secs());
|
||||||
([(header::SET_COOKIE, cookie)], StatusCode::OK).into_response()
|
([(header::SET_COOKIE, cookie)], StatusCode::OK).into_response()
|
||||||
}
|
}
|
||||||
|
|
@ -1127,6 +1137,7 @@ async fn post_logout(
|
||||||
) -> Response {
|
) -> Response {
|
||||||
if let Some(token) = session_token_from_headers(&headers) {
|
if let Some(token) = session_token_from_headers(&headers) {
|
||||||
state.sessions.lock_recover().remove(&token);
|
state.sessions.lock_recover().remove(&token);
|
||||||
|
let _ = state.db.delete_session(&token);
|
||||||
}
|
}
|
||||||
let cookie = session_cookie("", &headers, 0);
|
let cookie = session_cookie("", &headers, 0);
|
||||||
([(header::SET_COOKIE, cookie)], StatusCode::OK).into_response()
|
([(header::SET_COOKIE, cookie)], StatusCode::OK).into_response()
|
||||||
|
|
@ -1689,6 +1700,7 @@ async fn post_settings(
|
||||||
}
|
}
|
||||||
// Password changed: drop all existing sessions so they must re-authenticate.
|
// Password changed: drop all existing sessions so they must re-authenticate.
|
||||||
state.sessions.lock_recover().clear();
|
state.sessions.lock_recover().clear();
|
||||||
|
let _ = state.db.clear_sessions();
|
||||||
refresh_password_cache(&state);
|
refresh_password_cache(&state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3160,6 +3172,13 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
||||||
let _ = db.set_setting("feed_token", Some(&t));
|
let _ = db.set_setting("feed_token", Some(&t));
|
||||||
t
|
t
|
||||||
});
|
});
|
||||||
|
// Rehydrate persisted login sessions (pruning any past the TTL) so a server
|
||||||
|
// restart or upgrade doesn't log everyone out.
|
||||||
|
let sessions_initial: HashMap<String, u64> = db
|
||||||
|
.load_sessions(crate::stats::now_unix().saturating_sub(SESSION_TTL.as_secs()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
// Capacity 16 is plenty — only a small number of browser tabs ever
|
// Capacity 16 is plenty — only a small number of browser tabs ever
|
||||||
// subscribe and the broadcast is lossy by design (subscribers that lag
|
// subscribe and the broadcast is lossy by design (subscribers that lag
|
||||||
// get a Lagged error and just resubscribe).
|
// get a Lagged error and just resubscribe).
|
||||||
|
|
@ -3182,7 +3201,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
||||||
config_path,
|
config_path,
|
||||||
config: Mutex::new(config),
|
config: Mutex::new(config),
|
||||||
transcode,
|
transcode,
|
||||||
sessions: Mutex::new(HashMap::new()),
|
sessions: Mutex::new(sessions_initial),
|
||||||
last_scheduled_check: Mutex::new(None),
|
last_scheduled_check: Mutex::new(None),
|
||||||
password_required_cache: AtomicBool::new(password_required_initial),
|
password_required_cache: AtomicBool::new(password_required_initial),
|
||||||
library_version: AtomicU64::new(1),
|
library_version: AtomicU64::new(1),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue