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 <noreply@anthropic.com>
This commit is contained in:
Andrew Gundersen 2026-02-27 11:33:07 -05:00
commit 44f3192b78
10 changed files with 470 additions and 26 deletions

View 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

View 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\"}");
}
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\"}");
}
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\"}");

93
auth/src/session.c Normal file
View file

@ -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);
}

13
auth/src/session.h Normal file
View file

@ -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

79
auth/src/user.c Normal file
View file

@ -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;
}

10
auth/src/user.h Normal file
View file

@ -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

View 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>

View 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()
await this.checkAuth()
// 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)
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)
}
}
}))

View file

@ -1,4 +1,5 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
[x-cloak] { display: none !important; }
html, body {
width: 100%;

83
ui/styles/login.css Normal file
View file

@ -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;
}