From 44f3192b78e88ac8aa0f6f064b743da81f6288ed Mon Sep 17 00:00:00 2001 From: Andrew Gundersen Date: Fri, 27 Feb 2026 11:33:07 -0500 Subject: [PATCH] Add multi-user auth with sessions and login UI - auth: POST /auth validates PAM, returns session token - auth: GET /me validates Bearer token, returns username - auth: POST /logout destroys session - auth: POST /users creates Linux user via useradd+chpasswd - session.c: in-memory token store (1024 slots, pthread mutex, /dev/urandom tokens) - user.c: fork/exec useradd + chpasswd with username validation - ui: login overlay shown until authenticated (x-cloak) - ui: token persisted in sessionStorage, validated on page load - ui: username displayed in dock after login - ui: apps only load after successful auth Co-Authored-By: Claude Sonnet 4.6 --- auth/Makefile | 4 +- auth/src/main.c | 107 +++++++++++++++++++++++++++++++++++++++---- auth/src/session.c | 93 +++++++++++++++++++++++++++++++++++++ auth/src/session.h | 13 ++++++ auth/src/user.c | 79 ++++++++++++++++++++++++++++++++ auth/src/user.h | 10 ++++ ui/index.html | 26 +++++++++++ ui/js/os.js | 80 ++++++++++++++++++++++++++------ ui/styles/canvas.css | 1 + ui/styles/login.css | 83 +++++++++++++++++++++++++++++++++ 10 files changed, 470 insertions(+), 26 deletions(-) create mode 100644 auth/src/session.c create mode 100644 auth/src/session.h create mode 100644 auth/src/user.c create mode 100644 auth/src/user.h create mode 100644 ui/styles/login.css diff --git a/auth/Makefile b/auth/Makefile index 9cc64b9..bc22085 100644 --- a/auth/Makefile +++ b/auth/Makefile @@ -1,7 +1,7 @@ CC = gcc CFLAGS = -Wall -Wextra -O2 -LIBS = -lpam -lmicrohttpd -SRC = src/main.c src/pam_auth.c +LIBS = -lpam -lmicrohttpd -lpthread +SRC = src/main.c src/pam_auth.c src/session.c src/user.c OUT = crimata-auth .PHONY: all clean diff --git a/auth/src/main.c b/auth/src/main.c index 5476a1c..c6b9325 100644 --- a/auth/src/main.c +++ b/auth/src/main.c @@ -3,6 +3,8 @@ #include #include #include "pam_auth.h" +#include "session.h" +#include "user.h" #define PORT 7700 #define MAX_BODY 4096 @@ -17,7 +19,7 @@ static const char *json_get(const char *json, const char *key, char *out, size_t if (!pos) return NULL; pos += strlen(search); - while (*pos == ' ' || *pos == ':' || *pos == ' ') pos++; + while (*pos == ' ' || *pos == ':') pos++; if (*pos != '"') return NULL; pos++; @@ -35,11 +37,21 @@ static enum MHD_Result send_json(struct MHD_Connection *conn, struct MHD_Response *resp = MHD_create_response_from_buffer( strlen(body), (void *)body, MHD_RESPMEM_MUST_COPY); MHD_add_response_header(resp, "Content-Type", "application/json"); + MHD_add_response_header(resp, "Access-Control-Allow-Origin", "*"); enum MHD_Result ret = MHD_queue_response(conn, status, resp); MHD_destroy_response(resp); return ret; } +/* Extract Bearer token from Authorization header */ +static const char *bearer_token(struct MHD_Connection *conn) +{ + const char *auth = MHD_lookup_connection_value(conn, MHD_HEADER_KIND, "Authorization"); + if (!auth) return NULL; + if (strncmp(auth, "Bearer ", 7) != 0) return NULL; + return auth + 7; +} + /* ── Request context ──────────────────────────────────────────────────────── */ typedef struct { @@ -54,6 +66,7 @@ static enum MHD_Result handle_health(struct MHD_Connection *conn) return send_json(conn, MHD_HTTP_OK, "{\"status\":\"ok\"}"); } +/* POST /auth { username, password } → { token } */ static enum MHD_Result handle_auth(struct MHD_Connection *conn, const char *body) { char username[256] = {0}; @@ -65,12 +78,78 @@ static enum MHD_Result handle_auth(struct MHD_Connection *conn, const char *body "{\"success\":false,\"error\":\"username and password required\"}"); } - int result = authenticate(username, password); - if (result == 0) { - return send_json(conn, MHD_HTTP_OK, "{\"success\":true}"); + if (authenticate(username, password) != 0) { + return send_json(conn, MHD_HTTP_UNAUTHORIZED, + "{\"success\":false,\"error\":\"invalid credentials\"}"); + } + + const char *token = session_create(username); + if (!token) { + return send_json(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"success\":false,\"error\":\"no session slots\"}"); } - return send_json(conn, MHD_HTTP_UNAUTHORIZED, - "{\"success\":false,\"error\":\"invalid credentials\"}"); + + char resp[320]; + snprintf(resp, sizeof(resp), + "{\"success\":true,\"token\":\"%s\",\"username\":\"%s\"}", token, username); + return send_json(conn, MHD_HTTP_OK, resp); +} + +/* GET /me Authorization: Bearer → { username } */ +static enum MHD_Result handle_me(struct MHD_Connection *conn) +{ + const char *token = bearer_token(conn); + if (!token) { + return send_json(conn, MHD_HTTP_UNAUTHORIZED, + "{\"error\":\"missing token\"}"); + } + + const char *username = session_lookup(token); + if (!username) { + return send_json(conn, MHD_HTTP_UNAUTHORIZED, + "{\"error\":\"invalid token\"}"); + } + + char resp[320]; + snprintf(resp, sizeof(resp), "{\"username\":\"%s\"}", username); + return send_json(conn, MHD_HTTP_OK, resp); +} + +/* POST /logout Authorization: Bearer */ +static enum MHD_Result handle_logout(struct MHD_Connection *conn) +{ + const char *token = bearer_token(conn); + if (token) session_destroy(token); + return send_json(conn, MHD_HTTP_OK, "{\"success\":true}"); +} + +/* POST /users { username, password } → create Linux user */ +static enum MHD_Result handle_create_user(struct MHD_Connection *conn, const char *body) +{ + /* Caller must be authenticated */ + const char *token = bearer_token(conn); + if (!token || !session_lookup(token)) { + return send_json(conn, MHD_HTTP_UNAUTHORIZED, + "{\"success\":false,\"error\":\"authentication required\"}"); + } + + char username[256] = {0}; + char password[256] = {0}; + + if (!json_get(body, "username", username, sizeof(username)) || + !json_get(body, "password", password, sizeof(password))) { + return send_json(conn, MHD_HTTP_BAD_REQUEST, + "{\"success\":false,\"error\":\"username and password required\"}"); + } + + if (user_create(username, password) != 0) { + return send_json(conn, MHD_HTTP_CONFLICT, + "{\"success\":false,\"error\":\"could not create user\"}"); + } + + char resp[320]; + snprintf(resp, sizeof(resp), "{\"success\":true,\"username\":\"%s\"}", username); + return send_json(conn, MHD_HTTP_CREATED, resp); } /* ── Main handler ─────────────────────────────────────────────────────────── */ @@ -90,8 +169,16 @@ static enum MHD_Result handler(void *cls, if (strcmp(url, "/health") == 0 && strcmp(method, "GET") == 0) return handle_health(conn); - /* Auth — collect body first */ - if (strcmp(url, "/auth") == 0 && strcmp(method, "POST") == 0) { + /* GET /me */ + if (strcmp(url, "/me") == 0 && strcmp(method, "GET") == 0) + return handle_me(conn); + + /* Routes that need a request body */ + if ((strcmp(url, "/auth") == 0 || + strcmp(url, "/logout") == 0 || + strcmp(url, "/users") == 0) && + (strcmp(method, "POST") == 0)) + { if (!*con_cls) { request_ctx_t *ctx = calloc(1, sizeof(request_ctx_t)); if (!ctx) return MHD_NO; @@ -110,7 +197,9 @@ static enum MHD_Result handler(void *cls, return MHD_YES; } - return handle_auth(conn, ctx->body); + if (strcmp(url, "/auth") == 0) return handle_auth(conn, ctx->body); + if (strcmp(url, "/logout") == 0) return handle_logout(conn); + if (strcmp(url, "/users") == 0) return handle_create_user(conn, ctx->body); } return send_json(conn, MHD_HTTP_NOT_FOUND, "{\"error\":\"not found\"}"); diff --git a/auth/src/session.c b/auth/src/session.c new file mode 100644 index 0000000..32c8c53 --- /dev/null +++ b/auth/src/session.c @@ -0,0 +1,93 @@ +#include "session.h" +#include +#include +#include +#include +#include + +#define MAX_SESSIONS 1024 +#define TOKEN_LEN 32 + +typedef struct { + char token[TOKEN_LEN + 1]; + char username[256]; + int active; +} session_t; + +static session_t sessions[MAX_SESSIONS]; +static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; + +/* Generate a random hex token */ +static void gen_token(char *out) +{ + static const char hex[] = "0123456789abcdef"; + unsigned char buf[TOKEN_LEN / 2]; + FILE *f = fopen("/dev/urandom", "rb"); + if (f) { + fread(buf, 1, sizeof(buf), f); + fclose(f); + } else { + /* fallback: not cryptographically strong but avoids hanging */ + srand((unsigned)time(NULL)); + for (size_t i = 0; i < sizeof(buf); i++) + buf[i] = (unsigned char)rand(); + } + for (size_t i = 0; i < sizeof(buf); i++) { + out[i * 2] = hex[buf[i] >> 4]; + out[i * 2 + 1] = hex[buf[i] & 0xf]; + } + out[TOKEN_LEN] = '\0'; +} + +const char *session_create(const char *username) +{ + pthread_mutex_lock(&lock); + + /* Find a free slot */ + for (int i = 0; i < MAX_SESSIONS; i++) { + if (!sessions[i].active) { + gen_token(sessions[i].token); + strncpy(sessions[i].username, username, sizeof(sessions[i].username) - 1); + sessions[i].username[sizeof(sessions[i].username) - 1] = '\0'; + sessions[i].active = 1; + pthread_mutex_unlock(&lock); + return sessions[i].token; + } + } + + pthread_mutex_unlock(&lock); + return NULL; /* no free slots */ +} + +const char *session_lookup(const char *token) +{ + if (!token || !*token) return NULL; + + pthread_mutex_lock(&lock); + for (int i = 0; i < MAX_SESSIONS; i++) { + if (sessions[i].active && + strncmp(sessions[i].token, token, TOKEN_LEN) == 0) { + pthread_mutex_unlock(&lock); + return sessions[i].username; + } + } + pthread_mutex_unlock(&lock); + return NULL; +} + +void session_destroy(const char *token) +{ + if (!token || !*token) return; + + pthread_mutex_lock(&lock); + for (int i = 0; i < MAX_SESSIONS; i++) { + if (sessions[i].active && + strncmp(sessions[i].token, token, TOKEN_LEN) == 0) { + sessions[i].active = 0; + memset(sessions[i].token, 0, sizeof(sessions[i].token)); + memset(sessions[i].username, 0, sizeof(sessions[i].username)); + break; + } + } + pthread_mutex_unlock(&lock); +} diff --git a/auth/src/session.h b/auth/src/session.h new file mode 100644 index 0000000..f363773 --- /dev/null +++ b/auth/src/session.h @@ -0,0 +1,13 @@ +#ifndef SESSION_H +#define SESSION_H + +/* Create a session for username — returns token string (valid until destroyed) */ +const char *session_create(const char *username); + +/* Look up a token — returns username or NULL if not found */ +const char *session_lookup(const char *token); + +/* Destroy a session */ +void session_destroy(const char *token); + +#endif diff --git a/auth/src/user.c b/auth/src/user.c new file mode 100644 index 0000000..73dc4ea --- /dev/null +++ b/auth/src/user.c @@ -0,0 +1,79 @@ +#include "user.h" +#include +#include +#include +#include +#include +#include + +/* + * Run a command with two arguments, writing optional stdin data to it. + * Returns the exit code, or -1 on fork/exec failure. + */ +static int run_cmd(const char *path, const char *const argv[], + const char *stdin_data) +{ + int pipefd[2] = {-1, -1}; + + if (stdin_data) { + if (pipe(pipefd) != 0) return -1; + } + + pid_t pid = fork(); + if (pid < 0) return -1; + + if (pid == 0) { + /* child */ + if (stdin_data) { + close(pipefd[1]); + dup2(pipefd[0], STDIN_FILENO); + close(pipefd[0]); + } + execv(path, (char *const *)argv); + _exit(127); + } + + /* parent */ + if (stdin_data) { + close(pipefd[0]); + size_t len = strlen(stdin_data); + write(pipefd[1], stdin_data, len); + close(pipefd[1]); + } + + int status; + waitpid(pid, &status, 0); + if (WIFEXITED(status)) return WEXITSTATUS(status); + return -1; +} + +int user_create(const char *username, const char *password) +{ + /* Validate: username must be non-empty and only contain safe chars */ + if (!username || !*username || !password || !*password) return -1; + for (const char *p = username; *p; p++) { + char c = *p; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '-')) + return -1; + } + + /* useradd -m -s /bin/bash */ + const char *useradd_argv[] = { + "/usr/sbin/useradd", "-m", "-s", "/bin/bash", username, NULL + }; + int rc = run_cmd("/usr/sbin/useradd", useradd_argv, NULL); + if (rc != 0) return -1; + + /* chpasswd reads "username:password\n" from stdin */ + char chpasswd_input[512]; + snprintf(chpasswd_input, sizeof(chpasswd_input), "%s:%s\n", username, password); + + const char *chpasswd_argv[] = { "/usr/sbin/chpasswd", NULL }; + rc = run_cmd("/usr/sbin/chpasswd", chpasswd_argv, chpasswd_input); + + /* Zero out the password from the stack before returning */ + memset(chpasswd_input, 0, sizeof(chpasswd_input)); + + return (rc == 0) ? 0 : -1; +} diff --git a/auth/src/user.h b/auth/src/user.h new file mode 100644 index 0000000..4296006 --- /dev/null +++ b/auth/src/user.h @@ -0,0 +1,10 @@ +#ifndef USER_H +#define USER_H + +/* + * Create a new Linux user with useradd + set password via chpasswd. + * Returns 0 on success, -1 on error (user already exists, permission denied, etc.) + */ +int user_create(const char *username, const char *password); + +#endif diff --git a/ui/index.html b/ui/index.html index 432482b..526c231 100644 --- a/ui/index.html +++ b/ui/index.html @@ -8,10 +8,35 @@ + + +
+ +
+
+
diff --git a/ui/js/os.js b/ui/js/os.js index 04cc59f..cbbe104 100644 --- a/ui/js/os.js +++ b/ui/js/os.js @@ -1,6 +1,61 @@ document.addEventListener('alpine:init', () => { Alpine.data('os', () => ({ + // ── Auth state ──────────────────────────────────────────────────────── + authed: false, + currentUser: '', + loginUsername: '', + loginPassword: '', + loginError: '', + + async submitLogin() { + this.loginError = '' + try { + const res = await fetch('/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: this.loginUsername, + password: this.loginPassword, + }), + }) + const data = await res.json() + if (!res.ok || !data.success) { + this.loginError = data.error || 'Login failed' + return + } + sessionStorage.setItem('auth_token', data.token) + this.currentUser = data.username + this.loginPassword = '' + this.authed = true + await this.loadApps() + const bio = this.installedApps.find(a => a.id === 'bio') + if (bio) this.openApp(bio) + } catch (e) { + this.loginError = 'Auth service unreachable' + } + }, + + async checkAuth() { + const token = sessionStorage.getItem('auth_token') + if (!token) return + + try { + const res = await fetch('/me', { + headers: { 'Authorization': `Bearer ${token}` }, + }) + if (res.ok) { + const data = await res.json() + this.currentUser = data.username + this.authed = true + } else { + sessionStorage.removeItem('auth_token') + } + } catch (_) { + /* auth service down — leave authed=false */ + } + }, + // ── Canvas pan state ────────────────────────────────────────────────── offset: { x: 0, y: 0 }, isPanning: false, @@ -105,13 +160,6 @@ document.addEventListener('alpine:init', () => { submitQuery() { const query = this.cursorQuery.trim().toLowerCase() - // Built-in commands - if (query === 'login') { - this.openApp({ id: 'login', name: 'Login', url: '/login', icon: '🔐' }) - this.exitInputMode() - return - } - // Check if it matches an installed app name const app = this.installedApps.find(a => a.name.toLowerCase() === query) if (app) { this.openApp(app); this.exitInputMode(); return } @@ -139,14 +187,16 @@ document.addEventListener('alpine:init', () => { // ── Init ────────────────────────────────────────────────────────────── async init() { - await this.loadApps() - - // Refresh running status every 10s - setInterval(() => this.loadApps(), 10_000) - - // Open bio full-screen on load - const bio = this.installedApps.find(a => a.id === 'bio') - if (bio) this.openApp(bio) + await this.checkAuth() + + if (this.authed) { + await this.loadApps() + // Refresh running status every 10s + setInterval(() => this.loadApps(), 10_000) + // Open bio full-screen on load + const bio = this.installedApps.find(a => a.id === 'bio') + if (bio) this.openApp(bio) + } } })) diff --git a/ui/styles/canvas.css b/ui/styles/canvas.css index d730ff5..58782b8 100644 --- a/ui/styles/canvas.css +++ b/ui/styles/canvas.css @@ -1,4 +1,5 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +[x-cloak] { display: none !important; } html, body { width: 100%; diff --git a/ui/styles/login.css b/ui/styles/login.css new file mode 100644 index 0000000..1afd86c --- /dev/null +++ b/ui/styles/login.css @@ -0,0 +1,83 @@ +#login-overlay { + position: fixed; + inset: 0; + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); +} + +.login-card { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 20px; + padding: 40px 48px; + width: 360px; + display: flex; + flex-direction: column; + gap: 20px; + color: #fff; +} + +.login-card h1 { + font-size: 1.4rem; + font-weight: 600; + text-align: center; + letter-spacing: -0.02em; +} + +.login-card input { + width: 100%; + padding: 12px 16px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.08); + color: #fff; + font-size: 0.95rem; + outline: none; + transition: border-color 0.15s; +} + +.login-card input::placeholder { + color: rgba(255, 255, 255, 0.4); +} + +.login-card input:focus { + border-color: rgba(255, 255, 255, 0.55); +} + +.login-card button { + padding: 12px; + border-radius: 10px; + border: none; + background: rgba(255, 255, 255, 0.9); + color: #111; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} + +.login-card button:hover { + background: #fff; +} + +.login-error { + font-size: 0.85rem; + color: #ff6b6b; + text-align: center; + min-height: 1.2em; +} + +#dock-user { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.7); + display: flex; + align-items: center; + padding: 0 8px; + cursor: default; + user-select: none; +} -- 2.43.0