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 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-23 21:43:24 -04:00
parent ab733cb573
commit a4ec585aed
4 changed files with 604 additions and 51 deletions

View file

@ -220,9 +220,14 @@ add_library(naut_example MODULE plugins/example/example.c)
target_include_directories(naut_example PRIVATE ${CMAKE_SOURCE_DIR}/include) target_include_directories(naut_example PRIVATE ${CMAKE_SOURCE_DIR}/include)
set_target_properties(naut_example PROPERTIES PREFIX "") 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_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 "") set_target_properties(naut_webui PROPERTIES PREFIX "")
# --- swarm: multi-peer download driver over the torrent-peer engine --------- # --- swarm: multi-peer download driver over the torrent-peer engine ---------

274
plugins/webui/auth_store.c Normal file
View file

@ -0,0 +1,274 @@
/* auth_store.c — SQLite + PBKDF2 implementation of the web-UI account store. */
#include "auth_store.h"
#include <pthread.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sqlite3.h>
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <openssl/crypto.h>
#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;
}

View file

@ -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 <stdbool.h>
#include <stddef.h>
#include <jansson.h>
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 */

View file

@ -1,5 +1,6 @@
#include "naut/naut_plugin.h" #include "naut/naut_plugin.h"
#include "naut/http_client.h" #include "naut/http_client.h"
#include "auth_store.h"
#include <jansson.h> #include <jansson.h>
@ -36,6 +37,8 @@
typedef struct { typedef struct {
char token[96]; char token[96];
char user[64];
char role[16];
time_t expires; time_t expires;
bool used; bool used;
} webui_session; } webui_session;
@ -54,8 +57,9 @@ typedef struct {
naut_host_api host; naut_host_api host;
char root[PATH_MAX]; char root[PATH_MAX];
char host_name[64]; char host_name[64];
char auth_user[64]; char auth_user[64]; /* bootstrap admin name (for startup banner) */
char auth_password[64]; char auth_password[64]; /* generated bootstrap password (banner only) */
auth_store *auth; /* SQLite-backed account store */
int port; int port;
int listener; int listener;
bool generated_password; bool generated_password;
@ -240,21 +244,6 @@ static bool query_get(const char *query, const char *key, char *out, size_t outs
return false; 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 /* Cryptographically strong hex. Fails closed: if the kernel CSPRNG is
* unavailable we refuse rather than fall back to predictable bytes (these * unavailable we refuse rather than fall back to predictable bytes (these
* feed session tokens). */ * feed session tokens). */
@ -280,26 +269,71 @@ static bool random_hex(char *out, size_t out_size, size_t bytes) {
return true; 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) { 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"); const char *user = getenv("NAUT_AUTH_USER");
if (!user || !*user) user = getenv("NAUT_USER"); if (!user || !*user) user = getenv("NAUT_USER");
if (!user || !*user) user = "admin"; if (!user || !*user) user = "admin";
snprintf(g_webui.auth_user, sizeof g_webui.auth_user, "%s", user); 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"); const char *password = getenv("NAUT_AUTH_PASSWORD");
if (!password || !*password) password = getenv("NAUT_PASSWORD"); if (!password || !*password) password = getenv("NAUT_PASSWORD");
if (password && *password) { if (password && *password) {
snprintf(g_webui.auth_password, sizeof g_webui.auth_password, "%s",
password);
g_webui.generated_password = false; 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; return;
} }
if (!random_hex(g_webui.auth_password, sizeof g_webui.auth_password, 9)) { if (!auth_store_create_user(g_webui.auth, user, password, "admin"))
/* No CSPRNG: leave the password empty so login is impossible rather log_msg(0, "webui: failed to create the initial admin account");
* than guessable. The operator must set NAUT_AUTH_PASSWORD. */
g_webui.auth_password[0] = 0;
}
g_webui.generated_password = true;
} }
static const char *header_value(const char *headers, const char *end, 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; 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]; char token[96];
if (!cookie_token(headers, end, token, sizeof token)) return false; if (!cookie_token(headers, end, token, sizeof token)) return false;
bool ok = false; bool ok = false;
@ -360,6 +399,8 @@ static bool current_user(const char *headers, const char *end) {
break; break;
} }
session->expires = now + SESSION_TTL_SECONDS; 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; ok = true;
break; break;
} }
@ -367,7 +408,8 @@ static bool current_user(const char *headers, const char *end) {
return ok; 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]; char token[96];
if (!random_hex(token, sizeof token, 24)) return false; if (!random_hex(token, sizeof token, 24)) return false;
time_t now = time(NULL); 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; if (!slot || s->expires < slot->expires) slot = s;
} }
snprintf(slot->token, sizeof slot->token, "%s", token); 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->expires = expires;
slot->used = true; slot->used = true;
pthread_mutex_unlock(&g_webui.auth_lock); pthread_mutex_unlock(&g_webui.auth_lock);
@ -389,6 +433,17 @@ static bool create_session(char *out, size_t out_size) {
return true; 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) { static void clear_session(const char *headers, const char *end) {
char token[96]; char token[96];
if (!cookie_token(headers, end, token, sizeof token)) return; if (!cookie_token(headers, end, token, sizeof token)) return;
@ -2665,6 +2720,156 @@ static void serve_cached_torrents(int fd) {
free(copy); 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, static void handle_api(int fd, const char *method, char *path,
const char *headers, const char *headers_end, const char *headers, const char *headers_end,
const char *body, size_t body_len) { 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, '?'); const char *qmark = strchr(path, '?');
if (qmark) snprintf(query_str, sizeof query_str, "%s", qmark + 1); if (qmark) snprintf(query_str, sizeof query_str, "%s", qmark + 1);
strip_query(path); strip_query(path);
char cur_user[64] = {0}, cur_role[16] = {0};
if (strcmp(path, "/api/auth/status") == 0 && strcmp(method, "GET") == 0) { if (strcmp(path, "/api/auth/status") == 0 && strcmp(method, "GET") == 0) {
json_t *json = json_pack("{s:b,s:s,s:b}", bool authed = current_identity(headers, headers_end, cur_user,
"authenticated", sizeof cur_user, cur_role, sizeof cur_role);
current_user(headers, headers_end), json_t *json = json_pack("{s:b,s:s,s:s,s:b}",
"user", g_webui.auth_user, "authenticated", authed,
"generatedPassword", "user", authed ? cur_user : "",
g_webui.generated_password); "role", authed ? cur_role : "",
"generatedPassword", g_webui.generated_password);
http_json(fd, 200, json); http_json(fd, 200, json);
json_decref(json); json_decref(json);
} else if (strcmp(path, "/api/login") == 0 && strcmp(method, "POST") == 0) { } 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 *user = json_string_value(json_object_get(req, "username"));
const char *password = const char *password =
json_string_value(json_object_get(req, "password")); json_string_value(json_object_get(req, "password"));
bool user_ok = user && constant_time_equal(user, g_webui.auth_user); char role[16] = {0};
bool pass_ok = password && g_webui.auth_password[0] && bool ok = g_webui.auth && user && password &&
constant_time_equal(password, g_webui.auth_password); auth_store_verify(g_webui.auth, user, password, role, sizeof role);
if (!user_ok || !pass_ok) { if (!ok) {
json_t *json = json_pack("{s:b,s:s}", "ok", 0, json_t *json = json_pack("{s:b,s:s}", "ok", 0,
"error", "invalid credentials"); "error", "invalid credentials");
http_json(fd, 401, json); http_json(fd, 401, json);
@ -2699,7 +2906,7 @@ static void handle_api(int fd, const char *method, char *path,
return; return;
} }
char token[96]; char token[96];
if (!create_session(token, sizeof token)) { if (!create_session(user, role, token, sizeof token)) {
json_decref(req); json_decref(req);
http_text(fd, 500, "Internal Server Error", "session failed"); http_text(fd, 500, "Internal Server Error", "session failed");
return; return;
@ -2709,8 +2916,8 @@ static void handle_api(int fd, const char *method, char *path,
"Set-Cookie: %s=%s; Path=/; HttpOnly; SameSite=Lax; " "Set-Cookie: %s=%s; Path=/; HttpOnly; SameSite=Lax; "
"Max-Age=%d\r\n", "Max-Age=%d\r\n",
SESSION_COOKIE, token, SESSION_TTL_SECONDS); SESSION_COOKIE, token, SESSION_TTL_SECONDS);
json_t *json = json_pack("{s:b,s:s}", "ok", 1, json_t *json = json_pack("{s:b,s:s,s:s}", "ok", 1,
"user", g_webui.auth_user); "user", user, "role", role);
http_json_extra(fd, 200, json, cookie); http_json_extra(fd, 200, json, cookie);
json_decref(json); json_decref(json);
json_decref(req); json_decref(req);
@ -2721,7 +2928,8 @@ static void handle_api(int fd, const char *method, char *path,
"Set-Cookie: naut_session=; Path=/; HttpOnly; " "Set-Cookie: naut_session=; Path=/; HttpOnly; "
"SameSite=Lax; Max-Age=0\r\n"); "SameSite=Lax; Max-Age=0\r\n");
json_decref(json); 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", json_t *json = json_pack("{s:s}", "error",
"authentication required"); "authentication required");
http_json(fd, 401, json); 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); api_delete(fd, body, body_len);
} else if (strcmp(path, "/api/action") == 0 && strcmp(method, "POST") == 0) { } else if (strcmp(path, "/api/action") == 0 && strcmp(method, "POST") == 0) {
api_action(fd, body, body_len); 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) { } else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "GET") == 0) {
api_rss_list(fd); api_rss_list(fd);
} else if (strcmp(path, "/api/rss") == 0 && strcmp(method, "POST") == 0) { } 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) if (strcmp(g_webui.host_name, DEFAULT_HOST) != 0)
log_msg(1, "webui: bound to a non-loopback address; credentials cross " log_msg(1, "webui: bound to a non-loopback address; credentials cross "
"the network in plaintext (set NAUT_AUTH_PASSWORD)"); "the network in plaintext (set NAUT_AUTH_PASSWORD)");
snprintf(msg, sizeof msg, "webui: auth user %s", g_webui.auth_user); if (!g_webui.auth) {
log_msg(2, msg); log_msg(0, "webui: account store unavailable; logins will fail");
if (g_webui.generated_password && g_webui.auth_password[0]) { } else if (g_webui.generated_password && g_webui.auth_password[0]) {
snprintf(msg, sizeof msg, "webui: generated password %s", /* First run: surface the generated admin credentials once. */
g_webui.auth_password); 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); 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; return NAUT_OK;
} }
@ -3147,5 +3373,7 @@ naut_err naut_plugin_shutdown(void) {
pthread_cond_destroy(&g_webui.conn_cond); pthread_cond_destroy(&g_webui.conn_cond);
pthread_mutex_destroy(&g_webui.conn_lock); pthread_mutex_destroy(&g_webui.conn_lock);
pthread_mutex_destroy(&g_webui.auth_lock); pthread_mutex_destroy(&g_webui.auth_lock);
auth_store_close(g_webui.auth);
g_webui.auth = NULL;
return NAUT_OK; return NAUT_OK;
} }