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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue