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,
|
||||
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 (
|
||||
platform TEXT NOT NULL,
|
||||
handle TEXT NOT NULL,
|
||||
|
|
@ -624,6 +628,46 @@ impl Database {
|
|||
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<()> {
|
||||
let conn = self.conn();
|
||||
match value {
|
||||
|
|
|
|||
35
src/web.rs
35
src/web.rs
|
|
@ -107,9 +107,11 @@ pub struct WebState {
|
|||
pub config: Mutex<Config>,
|
||||
/// Whether to transcode MKV→mp4 on the fly for playback (requires ffmpeg).
|
||||
pub transcode: AtomicBool,
|
||||
/// Active session tokens mapped to their issued-at `Instant`. Tokens older
|
||||
/// than [`SESSION_TTL`] are rejected and pruned lazily on each touch.
|
||||
pub sessions: Mutex<HashMap<String, std::time::Instant>>,
|
||||
/// Active session tokens mapped to their issued-at UNIX time (seconds).
|
||||
/// Tokens older than [`SESSION_TTL`] are rejected and pruned lazily on each
|
||||
/// 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.
|
||||
pub last_scheduled_check: Mutex<Option<std::time::Instant>>,
|
||||
/// 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 {
|
||||
let Some(token) = session_token_from_headers(headers) else { return false };
|
||||
let mut sessions = state.sessions.lock_recover();
|
||||
let now = std::time::Instant::now();
|
||||
// Lazy prune: drop anything older than the TTL.
|
||||
sessions.retain(|_, issued| now.duration_since(*issued) < SESSION_TTL);
|
||||
let now = crate::stats::now_unix();
|
||||
let ttl = SESSION_TTL.as_secs();
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
|
@ -1115,7 +1119,13 @@ async fn post_login(
|
|||
state.login_attempts.lock_recover().remove(&ip);
|
||||
|
||||
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());
|
||||
([(header::SET_COOKIE, cookie)], StatusCode::OK).into_response()
|
||||
}
|
||||
|
|
@ -1127,6 +1137,7 @@ async fn post_logout(
|
|||
) -> Response {
|
||||
if let Some(token) = session_token_from_headers(&headers) {
|
||||
state.sessions.lock_recover().remove(&token);
|
||||
let _ = state.db.delete_session(&token);
|
||||
}
|
||||
let cookie = session_cookie("", &headers, 0);
|
||||
([(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.
|
||||
state.sessions.lock_recover().clear();
|
||||
let _ = state.db.clear_sessions();
|
||||
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));
|
||||
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
|
||||
// subscribe and the broadcast is lossy by design (subscribers that lag
|
||||
// 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: Mutex::new(config),
|
||||
transcode,
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
sessions: Mutex::new(sessions_initial),
|
||||
last_scheduled_check: Mutex::new(None),
|
||||
password_required_cache: AtomicBool::new(password_required_initial),
|
||||
library_version: AtomicU64::new(1),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue