The plugin's SQLite store will own more than accounts (taxonomy, RSS), so give it a general name. Mechanical rename of files + symbols; default DB filename is now webui.db. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
274 lines
10 KiB
C
274 lines
10 KiB
C
/* webui_store.c — SQLite + PBKDF2 implementation of the web-UI account store. */
|
|
#include "webui_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 webui_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);
|
|
}
|
|
|
|
webui_store *webui_store_open(const char *path) {
|
|
webui_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);
|
|
webui_store_close(s);
|
|
return NULL;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
void webui_store_close(webui_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(webui_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 webui_store_user_count(webui_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 webui_store_admin_count(webui_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 webui_store_user_exists(webui_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 webui_store_verify(webui_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 webui_store_create_user(webui_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 webui_store_set_password(webui_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 webui_store_set_role(webui_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 webui_store_delete_user(webui_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 webui_store_list_users(webui_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;
|
|
}
|