diff --git a/plugins/webui/webui.c b/plugins/webui/webui.c index 2f4a2fd..b3af7e0 100644 --- a/plugins/webui/webui.c +++ b/plugins/webui/webui.c @@ -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; diff --git a/plugins/webui/webui_store.c b/plugins/webui/webui_store.c index 2e42cbd..9b69089 100644 --- a/plugins/webui/webui_store.c +++ b/plugins/webui/webui_store.c @@ -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 diff --git a/plugins/webui/webui_store.h b/plugins/webui/webui_store.h index 4207823..7391b6f 100644 --- a/plugins/webui/webui_store.h +++ b/plugins/webui/webui_store.h @@ -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. */