]> Repos - crimata-os/commitdiff
Add multi-user auth with sessions and login UI
authorAndrew Gundersen <adgundersen@gmail.com>
Fri, 27 Feb 2026 16:33:07 +0000 (11:33 -0500)
committerAndrew Gundersen <adgundersen@gmail.com>
Fri, 27 Feb 2026 16:33:07 +0000 (11:33 -0500)
- 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 <noreply@anthropic.com>
auth/Makefile
auth/src/main.c
auth/src/session.c [new file with mode: 0644]
auth/src/session.h [new file with mode: 0644]
auth/src/user.c [new file with mode: 0644]
auth/src/user.h [new file with mode: 0644]
ui/index.html
ui/js/os.js
ui/styles/canvas.css
ui/styles/login.css [new file with mode: 0644]

index 9cc64b9ff6bc493e717bc2c7b88256b446d7aca1..bc22085502fda2b651b7d3b6fcf7997d0d64f631 100644 (file)
@@ -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
index 5476a1c2c24288587ad1de26186fedcad3bd8828..c6b9325088ba6258d33faeb0f61ce0091d2d5d09 100644 (file)
@@ -3,6 +3,8 @@
 #include <string.h>
 #include <microhttpd.h>
 #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 <token> → { 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 <token> */
+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 (file)
index 0000000..32c8c53
--- /dev/null
@@ -0,0 +1,93 @@
+#include "session.h"
+#include <stdlib.h>
+#include <string.h>
+#include <stdio.h>
+#include <time.h>
+#include <pthread.h>
+
+#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 (file)
index 0000000..f363773
--- /dev/null
@@ -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 (file)
index 0000000..73dc4ea
--- /dev/null
@@ -0,0 +1,79 @@
+#include "user.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+
+/*
+ * 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 <username> */
+    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 (file)
index 0000000..4296006
--- /dev/null
@@ -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
index 432482b925d1a537ee8108e7416bedadaa32ece3..526c231ae9957a47ec8fc191ac45eed6e089dfa2 100644 (file)
@@ -8,10 +8,35 @@
   <link rel="stylesheet" href="styles/window.css" />
   <link rel="stylesheet" href="styles/dock.css" />
   <link rel="stylesheet" href="styles/cursor.css" />
+  <link rel="stylesheet" href="styles/login.css" />
   <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
 </head>
 <body x-data="os" @keydown.window="handleKeydown">
 
+  <!-- Login overlay (shown until authenticated) -->
+  <div id="login-overlay" x-show="!authed" x-cloak>
+    <div class="login-card">
+      <h1>Crimata</h1>
+      <input
+        type="text"
+        x-model="loginUsername"
+        placeholder="Username"
+        @keydown.enter="$refs.loginPassword.focus()"
+        autocomplete="username"
+      />
+      <input
+        type="password"
+        x-model="loginPassword"
+        placeholder="Password"
+        @keydown.enter="submitLogin"
+        x-ref="loginPassword"
+        autocomplete="current-password"
+      />
+      <button @click="submitLogin">Sign in</button>
+      <div class="login-error" x-text="loginError"></div>
+    </div>
+  </div>
+
   <!-- Infinite canvas -->
   <div id="canvas"
     @mousedown="startPan"
@@ -60,6 +85,7 @@
         <span class="dock-dot" x-show="app.running"></span>
       </div>
     </template>
+    <div id="dock-user" x-show="currentUser" x-text="currentUser"></div>
   </div>
 
   <script src="js/os.js"></script>
index 04cc59f0e0105d2fae3d0d3441882ec8f2cf5f47..cbbe10422dd03d67de996d5f4ffb2ae1970a7bf0 100644 (file)
@@ -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)
+      }
     }
 
   }))
index d730ff5354a37c4c0d349d5bafeb2df3f7b85e46..58782b854ee738b3134361370d4eea338f731778 100644 (file)
@@ -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 (file)
index 0000000..1afd86c
--- /dev/null
@@ -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;
+}