webui: persist login sessions so they survive restarts

Sessions lived in an in-memory array, so every daemon restart wiped them
and forced a re-login. Move them into the webui DB:

- New sessions table storing a SHA-256 of the bearer token (never the
  raw token, so a DB read can't be replayed), the user, role, and an
  absolute expiry.
- create/lookup/touch/delete + per-user delete + prune in webui_store.
- Login persists the session; auth checks validate against the DB with a
  throttled sliding expiry (re-extended at most hourly to avoid a write
  per request); logout and admin reset/delete drop the rows. Expired
  rows are reaped lazily on lookup and pruned at startup.
- TTL is configurable via NAUT_SESSION_TTL (default 7 days) and drives
  the cookie Max-Age. Removes the in-memory session array + auth_lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-24 21:00:36 -04:00
parent 096535292d
commit 067b62c23a
3 changed files with 190 additions and 70 deletions

View file

@ -29,20 +29,11 @@
#define DEFAULT_PORT 8080
#define READ_LIMIT (8u << 20)
#define SESSION_COOKIE "naut_session"
#define SESSION_TTL_SECONDS (60 * 60 * 24 * 7)
#define MAX_SESSIONS 64
#define SESSION_TTL_SECONDS (60 * 60 * 24 * 7) /* default; NAUT_SESSION_TTL */
#define MAX_CONNECTIONS 128
#define SPEED_SLOTS 256
#define ETA_INFINITY 8640000 /* torrent-ui renders >= this as the infinity glyph */
typedef struct {
char token[96];
char user[64];
char role[16];
time_t expires;
bool used;
} webui_session;
/* Single-writer (sampler thread) running estimate of a torrent's download
* rate, derived from successive byte counts. */
typedef struct {
@ -70,8 +61,6 @@ typedef struct {
bool sampler_started;
pthread_t sampler;
pthread_mutex_t auth_lock;
pthread_mutex_t conn_lock;
pthread_cond_t conn_cond;
size_t active_connections;
@ -103,8 +92,6 @@ typedef struct {
bool rss_thread_started;
pthread_cond_t rss_cond; /* wakes the poller for an immediate refresh */
bool rss_wake; /* set with rss_cond to force an early re-poll */
webui_session sessions[MAX_SESSIONS];
} webui_state;
typedef struct {
@ -381,52 +368,52 @@ static bool cookie_token(const char *headers, const char *end,
/* Look up the session for this request. On a live session, refreshes its TTL
* and (optionally) copies the account's username and role. Returns true if a
* valid session was found. */
/* Session lifetime in seconds (sliding). Override with NAUT_SESSION_TTL. */
static long session_ttl(void) {
const char *env = getenv("NAUT_SESSION_TTL");
if (env && *env) {
char *e = NULL;
long v = strtol(env, &e, 10);
if (e && e != env && !*e && v > 0) return v;
}
return SESSION_TTL_SECONDS;
}
/* Don't rewrite the session row on every request; only re-extend the sliding
* expiry once it has advanced by more than this. */
#define SESSION_REFRESH_THRESHOLD 3600
static bool current_identity(const char *headers, const char *end,
char *user, size_t user_sz,
char *role, size_t role_sz) {
char token[96];
if (!cookie_token(headers, end, token, sizeof token)) return false;
bool ok = false;
time_t now = time(NULL);
pthread_mutex_lock(&g_webui.auth_lock);
for (size_t i = 0; i < MAX_SESSIONS; i++) {
webui_session *session = &g_webui.sessions[i];
if (!session->used || strcmp(session->token, token) != 0) continue;
if (session->expires < now) {
session->used = false;
break;
}
session->expires = now + SESSION_TTL_SECONDS;
if (user) snprintf(user, user_sz, "%s", session->user);
if (role) snprintf(role, role_sz, "%s", session->role);
ok = true;
break;
if (!g_webui.store) return false;
char u[64] = {0}, r[16] = {0};
long expires = 0;
if (!webui_store_session_lookup(g_webui.store, token, u, sizeof u,
r, sizeof r, &expires))
return false;
long now = (long)time(NULL);
if (expires <= now) { /* expired: clean it up */
webui_store_session_delete(g_webui.store, token);
return false;
}
pthread_mutex_unlock(&g_webui.auth_lock);
return ok;
long fresh = now + session_ttl(); /* sliding window, throttled */
if (fresh - expires > SESSION_REFRESH_THRESHOLD)
webui_store_session_touch(g_webui.store, token, fresh);
if (user) snprintf(user, user_sz, "%s", u);
if (role) snprintf(role, role_sz, "%s", r);
return true;
}
static bool create_session(const char *user, const char *role,
char *out, size_t out_size) {
char token[96];
if (!random_hex(token, sizeof token, 24)) return false;
time_t now = time(NULL);
time_t expires = now + SESSION_TTL_SECONDS;
pthread_mutex_lock(&g_webui.auth_lock);
webui_session *slot = NULL;
for (size_t i = 0; i < MAX_SESSIONS; i++) {
webui_session *s = &g_webui.sessions[i];
if (!s->used || s->expires < now) { slot = s; break; }
/* Otherwise track the session that expires soonest, so a full table
* evicts the oldest rather than always clobbering slot 0. */
if (!slot || s->expires < slot->expires) slot = s;
}
snprintf(slot->token, sizeof slot->token, "%s", token);
snprintf(slot->user, sizeof slot->user, "%s", user ? user : "");
snprintf(slot->role, sizeof slot->role, "%s", role ? role : "user");
slot->expires = expires;
slot->used = true;
pthread_mutex_unlock(&g_webui.auth_lock);
if (!g_webui.store || !random_hex(token, sizeof token, 24)) return false;
long expires = (long)time(NULL) + session_ttl();
if (!webui_store_session_create(g_webui.store, token, user, role, expires))
return false;
snprintf(out, out_size, "%s", token);
return true;
}
@ -434,23 +421,13 @@ static bool create_session(const char *user, const char *role,
/* Invalidate every session belonging to `user` (after delete / password reset
* by an admin). */
static void drop_user_sessions(const char *user) {
pthread_mutex_lock(&g_webui.auth_lock);
for (size_t i = 0; i < MAX_SESSIONS; i++)
if (g_webui.sessions[i].used &&
strcmp(g_webui.sessions[i].user, user) == 0)
g_webui.sessions[i].used = false;
pthread_mutex_unlock(&g_webui.auth_lock);
if (g_webui.store) webui_store_sessions_delete_user(g_webui.store, user);
}
static void clear_session(const char *headers, const char *end) {
char token[96];
if (!cookie_token(headers, end, token, sizeof token)) return;
pthread_mutex_lock(&g_webui.auth_lock);
for (size_t i = 0; i < MAX_SESSIONS; i++)
if (g_webui.sessions[i].used &&
strcmp(g_webui.sessions[i].token, token) == 0)
g_webui.sessions[i].used = false;
pthread_mutex_unlock(&g_webui.auth_lock);
if (g_webui.store && cookie_token(headers, end, token, sizeof token))
webui_store_session_delete(g_webui.store, token);
}
static bool bad_static_path(const char *path) {
@ -2764,8 +2741,8 @@ static void handle_api(int fd, const char *method, char *path,
char cookie[256];
snprintf(cookie, sizeof cookie,
"Set-Cookie: %s=%s; Path=/; HttpOnly; SameSite=Lax; "
"Max-Age=%d\r\n",
SESSION_COOKIE, token, SESSION_TTL_SECONDS);
"Max-Age=%ld\r\n",
SESSION_COOKIE, token, session_ttl());
json_t *json = json_pack("{s:b,s:s,s:s}", "ok", 1,
"user", user, "role", role);
http_json_extra(fd, 200, json, cookie);
@ -3110,10 +3087,8 @@ naut_err naut_plugin_register(const naut_host_api *host) {
memset(&g_webui, 0, sizeof g_webui);
g_webui.listener = -1;
g_webui.host = *host;
if (pthread_mutex_init(&g_webui.auth_lock, NULL) != 0)
return NAUT_ERR_NOMEM;
if (pthread_mutex_init(&g_webui.conn_lock, NULL) != 0)
goto fail_conn_lock;
return NAUT_ERR_NOMEM;
if (pthread_cond_init(&g_webui.conn_cond, NULL) != 0)
goto fail_conn_cond;
if (pthread_mutex_init(&g_webui.snap_lock, NULL) != 0)
@ -3132,6 +3107,8 @@ naut_err naut_plugin_register(const naut_host_api *host) {
pthread_mutex_init(&g_webui.rss_lock, NULL);
pthread_cond_init(&g_webui.rss_cond, NULL);
init_auth();
if (g_webui.store) /* drop sessions that expired while we were down */
webui_store_sessions_prune(g_webui.store, (long)time(NULL));
error = g_webui.host.set_plugin_name(g_webui.host.host_context,
"webui");
if (error != NAUT_OK) goto fail_store;
@ -3158,8 +3135,6 @@ fail_snap_lock:
pthread_cond_destroy(&g_webui.conn_cond);
fail_conn_cond:
pthread_mutex_destroy(&g_webui.conn_lock);
fail_conn_lock:
pthread_mutex_destroy(&g_webui.auth_lock);
return error;
}
@ -3212,7 +3187,6 @@ naut_err naut_plugin_shutdown(void) {
pthread_mutex_destroy(&g_webui.snap_lock);
pthread_cond_destroy(&g_webui.conn_cond);
pthread_mutex_destroy(&g_webui.conn_lock);
pthread_mutex_destroy(&g_webui.auth_lock);
webui_store_close(g_webui.store);
g_webui.store = NULL;
return NAUT_OK;

View file

@ -79,6 +79,13 @@ webui_store *webui_store_open(const char *path) {
" pw_iters INTEGER NOT NULL,"
" role TEXT NOT NULL DEFAULT 'user',"
" created_at INTEGER NOT NULL);"
"CREATE TABLE IF NOT EXISTS sessions ("
" token_hash TEXT PRIMARY KEY,"
" username TEXT NOT NULL,"
" role TEXT NOT NULL DEFAULT 'user',"
" expires INTEGER NOT NULL);"
"CREATE INDEX IF NOT EXISTS sessions_user ON sessions(username);"
"CREATE INDEX IF NOT EXISTS sessions_expires ON sessions(expires);"
"CREATE TABLE IF NOT EXISTS categories ("
" name TEXT PRIMARY KEY,"
" save_path TEXT NOT NULL DEFAULT '');"
@ -323,6 +330,131 @@ bool webui_store_list_users(webui_store *s, json_t *out) {
return ok;
}
/* --- sessions ------------------------------------------------------------- */
/* SHA-256 of a bearer token, hex-encoded. We persist only this, never the raw
* token, so a DB leak can't be replayed as a live cookie. */
static void sha256_hex(const char *token, char out[65]) {
unsigned char d[32];
unsigned int dl = 0;
EVP_Digest(token, strlen(token), d, &dl, EVP_sha256(), NULL);
to_hex(d, 32, out);
}
bool webui_store_session_create(webui_store *s, const char *token,
const char *user, const char *role,
long expires) {
if (!s || !token || !*token || !user || !*user) return false;
char th[65];
sha256_hex(token, th);
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db,
"INSERT OR REPLACE INTO sessions (token_hash,username,role,expires)"
" VALUES (?,?,?,?);", -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, th, -1, SQLITE_STATIC);
sqlite3_bind_text(st, 2, user, -1, SQLITE_STATIC);
sqlite3_bind_text(st, 3, role && *role ? role : "user", -1, SQLITE_STATIC);
sqlite3_bind_int64(st, 4, (sqlite3_int64)expires);
ok = sqlite3_step(st) == SQLITE_DONE;
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
bool webui_store_session_lookup(webui_store *s, const char *token,
char *user, size_t user_sz,
char *role, size_t role_sz, long *expires_out) {
if (!s || !token || !*token) return false;
char th[65];
sha256_hex(token, th);
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db,
"SELECT username, role, expires FROM sessions WHERE token_hash=?;",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, th, -1, SQLITE_STATIC);
if (sqlite3_step(st) == SQLITE_ROW) {
const char *u = (const char *)sqlite3_column_text(st, 0);
const char *r = (const char *)sqlite3_column_text(st, 1);
if (user) snprintf(user, user_sz, "%s", u ? u : "");
if (role) snprintf(role, role_sz, "%s", r ? r : "user");
if (expires_out) *expires_out = (long)sqlite3_column_int64(st, 2);
ok = true;
}
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
bool webui_store_session_touch(webui_store *s, const char *token, long expires) {
if (!s || !token) return false;
char th[65];
sha256_hex(token, th);
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db,
"UPDATE sessions SET expires=? WHERE token_hash=?;",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_int64(st, 1, (sqlite3_int64)expires);
sqlite3_bind_text(st, 2, th, -1, SQLITE_STATIC);
ok = sqlite3_step(st) == SQLITE_DONE;
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
bool webui_store_session_delete(webui_store *s, const char *token) {
if (!s || !token) return false;
char th[65];
sha256_hex(token, th);
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db, "DELETE FROM sessions WHERE token_hash=?;",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, th, -1, SQLITE_STATIC);
ok = sqlite3_step(st) == SQLITE_DONE;
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
bool webui_store_sessions_delete_user(webui_store *s, const char *user) {
if (!s || !user) return false;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
bool ok = false;
if (sqlite3_prepare_v2(s->db, "DELETE FROM sessions WHERE username=?;",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, user, -1, SQLITE_STATIC);
ok = sqlite3_step(st) == SQLITE_DONE;
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
return ok;
}
void webui_store_sessions_prune(webui_store *s, long now) {
if (!s) return;
pthread_mutex_lock(&s->lock);
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(s->db, "DELETE FROM sessions WHERE expires<=?;",
-1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_int64(st, 1, (sqlite3_int64)now);
sqlite3_step(st);
}
sqlite3_finalize(st);
pthread_mutex_unlock(&s->lock);
}
/* --- taxonomy ------------------------------------------------------------- */
/* Replace one table's contents from a json array, inside a transaction. The

View file

@ -44,6 +44,20 @@ bool webui_store_delete_user(webui_store *s, const char *username);
* json array `out`. Returns false on error. */
bool webui_store_list_users(webui_store *s, json_t *out);
/* --- sessions (persisted so logins survive daemon restarts) --------------- *
* Only a SHA-256 of the bearer token is stored, so a DB read can't be replayed
* as a live cookie. `expires` is an absolute unix time. */
bool webui_store_session_create(webui_store *s, const char *token,
const char *user, const char *role, long expires);
/* On a live (unexpired) session, copies username/role and the stored expiry. */
bool webui_store_session_lookup(webui_store *s, const char *token,
char *user, size_t user_sz,
char *role, size_t role_sz, long *expires_out);
bool webui_store_session_touch(webui_store *s, const char *token, long expires);
bool webui_store_session_delete(webui_store *s, const char *token);
bool webui_store_sessions_delete_user(webui_store *s, const char *user);
void webui_store_sessions_prune(webui_store *s, long now);
/* --- category / tag taxonomy (web-UI organization, owned here) ------------- *
* The save_* calls replace the whole list atomically; the load_* calls append
* to the (array) `out`. Categories are {name, savePath}; tags are strings. */