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,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)
}
}
}))