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

@ -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