From a4ec585aedeba45486d3bea65a560a651f131677 Mon Sep 17 00:00:00 2001 From: ookami125 Date: Tue, 23 Jun 2026 21:43:24 -0400 Subject: [PATCH] webui: real multi-user accounts (SQLite + PBKDF2) Replace the single env/generated password with a proper account system, owned entirely by the webui plugin: - auth_store: SQLite users table, PBKDF2-HMAC-SHA256 password hashing (per-user salt, 210k iterations) via OpenSSL. DB at NAUT_WEBUI_DB or an XDG default. Thread-safe (serialized connection). - Login verifies against the DB; sessions now carry the username + role. First run bootstraps an admin from NAUT_AUTH_USER/PASSWORD or a generated password (logged once). - Admin-only user management: GET/POST /api/users, /api/users/delete, /api/users/password, /api/users/role. Self-service POST /api/account/password. Guards the last admin and invalidates a user's sessions on delete or password reset. - /api/auth/status and /api/login now return the role. Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 9 +- plugins/webui/auth_store.c | 274 +++++++++++++++++++++++++++++++ plugins/webui/auth_store.h | 46 ++++++ plugins/webui/webui.c | 326 +++++++++++++++++++++++++++++++------ 4 files changed, 604 insertions(+), 51 deletions(-) create mode 100644 plugins/webui/auth_store.c create mode 100644 plugins/webui/auth_store.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 86abe96..c31cf1e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -220,9 +220,14 @@ add_library(naut_example MODULE plugins/example/example.c) target_include_directories(naut_example PRIVATE ${CMAKE_SOURCE_DIR}/include) set_target_properties(naut_example PROPERTIES PREFIX "") -add_library(naut_webui MODULE plugins/webui/webui.c) +# SQLite backs the webui account store. +find_package(PkgConfig REQUIRED) +pkg_check_modules(SQLITE3 REQUIRED IMPORTED_TARGET sqlite3) + +add_library(naut_webui MODULE plugins/webui/webui.c plugins/webui/auth_store.c) target_include_directories(naut_webui PRIVATE ${CMAKE_SOURCE_DIR}/include) -target_link_libraries(naut_webui PRIVATE ${NAUT_JANSSON_TARGET} naut_net pthread) +target_link_libraries(naut_webui PRIVATE ${NAUT_JANSSON_TARGET} naut_net + PkgConfig::SQLITE3 OpenSSL::Crypto pthread) set_target_properties(naut_webui PROPERTIES PREFIX "") # --- swarm: multi-peer download driver over the torrent-peer engine --------- diff --git a/plugins/webui/auth_store.c b/plugins/webui/auth_store.c new file mode 100644 index 0000000..bd9ac9a --- /dev/null +++ b/plugins/webui/auth_store.c @@ -0,0 +1,274 @@ +/* auth_store.c — SQLite + PBKDF2 implementation of the web-UI account store. */ +#include "auth_store.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#define PBKDF2_ITERS 210000 +#define SALT_BYTES 16 +#define HASH_BYTES 32 + +struct auth_store { + sqlite3 *db; + pthread_mutex_t lock; +}; + +static void to_hex(const unsigned char *in, size_t n, char *out) { + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < n; i++) { + out[i * 2] = hex[in[i] >> 4]; + out[i * 2 + 1] = hex[in[i] & 0xf]; + } + out[n * 2] = 0; +} + +static int from_hex(const char *in, unsigned char *out, size_t out_n) { + size_t len = strlen(in); + if (len != out_n * 2) return -1; + for (size_t i = 0; i < out_n; i++) { + char c[3] = { in[i * 2], in[i * 2 + 1], 0 }; + char *end; + long v = strtol(c, &end, 16); + if (end != c + 2) return -1; + out[i] = (unsigned char)v; + } + return 0; +} + +/* Derive a hash for `password` with the given salt + iteration count. */ +static bool derive(const char *password, const unsigned char *salt, + size_t salt_n, int iters, unsigned char out[HASH_BYTES]) { + return PKCS5_PBKDF2_HMAC(password, (int)strlen(password), salt, (int)salt_n, + iters, EVP_sha256(), HASH_BYTES, out) == 1; +} + +static bool valid_role(const char *role) { + return role && (strcmp(role, "admin") == 0 || strcmp(role, "user") == 0); +} + +auth_store *auth_store_open(const char *path) { + auth_store *s = calloc(1, sizeof *s); + if (!s) return NULL; + if (pthread_mutex_init(&s->lock, NULL) != 0) { free(s); return NULL; } + if (sqlite3_open(path, &s->db) != SQLITE_OK) { + sqlite3_close(s->db); + pthread_mutex_destroy(&s->lock); + free(s); + return NULL; + } + sqlite3_busy_timeout(s->db, 4000); + const char *schema = + "PRAGMA journal_mode=WAL;" + "CREATE TABLE IF NOT EXISTS users (" + " id INTEGER PRIMARY KEY," + " username TEXT NOT NULL UNIQUE COLLATE NOCASE," + " pw_hash TEXT NOT NULL," + " pw_salt TEXT NOT NULL," + " pw_iters INTEGER NOT NULL," + " role TEXT NOT NULL DEFAULT 'user'," + " created_at INTEGER NOT NULL);"; + char *err = NULL; + if (sqlite3_exec(s->db, schema, NULL, NULL, &err) != SQLITE_OK) { + sqlite3_free(err); + auth_store_close(s); + return NULL; + } + return s; +} + +void auth_store_close(auth_store *s) { + if (!s) return; + if (s->db) sqlite3_close(s->db); + pthread_mutex_destroy(&s->lock); + free(s); +} + +/* Run a "SELECT count(*) ... " style query returning a single integer. */ +static int count_query(auth_store *s, const char *sql) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(s->db, sql, -1, &st, NULL) != SQLITE_OK) return -1; + int n = -1; + if (sqlite3_step(st) == SQLITE_ROW) n = sqlite3_column_int(st, 0); + sqlite3_finalize(st); + return n; +} + +int auth_store_user_count(auth_store *s) { + if (!s) return -1; + pthread_mutex_lock(&s->lock); + int n = count_query(s, "SELECT count(*) FROM users;"); + pthread_mutex_unlock(&s->lock); + return n; +} + +int auth_store_admin_count(auth_store *s) { + if (!s) return -1; + pthread_mutex_lock(&s->lock); + int n = count_query(s, "SELECT count(*) FROM users WHERE role='admin';"); + pthread_mutex_unlock(&s->lock); + return n; +} + +bool auth_store_user_exists(auth_store *s, const char *username) { + if (!s || !username) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool found = false; + if (sqlite3_prepare_v2(s->db, "SELECT 1 FROM users WHERE username=?;", -1, + &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, username, -1, SQLITE_STATIC); + found = sqlite3_step(st) == SQLITE_ROW; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return found; +} + +bool auth_store_verify(auth_store *s, const char *username, + const char *password, char *role_out, size_t role_sz) { + if (!s || !username || !password) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "SELECT pw_hash, pw_salt, pw_iters, role FROM users WHERE username=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, username, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) { + const char *hash_hex = (const char *)sqlite3_column_text(st, 0); + const char *salt_hex = (const char *)sqlite3_column_text(st, 1); + int iters = sqlite3_column_int(st, 2); + const char *role = (const char *)sqlite3_column_text(st, 3); + unsigned char salt[SALT_BYTES], want[HASH_BYTES], got[HASH_BYTES]; + if (hash_hex && salt_hex && + from_hex(salt_hex, salt, SALT_BYTES) == 0 && + from_hex(hash_hex, want, HASH_BYTES) == 0 && + derive(password, salt, SALT_BYTES, iters, got) && + CRYPTO_memcmp(want, got, HASH_BYTES) == 0) { + ok = true; + if (role_out && role) snprintf(role_out, role_sz, "%s", role); + } + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +/* Compute a fresh salt + hash for `password`, hex-encoded into the buffers. */ +static bool make_hash(const char *password, char salt_hex[SALT_BYTES * 2 + 1], + char hash_hex[HASH_BYTES * 2 + 1]) { + unsigned char salt[SALT_BYTES], hash[HASH_BYTES]; + if (RAND_bytes(salt, SALT_BYTES) != 1) return false; + if (!derive(password, salt, SALT_BYTES, PBKDF2_ITERS, hash)) return false; + to_hex(salt, SALT_BYTES, salt_hex); + to_hex(hash, HASH_BYTES, hash_hex); + return true; +} + +bool auth_store_create_user(auth_store *s, const char *username, + const char *password, const char *role) { + if (!s || !username || !*username || !password || !*password) return false; + if (!valid_role(role)) role = "user"; + char salt_hex[SALT_BYTES * 2 + 1], hash_hex[HASH_BYTES * 2 + 1]; + if (!make_hash(password, salt_hex, hash_hex)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "INSERT INTO users (username, pw_hash, pw_salt, pw_iters, role, created_at)" + " VALUES (?,?,?,?,?,?);", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, username, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, hash_hex, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 3, salt_hex, -1, SQLITE_STATIC); + sqlite3_bind_int(st, 4, PBKDF2_ITERS); + sqlite3_bind_text(st, 5, role, -1, SQLITE_STATIC); + sqlite3_bind_int64(st, 6, (sqlite3_int64)time(NULL)); + ok = sqlite3_step(st) == SQLITE_DONE; /* false on UNIQUE conflict */ + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool auth_store_set_password(auth_store *s, const char *username, + const char *password) { + if (!s || !username || !password || !*password) return false; + char salt_hex[SALT_BYTES * 2 + 1], hash_hex[HASH_BYTES * 2 + 1]; + if (!make_hash(password, salt_hex, hash_hex)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "UPDATE users SET pw_hash=?, pw_salt=?, pw_iters=? WHERE username=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, hash_hex, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, salt_hex, -1, SQLITE_STATIC); + sqlite3_bind_int(st, 3, PBKDF2_ITERS); + sqlite3_bind_text(st, 4, username, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool auth_store_set_role(auth_store *s, const char *username, const char *role) { + if (!s || !username || !valid_role(role)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, "UPDATE users SET role=? WHERE username=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, role, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 2, username, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool auth_store_delete_user(auth_store *s, const char *username) { + if (!s || !username) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, "DELETE FROM users WHERE username=?;", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, username, -1, SQLITE_STATIC); + ok = sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(s->db) > 0; + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} + +bool auth_store_list_users(auth_store *s, json_t *out) { + if (!s || !json_is_array(out)) return false; + pthread_mutex_lock(&s->lock); + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_prepare_v2(s->db, + "SELECT username, role, created_at FROM users ORDER BY username COLLATE NOCASE;", + -1, &st, NULL) == SQLITE_OK) { + ok = true; + while (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); + json_array_append_new(out, json_pack("{s:s,s:s,s:I}", + "username", u ? u : "", "role", r ? r : "user", + "createdAt", (json_int_t)sqlite3_column_int64(st, 2))); + } + } + sqlite3_finalize(st); + pthread_mutex_unlock(&s->lock); + return ok; +} diff --git a/plugins/webui/auth_store.h b/plugins/webui/auth_store.h new file mode 100644 index 0000000..c3097fe --- /dev/null +++ b/plugins/webui/auth_store.h @@ -0,0 +1,46 @@ +/* auth_store.h — SQLite-backed user account store for the web UI. + * + * Owned entirely by the webui plugin. Passwords are stored as PBKDF2-HMAC- + * SHA256 hashes with a per-user random salt. All calls are thread-safe (the + * store serializes access to its single SQLite connection internally). */ +#ifndef NAUT_WEBUI_AUTH_STORE_H +#define NAUT_WEBUI_AUTH_STORE_H + +#include +#include +#include + +typedef struct auth_store auth_store; + +/* Open (creating if needed) the account database at `path`. Returns NULL on + * failure. The schema is created/migrated on open. */ +auth_store *auth_store_open(const char *path); +void auth_store_close(auth_store *s); + +/* Number of accounts, or -1 on error. */ +int auth_store_user_count(auth_store *s); +/* Number of admin accounts, or -1 on error. */ +int auth_store_admin_count(auth_store *s); +bool auth_store_user_exists(auth_store *s, const char *username); + +/* Verify a username/password pair (constant-time). On success, copies the + * account's role ("admin"/"user") into role_out. */ +bool auth_store_verify(auth_store *s, const char *username, + const char *password, char *role_out, size_t role_sz); + +/* Create an account. `role` must be "admin" or "user" (defaults to "user" if + * NULL/invalid). Returns false if the username already exists or on error. */ +bool auth_store_create_user(auth_store *s, const char *username, + const char *password, const char *role); + +bool auth_store_set_password(auth_store *s, const char *username, + const char *password); +/* Change an account's role ("admin"/"user"). */ +bool auth_store_set_role(auth_store *s, const char *username, const char *role); +bool auth_store_delete_user(auth_store *s, const char *username); + +/* Append {username, role, createdAt} objects (sorted by username) to the + * json array `out`. Returns false on error. */ +bool auth_store_list_users(auth_store *s, json_t *out); + +#endif /* NAUT_WEBUI_AUTH_STORE_H */ diff --git a/plugins/webui/webui.c b/plugins/webui/webui.c index 96fc337..bebc989 100644 --- a/plugins/webui/webui.c +++ b/plugins/webui/webui.c @@ -1,5 +1,6 @@ #include "naut/naut_plugin.h" #include "naut/http_client.h" +#include "auth_store.h" #include @@ -36,6 +37,8 @@ typedef struct { char token[96]; + char user[64]; + char role[16]; time_t expires; bool used; } webui_session; @@ -54,8 +57,9 @@ typedef struct { naut_host_api host; char root[PATH_MAX]; char host_name[64]; - char auth_user[64]; - char auth_password[64]; + char auth_user[64]; /* bootstrap admin name (for startup banner) */ + char auth_password[64]; /* generated bootstrap password (banner only) */ + auth_store *auth; /* SQLite-backed account store */ int port; int listener; bool generated_password; @@ -240,21 +244,6 @@ static bool query_get(const char *query, const char *key, char *out, size_t outs return false; } -/* Constant-time equality so credential checks don't leak length/content via - * timing. Returns true when both NUL-terminated strings match exactly. */ -static bool constant_time_equal(const char *a, const char *b) { - if (!a || !b) return false; - size_t la = strlen(a), lb = strlen(b); - size_t n = la > lb ? la : lb; - unsigned diff = (unsigned)(la ^ lb); - for (size_t i = 0; i < n; i++) { - unsigned char ca = i < la ? (unsigned char)a[i] : 0; - unsigned char cb = i < lb ? (unsigned char)b[i] : 0; - diff |= (unsigned)(ca ^ cb); - } - return diff == 0; -} - /* Cryptographically strong hex. Fails closed: if the kernel CSPRNG is * unavailable we refuse rather than fall back to predictable bytes (these * feed session tokens). */ @@ -280,26 +269,71 @@ static bool random_hex(char *out, size_t out_size, size_t bytes) { return true; } +/* mkdir -p for the account DB's parent directory (0700). */ +static int mkdir_p(const char *path, mode_t mode) { + char tmp[PATH_MAX]; + size_t len = snprintf(tmp, sizeof tmp, "%s", path); + if (len == 0 || len >= sizeof tmp) return -1; + for (char *p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = 0; + if (mkdir(tmp, mode) != 0 && errno != EEXIST) return -1; + *p = '/'; + } + } + if (mkdir(tmp, mode) != 0 && errno != EEXIST) return -1; + return 0; +} + +/* Resolve the account database path: NAUT_WEBUI_DB, else an XDG/HOME default + * under naut/. Creates the parent directory. */ +static bool resolve_auth_db_path(char *out, size_t n) { + const char *env = getenv("NAUT_WEBUI_DB"); + if (env && *env) return (size_t)snprintf(out, n, "%s", env) < n; + const char *xdg = getenv("XDG_DATA_HOME"); + const char *home = getenv("HOME"); + char dir[PATH_MAX]; + if (xdg && *xdg) snprintf(dir, sizeof dir, "%s/naut", xdg); + else if (home && *home) snprintf(dir, sizeof dir, "%s/.local/share/naut", home); + else return false; + if (mkdir_p(dir, 0700) != 0) return false; + return (size_t)snprintf(out, n, "%s/webui_accounts.db", dir) < n; +} + +/* Open the account store and, on first run (no accounts), bootstrap an admin + * from NAUT_AUTH_USER/PASSWORD or a generated password (logged once). */ static void init_auth(void) { + char db_path[PATH_MAX]; + if (!resolve_auth_db_path(db_path, sizeof db_path)) { + log_msg(0, "webui: cannot resolve account DB path; set NAUT_WEBUI_DB"); + return; + } + g_webui.auth = auth_store_open(db_path); + if (!g_webui.auth) { + log_msg(0, "webui: failed to open account database"); + return; + } const char *user = getenv("NAUT_AUTH_USER"); if (!user || !*user) user = getenv("NAUT_USER"); if (!user || !*user) user = "admin"; snprintf(g_webui.auth_user, sizeof g_webui.auth_user, "%s", user); + if (auth_store_user_count(g_webui.auth) > 0) return; /* already set up */ + + /* No accounts yet — create the initial admin. */ const char *password = getenv("NAUT_AUTH_PASSWORD"); if (!password || !*password) password = getenv("NAUT_PASSWORD"); if (password && *password) { - snprintf(g_webui.auth_password, sizeof g_webui.auth_password, "%s", - password); g_webui.generated_password = false; + } else if (random_hex(g_webui.auth_password, sizeof g_webui.auth_password, 9)) { + password = g_webui.auth_password; + g_webui.generated_password = true; + } else { + log_msg(0, "webui: no CSPRNG; set NAUT_AUTH_PASSWORD to create the admin"); return; } - if (!random_hex(g_webui.auth_password, sizeof g_webui.auth_password, 9)) { - /* No CSPRNG: leave the password empty so login is impossible rather - * than guessable. The operator must set NAUT_AUTH_PASSWORD. */ - g_webui.auth_password[0] = 0; - } - g_webui.generated_password = true; + if (!auth_store_create_user(g_webui.auth, user, password, "admin")) + log_msg(0, "webui: failed to create the initial admin account"); } static const char *header_value(const char *headers, const char *end, @@ -346,7 +380,12 @@ static bool cookie_token(const char *headers, const char *end, return false; } -static bool current_user(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. */ +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; @@ -360,6 +399,8 @@ static bool current_user(const char *headers, const char *end) { 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; } @@ -367,7 +408,8 @@ static bool current_user(const char *headers, const char *end) { return ok; } -static bool create_session(char *out, size_t out_size) { +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); @@ -382,6 +424,8 @@ static bool create_session(char *out, size_t out_size) { 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); @@ -389,6 +433,17 @@ static bool create_session(char *out, size_t out_size) { return true; } +/* 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); +} + static void clear_session(const char *headers, const char *end) { char token[96]; if (!cookie_token(headers, end, token, sizeof token)) return; @@ -2665,6 +2720,156 @@ static void serve_cached_torrents(int fd) { free(copy); } +/* ============================ account management ========================== * + * Admin-only user CRUD plus a self-service password change. The web layer owns + * everything via auth_store; the daemon is not involved. */ + +static bool valid_username(const char *u) { + if (!u || !*u || strlen(u) >= 64) return false; + for (const char *p = u; *p; p++) + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '_' || *p == '-' || *p == '.')) + return false; + return true; +} + +/* GET /api/users → [{username, role, createdAt}] (admin only). */ +static void api_users_list(int fd) { + json_t *users = json_array(); + if (g_webui.auth) auth_store_list_users(g_webui.auth, users); + http_json(fd, 200, users); + json_decref(users); +} + +/* POST /api/users {username, password, role} (admin only). */ +static void api_user_create(int fd, const char *body, size_t len) { + json_t *req = read_body_json(body, len); + const char *user = json_string_value(json_object_get(req, "username")); + const char *pass = json_string_value(json_object_get(req, "password")); + const char *role = json_string_value(json_object_get(req, "role")); + if (!valid_username(user) || !pass || !*pass) { + json_decref(req); + http_text(fd, 400, "Bad Request", + "username (letters/digits/._-) and password are required"); + return; + } + bool ok = g_webui.auth && + auth_store_create_user(g_webui.auth, user, pass, + role && *role ? role : "user"); + json_decref(req); + if (!ok) { http_text(fd, 409, "Conflict", "user already exists"); return; } + api_users_list(fd); +} + +/* POST /api/users/delete {username} (admin only). Refuses to remove the last + * admin so the instance can't lock everyone out. */ +static void api_user_delete(int fd, const char *body, size_t len, + const char *actor) { + json_t *req = read_body_json(body, len); + const char *uname = json_string_value(json_object_get(req, "username")); + if (!uname || !*uname) { json_decref(req); http_text(fd, 400, "Bad Request", "username required"); return; } + char user[64]; + snprintf(user, sizeof user, "%s", uname); /* own it before decref */ + char role[16] = {0}; + /* Look up the target's role to guard the last-admin rule. */ + json_t *list = json_array(); + if (g_webui.auth) auth_store_list_users(g_webui.auth, list); + size_t i; json_t *u; + json_array_foreach(list, i, u) + if (strcasecmp(json_string_or(u, "username", ""), user) == 0) + snprintf(role, sizeof role, "%s", json_string_or(u, "role", "")); + json_decref(list); + if (strcmp(role, "admin") == 0 && auth_store_admin_count(g_webui.auth) <= 1) { + json_decref(req); + http_text(fd, 409, "Conflict", "cannot delete the last admin"); + return; + } + bool ok = g_webui.auth && auth_store_delete_user(g_webui.auth, user); + json_decref(req); + if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; } + drop_user_sessions(user); + (void)actor; + api_users_list(fd); +} + +/* POST /api/users/password {username, password} — admin reset. */ +static void api_user_set_password(int fd, const char *body, size_t len) { + json_t *req = read_body_json(body, len); + const char *uname = json_string_value(json_object_get(req, "username")); + const char *pass = json_string_value(json_object_get(req, "password")); + if (!uname || !*uname || !pass || !*pass) { + json_decref(req); + http_text(fd, 400, "Bad Request", "username and password required"); + return; + } + char user[64]; + snprintf(user, sizeof user, "%s", uname); + bool ok = g_webui.auth && auth_store_set_password(g_webui.auth, user, pass); + json_decref(req); + if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; } + drop_user_sessions(user); /* force re-login with the new password */ + json_t *reply = json_pack("{s:b}", "ok", 1); + http_json(fd, 200, reply); + json_decref(reply); +} + +/* POST /api/users/role {username, role} — admin; keeps at least one admin. */ +static void api_user_set_role(int fd, const char *body, size_t len) { + json_t *req = read_body_json(body, len); + const char *user = json_string_value(json_object_get(req, "username")); + const char *role = json_string_value(json_object_get(req, "role")); + if (!user || !*user || (strcmp(role ? role : "", "admin") && strcmp(role ? role : "", "user"))) { + json_decref(req); + http_text(fd, 400, "Bad Request", "username and role (admin|user) required"); + return; + } + if (strcmp(role, "user") == 0 && auth_store_admin_count(g_webui.auth) <= 1) { + /* Only block if the target is currently the sole admin. */ + char cur[16] = {0}; + json_t *list = json_array(); + if (g_webui.auth) auth_store_list_users(g_webui.auth, list); + size_t i; json_t *u; + json_array_foreach(list, i, u) + if (strcasecmp(json_string_or(u, "username", ""), user) == 0) + snprintf(cur, sizeof cur, "%s", json_string_or(u, "role", "")); + json_decref(list); + if (strcmp(cur, "admin") == 0) { + json_decref(req); + http_text(fd, 409, "Conflict", "cannot demote the last admin"); + return; + } + } + bool ok = g_webui.auth && auth_store_set_role(g_webui.auth, user, role); + json_decref(req); + if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; } + api_users_list(fd); +} + +/* POST /api/account/password {oldPassword, newPassword} — change own password. */ +static void api_account_password(int fd, const char *body, size_t len, + const char *actor) { + json_t *req = read_body_json(body, len); + const char *oldp = json_string_value(json_object_get(req, "oldPassword")); + const char *newp = json_string_value(json_object_get(req, "newPassword")); + if (!oldp || !newp || !*newp) { + json_decref(req); + http_text(fd, 400, "Bad Request", "oldPassword and newPassword required"); + return; + } + char role[16] = {0}; + if (!g_webui.auth || !auth_store_verify(g_webui.auth, actor, oldp, role, sizeof role)) { + json_decref(req); + http_text(fd, 403, "Forbidden", "current password is incorrect"); + return; + } + bool ok = auth_store_set_password(g_webui.auth, actor, newp); + json_decref(req); + if (!ok) { http_text(fd, 500, "Internal Server Error", "could not update password"); return; } + json_t *reply = json_pack("{s:b}", "ok", 1); + http_json(fd, 200, reply); + json_decref(reply); +} + static void handle_api(int fd, const char *method, char *path, const char *headers, const char *headers_end, const char *body, size_t body_len) { @@ -2673,13 +2878,15 @@ static void handle_api(int fd, const char *method, char *path, const char *qmark = strchr(path, '?'); if (qmark) snprintf(query_str, sizeof query_str, "%s", qmark + 1); strip_query(path); + char cur_user[64] = {0}, cur_role[16] = {0}; if (strcmp(path, "/api/auth/status") == 0 && strcmp(method, "GET") == 0) { - json_t *json = json_pack("{s:b,s:s,s:b}", - "authenticated", - current_user(headers, headers_end), - "user", g_webui.auth_user, - "generatedPassword", - g_webui.generated_password); + bool authed = current_identity(headers, headers_end, cur_user, + sizeof cur_user, cur_role, sizeof cur_role); + json_t *json = json_pack("{s:b,s:s,s:s,s:b}", + "authenticated", authed, + "user", authed ? cur_user : "", + "role", authed ? cur_role : "", + "generatedPassword", g_webui.generated_password); http_json(fd, 200, json); json_decref(json); } else if (strcmp(path, "/api/login") == 0 && strcmp(method, "POST") == 0) { @@ -2687,10 +2894,10 @@ static void handle_api(int fd, const char *method, char *path, const char *user = json_string_value(json_object_get(req, "username")); const char *password = json_string_value(json_object_get(req, "password")); - bool user_ok = user && constant_time_equal(user, g_webui.auth_user); - bool pass_ok = password && g_webui.auth_password[0] && - constant_time_equal(password, g_webui.auth_password); - if (!user_ok || !pass_ok) { + char role[16] = {0}; + bool ok = g_webui.auth && user && password && + auth_store_verify(g_webui.auth, user, password, role, sizeof role); + if (!ok) { json_t *json = json_pack("{s:b,s:s}", "ok", 0, "error", "invalid credentials"); http_json(fd, 401, json); @@ -2699,7 +2906,7 @@ static void handle_api(int fd, const char *method, char *path, return; } char token[96]; - if (!create_session(token, sizeof token)) { + if (!create_session(user, role, token, sizeof token)) { json_decref(req); http_text(fd, 500, "Internal Server Error", "session failed"); return; @@ -2709,8 +2916,8 @@ static void handle_api(int fd, const char *method, char *path, "Set-Cookie: %s=%s; Path=/; HttpOnly; SameSite=Lax; " "Max-Age=%d\r\n", SESSION_COOKIE, token, SESSION_TTL_SECONDS); - json_t *json = json_pack("{s:b,s:s}", "ok", 1, - "user", g_webui.auth_user); + json_t *json = json_pack("{s:b,s:s,s:s}", "ok", 1, + "user", user, "role", role); http_json_extra(fd, 200, json, cookie); json_decref(json); json_decref(req); @@ -2721,7 +2928,8 @@ static void handle_api(int fd, const char *method, char *path, "Set-Cookie: naut_session=; Path=/; HttpOnly; " "SameSite=Lax; Max-Age=0\r\n"); json_decref(json); - } else if (!current_user(headers, headers_end)) { + } else if (!current_identity(headers, headers_end, cur_user, sizeof cur_user, + cur_role, sizeof cur_role)) { json_t *json = json_pack("{s:s}", "error", "authentication required"); http_json(fd, 401, json); @@ -2767,6 +2975,25 @@ static void handle_api(int fd, const char *method, char *path, api_delete(fd, body, body_len); } else if (strcmp(path, "/api/action") == 0 && strcmp(method, "POST") == 0) { api_action(fd, body, body_len); + } else if (strcmp(path, "/api/account/password") == 0 && strcmp(method, "POST") == 0) { + api_account_password(fd, body, body_len, cur_user); + } else if (strncmp(path, "/api/users", 10) == 0) { + /* All user-management endpoints are admin-only. */ + if (strcmp(cur_role, "admin") != 0) { + http_text(fd, 403, "Forbidden", "admin privileges required"); + } else if (strcmp(path, "/api/users") == 0 && strcmp(method, "GET") == 0) { + api_users_list(fd); + } else if (strcmp(path, "/api/users") == 0 && strcmp(method, "POST") == 0) { + api_user_create(fd, body, body_len); + } else if (strcmp(path, "/api/users/delete") == 0 && strcmp(method, "POST") == 0) { + api_user_delete(fd, body, body_len, cur_user); + } else if (strcmp(path, "/api/users/password") == 0 && strcmp(method, "POST") == 0) { + api_user_set_password(fd, body, body_len); + } else if (strcmp(path, "/api/users/role") == 0 && strcmp(method, "POST") == 0) { + api_user_set_role(fd, body, body_len); + } else { + http_text(fd, 404, "Not Found", "not found"); + } } else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "GET") == 0) { api_rss_list(fd); } else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "POST") == 0) { @@ -3013,15 +3240,14 @@ static naut_err start_server(void) { if (strcmp(g_webui.host_name, DEFAULT_HOST) != 0) log_msg(1, "webui: bound to a non-loopback address; credentials cross " "the network in plaintext (set NAUT_AUTH_PASSWORD)"); - snprintf(msg, sizeof msg, "webui: auth user %s", g_webui.auth_user); - log_msg(2, msg); - if (g_webui.generated_password && g_webui.auth_password[0]) { - snprintf(msg, sizeof msg, "webui: generated password %s", - g_webui.auth_password); + if (!g_webui.auth) { + log_msg(0, "webui: account store unavailable; logins will fail"); + } else if (g_webui.generated_password && g_webui.auth_password[0]) { + /* First run: surface the generated admin credentials once. */ + snprintf(msg, sizeof msg, + "webui: created initial admin '%s' with generated password %s", + g_webui.auth_user, g_webui.auth_password); log_msg(1, msg); - } else if (!g_webui.auth_password[0]) { - log_msg(0, "webui: no password available (CSPRNG unavailable); set " - "NAUT_AUTH_PASSWORD to enable login"); } return NAUT_OK; } @@ -3147,5 +3373,7 @@ naut_err naut_plugin_shutdown(void) { pthread_cond_destroy(&g_webui.conn_cond); pthread_mutex_destroy(&g_webui.conn_lock); pthread_mutex_destroy(&g_webui.auth_lock); + auth_store_close(g_webui.auth); + g_webui.auth = NULL; return NAUT_OK; }