From f6268440d9ca4c406559b7fa8abe6414ddde8714 Mon Sep 17 00:00:00 2001 From: Jon Vanvik Date: Mon, 15 Jun 2026 14:27:13 +0200 Subject: [PATCH] Innebygd innlogging (AUTH_MODE=app) med neon-login og proxy-bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Valgfri app-auth ved siden av proxy-modus (default uendret). Signert sesjons-cookie (scrypt-passord, HMAC, ingen avhengigheter), gating av API + WebSocket, og en selvstendig neon-login (public/login.html) for uautentiserte. Trusted bypass: en proxy-identitet (X-Remote-User / Basic Auth) regnes som innlogget uten app-passord (TRUST_PROXY_HEADER, default på), så man kan bootstrappe: logg inn via NPM Basic Auth → sett app-passord i konto-modalen → skru av Basic Auth. Konto-UI for sett/endre passord + logg ut. AUTH_MODE leses per createServer-instans (testbart). 34 integrasjonstester (8 nye for app-auth), alle grønne. README/CLAUDE.md/setup-skript oppdatert. Co-Authored-By: Claude Fable 5 --- netops-todo-node/CLAUDE.md | 53 ++++++--- netops-todo-node/README.md | 62 ++++++++-- netops-todo-node/lib/auth.js | 109 ++++++++++++++++++ netops-todo-node/public/app.js | 68 ++++++++++- netops-todo-node/public/index.html | 30 +++++ netops-todo-node/public/login.html | 178 +++++++++++++++++++++++++++++ netops-todo-node/server.js | 141 ++++++++++++++++++++--- netops-todo-node/setup-debian13.sh | 6 +- netops-todo-node/test/api.test.js | 106 +++++++++++++++++ 9 files changed, 711 insertions(+), 42 deletions(-) create mode 100644 netops-todo-node/lib/auth.js create mode 100644 netops-todo-node/public/login.html diff --git a/netops-todo-node/CLAUDE.md b/netops-todo-node/CLAUDE.md index 9e02534..7cac4e7 100644 --- a/netops-todo-node/CLAUDE.md +++ b/netops-todo-node/CLAUDE.md @@ -36,18 +36,21 @@ Nytt mot PHP-versjonen: ``` netops-todo-node/ -├── server.js Express + ws. REST-API, WebSocket-hub, auth-oppslag. +├── server.js Express + ws. REST-API, WebSocket-hub, auth-oppslag/gating. ├── lib/store.js Datalager: normalisering, validering, aktivitetslogg, │ atomisk JSON-persistens. Kilden til sannhet for skjema. +├── lib/auth.js Innebygd auth: scrypt-passord, signert sesjons-token. ├── public/ │ ├── index.html Markup. Ingen templating — app.js fyller alt. │ ├── app.js All frontend-logikk (vanilla JS, ingen rammeverk). +│ ├── login.html Selvstendig neon-login (AUTH_MODE=app). Inline CSS/JS. │ └── style.css Mørkt tema, samme visuelle identitet som PHP-versjonen. ├── seed.json 16 eksempel-oppgaver. ├── setup-debian13.sh Engangs-oppsett på Debian 13 LXC (apt + npm ci + systemd). ├── test/api.test.js Integrasjonstester (node:test) — REST + WS ende-til-ende. ├── data/ Opprettes ved kjøring: tasks.json, activity.json, -│ users.json, plans.json, categories.json. +│ users.json, plans.json, categories.json, +│ auth.json (passordhasher), auth.secret (HMAC-nøkkel). └── README.md Installasjon, proxy-oppsett, API-referanse. ``` @@ -57,9 +60,23 @@ netops-todo-node/ brukes kun til push: endringer, presence, editing-signaler. - Én node-prosess eier datakatalogen. Lagring i minnet, skriv til disk er atomisk (tmp + rename) og serialisert gjennom en promise-kjede i `store.js`. -- Auth ligger utenfor appen: `X-Remote-User`-header > Basic Auth > cookie - (`netops_user`, kan skrus av med `DISABLE_COOKIE_AUTH=1`) > `anon`. - Samme oppslag for HTTP og WS-upgrade (`userFromRequest` i server.js). +- **To auth-modi** (`AUTH_MODE`, lest per `createServer`-instans så det er + testbart): + - `proxy` (default): `X-Remote-User` > Basic Auth > `netops_user`-cookie > + `anon`. Ingen guard — alt åpent. Bakoverkompatibelt. + - `app`: innebygd innlogging. `resolveAuth(req)` → sesjons-cookie + (`netops_session`, verifisert i lib/auth.js) ELLER, hvis `TRUST_PROXY_HEADER`, + en proxy-identitet (`proxyUser()` = header/Basic, IKKE navn-cookie) som + "trusted bypass". En guard-middleware 401-er API og serverer `login.html` på + navigasjon når uautentisert. WS-upgrade gates likt. + - Bypass-poenget: bootstrap fra Basic Auth → sett app-passord → skru av Basic + Auth. `POST /api/auth/password` tillater å sette passord når man er + proxy-innlogget; krever nåværende passord kun når man endrer via en app-sesjon. + - `resolveAuth` brukes både i HTTP-middleware og WS (`server.on('upgrade')` + + `wss.on('connection')`), så samme identitet/gating gjelder begge. +- Sesjon: signert cookie `b64url(user).exp.HMAC` (ingen server-side lager). + Hemmelighet fra `AUTH_SECRET` eller persistert `data/auth.secret`. Cookie er + HttpOnly, SameSite=Lax, Secure når `X-Forwarded-Proto: https`. ### WebSocket-protokoll (server → klient) @@ -153,14 +170,21 @@ strenglengde-klamping, brukernavn-regex `[A-Za-z0-9._@-]{1,64}`), pluss: cookie-auth-modus. Bak proxy-auth gjelder samme vurdering som før (intern bruk OK; offentlig nett → legg på token). - `express.json({ limit: '2mb' })` begrenser payload (import av store lister). +- **App-auth (AUTH_MODE=app):** scrypt-hashing + `timingSafeEqual`, signert + HMAC-sesjons-cookie (HttpOnly, SameSite=Lax, Secure over https), passord min. + 6 tegn. Auth-filer (`auth.json`, `auth.secret`) skrives med mode `0600`. + `proxyUser()` (bypass-kilden) stoler IKKE på navn-cookien, kun proxy-satte + headere. Forutsetning for bypass: app-porten er kun nåbar fra proxyen — sett + `TRUST_PROXY_HEADER=0` for å fjerne bypass etter bootstrapping. ## Vanlige utvidelser - **Nytt felt per oppgave:** input i modal (index.html) → `saveForm()` og `parseQuickAdd()` om ønskelig (app.js) → `normalizeTask()` (store.js) → `diffDetail()` for aktivitetsloggen → `renderCard()`/`renderTable()`. -- **Ny kategori:** `CATEGORIES`/`CAT_COLORS` i app.js + `VALID_CATEGORIES` - i store.js. +- **Ny kategori:** egendefinerte legges til i UI-et («+ Ny kategori …») og + lagres i categories.json. Nye *innebygde* defaults endres i `DEFAULT_CATEGORIES` + (både store.js og app.js har lista; serveren er kilden, app.js har en fallback). - **SQLite:** bytt persistens i `Store` (load/_persist) mot `node:sqlite` — resten av appen er uberørt siden alt går gjennom Store-metodene. - **Varsler ved frist:** server har allerede deadline per task; en @@ -169,13 +193,14 @@ strenglengde-klamping, brukernavn-regex `[A-Za-z0-9._@-]{1,64}`), pluss: ## Testing-status -- `npm test`: 26 integrasjonstester (node:test), booter ekte server på - tilfeldig port med temp-datakatalog. Dekker normalisering, versjonskonflikt, - kommentar-autorisasjon, statuskommentar-på-update, dagsplan (CRUD, per-bruker, - validering, scrub ved sletting, dato-fallback, navn, flytt/merge, deling + - tilgang, planIndex per viewer), egendefinerte kategorier, aktivitetslogg, - WS-broadcast (delt vs. privat plan), persistens, cookie-auth. Alle grønne per - 2026-06-15 (Node 25). +- `npm test`: 34 integrasjonstester (node:test), booter ekte servere på + tilfeldig port med temp-datakatalog (én i proxy-modus, én i app-modus). Dekker + normalisering, versjonskonflikt, kommentar-autorisasjon, statuskommentar, + dagsplan (CRUD, per-bruker, validering, scrub, dato-fallback, navn, flytt/merge, + deling + tilgang, planIndex per viewer), egendefinerte kategorier, aktivitetslogg, + WS-broadcast (delt vs. privat plan), persistens, cookie-auth, og app-auth + (401-guard, login-side på navigasjon, proxy-bypass, passord-bootstrap, sesjon, + feil passord, TRUST_PROXY_HEADER=0). Alle grønne per 2026-06-15 (Node 25). - Manuell røyk-test over HTTP: kategori-opprettelse (auto-id + farge), navngitt delt plan, annen brukers tilgang (delt 200 / privat 403), flytt med bevart navn+delingsstatus, og planIndex verifisert. diff --git a/netops-todo-node/README.md b/netops-todo-node/README.md index 888ef1f..4460e0e 100644 --- a/netops-todo-node/README.md +++ b/netops-todo-node/README.md @@ -29,13 +29,20 @@ npm test |---|---|---| | `PORT` | `3000` | HTTP-port | | `HOST` | `0.0.0.0` | Lytteadresse | -| `DATA_DIR` | `./data` | Katalog for tasks.json / activity.json / users.json | -| `DISABLE_COOKIE_AUTH` | (av) | Sett til `1` for å skru av selvvalgt navn via cookie — da gjelder kun proxy-headere | +| `DATA_DIR` | `./data` | Datakatalog (tasks/activity/users/plans/categories/auth) | +| `AUTH_MODE` | `proxy` | `proxy` = stol på proxy-header/Basic Auth. `app` = innebygd innlogging (sesjons-cookie + passord) | +| `TRUST_PROXY_HEADER` | `1` | I app-modus: la en proxy-identitet (X-Remote-User / Basic) slippe inn uten app-passord (for bootstrapping). Sett `0` for å slå av | +| `SESSION_TTL_DAYS` | `30` | Levetid på app-sesjons-cookien | +| `AUTH_SECRET` | (auto) | HMAC-nøkkel for sesjons-cookies. Settes ikke den, genereres og lagres i `data/auth.secret` | +| `DISABLE_COOKIE_AUTH` | (av) | Proxy-modus: `1` skrur av selvvalgt navn via cookie — da gjelder kun proxy-headere | ## Autentisering -Påloggingslogikk er bevisst holdt utenfor appen. Brukernavn hentes fra, -i prioritert rekkefølge: +To modi, styrt av `AUTH_MODE`. + +### `proxy` (default) — auth utenfor appen + +Brukernavn hentes, i prioritert rekkefølge: 1. `X-Remote-User`-header — satt av reverse proxy (oauth2-proxy, Authelia, …) 2. `Authorization: Basic` — Basic Auth terminert i proxy, brukernavnet gjenbrukes @@ -44,6 +51,29 @@ i prioritert rekkefølge: For team-bruk bak proxy: sett `DISABLE_COOKIE_AUTH=1` så ingen kan velge navn selv. +### `app` — innebygd innlogging + +Egen neon-login (`/login`) med passord. Sesjon er en signert, langlevd cookie +(`SESSION_TTL_DAYS`, default 30) — ingen repeterte Basic Auth-prompts, og virker +sømløst med WebSocket. Passord hashes med scrypt (Node-kjerne, ingen +avhengigheter), lagret i `data/auth.json`. + +**Trusted bypass / migrering fra Basic Auth:** Når `TRUST_PROXY_HEADER=1` +(default) regnes en proxy-identitet (X-Remote-User eller Basic Auth) som +innlogget, *selv uten app-passord*. Det gir en trygg overgang: + +1. Behold NPM Access List (Basic Auth) og deploy med `AUTH_MODE=app`. +2. Åpne appen (du slipper inn via Basic Auth-bypass), klikk brukernavnet ditt + oppe til høyre → **sett et app-passord**. Gjenta for hver kollega. +3. Fjern NPM Access List. Nå møtes alle av neon-login og logger inn med passordet. + +> Bypass krever at app-porten kun er nåbar fra proxyen (samme antakelse som +> proxy-modus). Vil du være streng, sett `TRUST_PROXY_HEADER=0` etter at alle +> har satt passord — da gjelder kun app-sesjoner. + +Oppsett som tjeneste: legg `Environment=AUTH_MODE=app` i systemd-enheten (se +under), eller kjør skriptet med `AUTH_MODE=app bash setup-debian13.sh`. + ## Rask installasjon på Debian 13 (LXC/VM) ```bash @@ -74,11 +104,15 @@ frontend bruker absolutte stier, så sti-prefiks (`proxy.lan/todo/`) fungerer ik - *Block Common Exploits:* valgfritt, fungerer fint sammen med appen 2. **SSL-fanen:** *Request a new SSL Certificate* (Let's Encrypt) + *Force SSL*. Frontend bytter selv til `wss:` når siden er https. -3. **Auth (valgfritt):** *Access Lists* → lag liste med brukere (Basic Auth) og - knytt den til proxy-hosten. NPM/nginx sender `Authorization`-headeren videre, - og appen bruker brukernavnet derfra — da får hver kollega riktig attribusjon. - Sett i så fall `DISABLE_COOKIE_AUTH=1` på tjenesten. Uten access list velger - folk navn selv i UI-et (cookie). +3. **Auth — to alternativer:** + - *Innebygd app-innlogging (anbefalt — slipper repeterte Basic Auth-prompts):* + kjør tjenesten med `AUTH_MODE=app`. Bruk gjerne NPM Access List midlertidig + for å bootstrappe (se «app»-seksjonen over), og fjern den når alle har satt + passord. Da trenger ikke NPM Access List i det hele tatt. + - *Proxy Basic Auth:* *Access Lists* → lag brukere og knytt til proxy-hosten. + NPM sender `Authorization`-headeren videre og appen bruker brukernavnet + derfra. Sett `DISABLE_COOKIE_AUTH=1`. (Ulempe: nettleseren spør om passord + på nytt ved hver ny økt — derav app-innlogging som alternativ.) **nginx:** @@ -122,7 +156,9 @@ Environment=PORT=3000 # container/host: fjern linjen (lytt på alt) og brannmur port 3000 til kun proxyen. Environment=HOST=127.0.0.1 Environment=DATA_DIR=/var/lib/netops-todo -Environment=DISABLE_COOKIE_AUTH=1 +# Innebygd innlogging (anbefalt). Bytt til proxy-modus ved å fjerne denne +# og evt. sette DISABLE_COOKIE_AUTH=1. +Environment=AUTH_MODE=app Restart=on-failure [Install] @@ -166,7 +202,11 @@ optimistisk låsing — send med ved update, få 409 hvis noen andre har lagret. | Metode | Endepunkt | Beskrivelse | |---|---|---| -| GET | `/api/state` | Bruker, oppgaver, aktivitet, kjente brukere, online, kategorier, dagens plan, planIndex | +| GET | `/api/state` | Bruker, auth-status, oppgaver, aktivitet, kjente brukere, online, kategorier, dagens plan, planIndex | +| GET | `/api/auth/status` | Auth-modus, innlogget?, bruker, via, har passord | +| POST | `/api/auth/login` | App-innlogging `{ user, password }` → sesjons-cookie | +| POST | `/api/auth/logout` | Tøm sesjons-cookie | +| POST | `/api/auth/password` | Sett/endre eget passord `{ password, current? }` | | POST | `/api/tasks` | Opprett oppgave (`title` påkrevd) | | PUT | `/api/tasks/:id` | Oppdater (delvis OK; send `v` for konfliktsjekk; valgfri `comment` festes som status-kommentar) | | DELETE | `/api/tasks/:id` | Slett | diff --git a/netops-todo-node/lib/auth.js b/netops-todo-node/lib/auth.js new file mode 100644 index 0000000..148616c --- /dev/null +++ b/netops-todo-node/lib/auth.js @@ -0,0 +1,109 @@ +'use strict'; +/** + * NetOps To-Do — innebygd autentisering (valgfri, AUTH_MODE=app). + * + * Passord hashes med scrypt (Node-kjerne, ingen avhengigheter). Sesjoner er + * en signert, langlevd cookie: `base64url(user).exp.HMAC-SHA256` — ingen + * server-side sesjonslager nødvendig, og en restart logger ingen ut så lenge + * hemmeligheten overlever (env eller persistert fil). + * + * Brukere lagres i data/auth.json: { "": { password: "scrypt$..", updated } } + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +function hashPassword(password) { + const salt = crypto.randomBytes(16); + const hash = crypto.scryptSync(String(password), salt, 64); + return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`; +} + +function verifyPassword(password, stored) { + if (typeof stored !== 'string') return false; + const [alg, saltHex, hashHex] = stored.split('$'); + if (alg !== 'scrypt' || !saltHex || !hashHex) return false; + let expected, actual; + try { + expected = Buffer.from(hashHex, 'hex'); + actual = crypto.scryptSync(String(password), Buffer.from(saltHex, 'hex'), expected.length); + } catch { return false; } + return expected.length === actual.length && crypto.timingSafeEqual(actual, expected); +} + +class Auth { + constructor(dataDir, { ttlDays = 30, secret = process.env.AUTH_SECRET || '' } = {}) { + this.dataDir = dataDir; + this.usersFile = path.join(dataDir, 'auth.json'); + this.secretFile = path.join(dataDir, 'auth.secret'); + this.ttlMs = Math.max(1, ttlDays) * 86400000; + this.users = {}; + this.secret = secret; + } + + load() { + fs.mkdirSync(this.dataDir, { recursive: true }); + try { + const d = JSON.parse(fs.readFileSync(this.usersFile, 'utf8')); + if (d && typeof d === 'object' && !Array.isArray(d)) this.users = d; + } catch { /* ingen brukere ennå */ } + + // Hemmelighet: env > persistert fil > nygenerert (persistert). + if (!this.secret) { + try { this.secret = fs.readFileSync(this.secretFile, 'utf8').trim(); } catch { /* genereres under */ } + } + if (!this.secret) { + this.secret = crypto.randomBytes(32).toString('hex'); + try { fs.writeFileSync(this.secretFile, this.secret, { mode: 0o600 }); } + catch (e) { console.error('Kunne ikke lagre auth.secret:', e.message); } + } + } + + _save() { + const tmp = this.usersFile + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(this.users, null, 2) + '\n', { mode: 0o600 }); + fs.renameSync(tmp, this.usersFile); + } + + hasPassword(user) { return !!(user && this.users[user] && this.users[user].password); } + anyPassword() { return Object.values(this.users).some(u => u && u.password); } + + setPassword(user, password) { + if (!user) throw new Error('Mangler bruker'); + if (typeof password !== 'string' || password.length < 6) throw new Error('Passord må være minst 6 tegn'); + this.users[user] = { ...(this.users[user] || {}), password: hashPassword(password), updated: new Date().toISOString() }; + this._save(); + } + + verify(user, password) { + const u = this.users[user]; + return !!(u && u.password && verifyPassword(password, u.password)); + } + + // Signert sesjons-token. user → ".." + makeToken(user) { + const payload = `${Buffer.from(String(user)).toString('base64url')}.${Date.now() + this.ttlMs}`; + const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('base64url'); + return `${payload}.${sig}`; + } + + verifyToken(token) { + if (typeof token !== 'string') return null; + const parts = token.split('.'); + if (parts.length !== 3) return null; + const payload = `${parts[0]}.${parts[1]}`; + const expected = crypto.createHmac('sha256', this.secret).update(payload).digest('base64url'); + const a = Buffer.from(parts[2]); + const b = Buffer.from(expected); + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null; + const exp = Number(parts[1]); + if (!exp || Date.now() > exp) return null; + try { return Buffer.from(parts[0], 'base64url').toString('utf8') || null; } + catch { return null; } + } + + ttlSeconds() { return Math.floor(this.ttlMs / 1000); } +} + +module.exports = { Auth, hashPassword, verifyPassword }; diff --git a/netops-todo-node/public/app.js b/netops-todo-node/public/app.js index 5bfc3a5..9034bde 100644 --- a/netops-todo-node/public/app.js +++ b/netops-todo-node/public/app.js @@ -48,6 +48,7 @@ function catName(id) { return (catById(id) || {}).name || id; } const state = { user: 'anon', canSetName: false, + auth: { mode: 'proxy', via: null, hasPassword: false }, tasks: [], activity: [], knownUsers: [], @@ -111,6 +112,8 @@ async function api(method, path, body = null) { } const data = await res.json(); if (!res.ok) { + // Sesjon utløpt / ikke innlogget i app-modus → til login-siden. + if (res.status === 401 && data && data.login) { location.href = '/login'; } const err = new Error(data.error || `HTTP ${res.status}`); err.status = res.status; err.data = data; @@ -301,6 +304,7 @@ async function refreshState() { const data = await api('GET', '/api/state'); state.user = data.user; state.canSetName = !!data.canSetName; + if (data.auth) state.auth = data.auth; state.tasks = data.tasks || []; state.activity = data.activity || []; state.knownUsers = data.known_users || []; @@ -439,7 +443,9 @@ function renderAll() { function renderHeader() { $('user-chip').textContent = state.user; - $('user-chip').className = 'user-chip' + (state.canSetName ? ' clickable' : ''); + const chipClickable = state.canSetName || state.auth.mode === 'app'; + $('user-chip').className = 'user-chip' + (chipClickable ? ' clickable' : ''); + $('user-chip').title = state.auth.mode === 'app' ? 'Konto: passord / logg ut' : 'Pålogget bruker'; $('anon-banner').className = 'anon-banner' + (state.user === 'anon' && state.canSetName ? ' show' : ''); const others = state.online.filter(u => u !== state.user); $('online-row').innerHTML = others.length @@ -1388,6 +1394,52 @@ async function setName() { } catch (e) { toast(e.message, { kind: 'err' }); } } +// ---------- Konto (app-innlogging) ---------- +function openAccount() { + const viaProxy = state.auth.via === 'proxy'; + const has = state.auth.hasPassword; + const status = $('account-status'); + if (viaProxy && !has) { + status.textContent = `Du er innlogget via proxy (${state.user}). Sett et app-passord her, så kan du logge inn uten proxy og skru av Basic Auth.`; + } else if (viaProxy && has) { + status.textContent = `Innlogget via proxy som ${state.user}. Du har allerede et app-passord — du kan endre det her.`; + } else { + status.textContent = `Innlogget som ${state.user} (app-sesjon).`; + } + status.className = 'modal-warning show'; + // Nåværende passord kreves kun når du endrer via en app-sesjon + $('account-current-field').style.display = (has && state.auth.via === 'session') ? '' : 'none'; + $('account-pw-label').textContent = has ? 'Nytt passord' : 'Velg passord'; + $('account-current').value = ''; + $('account-newpw').value = ''; + $('account-newpw2').value = ''; + $('account-modal').classList.add('open'); + setTimeout(() => $('account-newpw').focus(), 50); +} +function closeAccount() { $('account-modal').classList.remove('open'); } + +async function saveAccountPassword() { + const pw = $('account-newpw').value; + const pw2 = $('account-newpw2').value; + if (pw.length < 6) { toast('Passord må være minst 6 tegn', { kind: 'warn' }); return; } + if (pw !== pw2) { toast('Passordene er ikke like', { kind: 'warn' }); return; } + const body = { password: pw }; + if ($('account-current-field').style.display !== 'none') body.current = $('account-current').value; + try { + await api('POST', '/api/auth/password', body); + state.auth.hasPassword = true; + state.auth.via = 'session'; + closeAccount(); + renderHeader(); + toast('Passord lagret — du er nå innlogget med app-sesjon'); + } catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); } +} + +async function logout() { + try { await api('POST', '/api/auth/logout'); } catch { /* uansett til login */ } + location.href = '/login'; +} + // ---------- Import / eksport / seed ---------- function exportJSON() { const blob = new Blob([JSON.stringify(state.tasks, null, 2)], { type: 'application/json' }); @@ -1488,7 +1540,18 @@ function bindUI() { }); $('seedBtn').addEventListener('click', seedSamples); $('setNameBtn').addEventListener('click', setName); - $('user-chip').addEventListener('click', () => { if (state.canSetName) setName(); }); + $('user-chip').addEventListener('click', () => { + if (state.auth.mode === 'app') openAccount(); + else if (state.canSetName) setName(); + }); + + // Konto-modal + $('account-close').addEventListener('click', closeAccount); + $('account-cancel').addEventListener('click', closeAccount); + $('account-save').addEventListener('click', saveAccountPassword); + $('account-logout').addEventListener('click', logout); + $('account-modal').addEventListener('click', e => { if (e.target.id === 'account-modal') closeAccount(); }); + $('account-newpw2').addEventListener('keydown', e => { if (e.key === 'Enter') saveAccountPassword(); }); // Sortering (tabell) document.querySelectorAll('thead th[data-sort]').forEach(th => { @@ -1510,6 +1573,7 @@ function bindUI() { const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; if (e.key === 'Escape') { if ($('status-modal').classList.contains('open')) { resolveStatus(null); return; } + if ($('account-modal').classList.contains('open')) { closeAccount(); return; } closeModal(); $('drawer').classList.remove('open'); return; } if (typing) return; diff --git a/netops-todo-node/public/index.html b/netops-todo-node/public/index.html index 9e7e803..398c08e 100644 --- a/netops-todo-node/public/index.html +++ b/netops-todo-node/public/index.html @@ -326,6 +326,36 @@ + +
diff --git a/netops-todo-node/public/login.html b/netops-todo-node/public/login.html new file mode 100644 index 0000000..1acfdcb --- /dev/null +++ b/netops-todo-node/public/login.html @@ -0,0 +1,178 @@ + + + + + +NetOps · innlogging + + + +
+
+
+
+ +
+
network-engineer@nms:~$ ssh netops
+

NetOps//

+

+ Bak denne porten puster nettet. Pakker flyr, rutene konvergerer, + og noen P1-er venter på akkurat deg. Logg inn for å se hva som rører seg. +

+
+ +
+
+ + +
+
+ + +
+ +
+
+ +
system online · venter på autentisering
+
+ + + + diff --git a/netops-todo-node/server.js b/netops-todo-node/server.js index 9976539..cfb129f 100644 --- a/netops-todo-node/server.js +++ b/netops-todo-node/server.js @@ -6,12 +6,14 @@ * Alle mutasjoner går via REST (lett å curl-e og feilsøke); WebSocket brukes * kun til å pushe endringer, presence og "redigerer nå"-signaler til klientene. * - * Brukernavn hentes fra (i denne rekkefølgen): - * 1. X-Remote-User-header (satt av reverse proxy / oauth2-proxy o.l.) - * 2. Authorization: Basic (Basic Auth terminert i proxy, brukernavn gjenbrukes) - * 3. netops_user-cookie (selvvalgt navn — for hjemme/dev-bruk; - * skru av med DISABLE_COOKIE_AUTH=1) - * 4. 'anon' + * To auth-modi (AUTH_MODE): + * proxy (default) — stol på reverse proxy: X-Remote-User > Basic Auth > + * netops_user-cookie (selvvalgt navn) > 'anon'. Ingen passord. + * app — innebygd innlogging: signert sesjons-cookie (lib/auth.js). + * En "trusted bypass" lar en proxy-identitet (X-Remote-User + * eller Basic Auth) slippe inn uten app-passord, slik at man + * kan bootstrappe: logg inn via proxy → sett app-passord → + * skru av proxy-auth. Styres av TRUST_PROXY_HEADER (default på). */ const path = require('path'); @@ -20,8 +22,10 @@ const fs = require('fs'); const express = require('express'); const { WebSocketServer } = require('ws'); const { Store, NotFoundError, ConflictError } = require('./lib/store'); +const { Auth } = require('./lib/auth'); const COOKIE_AUTH_ENABLED = process.env.DISABLE_COOKIE_AUTH !== '1'; +const SESSION_COOKIE = 'netops_session'; function sanitizeUser(raw) { if (typeof raw !== 'string') return ''; @@ -57,20 +61,62 @@ function userFromRequest(req) { return 'anon'; } -function createServer({ dataDir = path.join(__dirname, 'data') } = {}) { +// Identitet fra proxy alene (header eller Basic Auth) — IKKE selvvalgt navn-cookie. +// Brukes til "trusted bypass" i app-modus. +function proxyUser(req) { + let u = sanitizeUser(req.headers['x-remote-user']); + if (u) return u; + const a = req.headers.authorization || ''; + if (a.startsWith('Basic ')) { + try { + u = sanitizeUser(Buffer.from(a.slice(6), 'base64').toString('utf8').split(':')[0]); + if (u) return u; + } catch { /* ignorer ugyldig base64 */ } + } + return ''; +} + +function createServer({ + dataDir = path.join(__dirname, 'data'), + authMode = process.env.AUTH_MODE === 'app' ? 'app' : 'proxy', + trustProxyHeader = process.env.TRUST_PROXY_HEADER !== '0', + sessionTtlDays = Number(process.env.SESSION_TTL_DAYS) || 30, +} = {}) { const store = new Store(dataDir); store.load(); + const auth = new Auth(dataDir, { ttlDays: sessionTtlDays }); + auth.load(); + + // Løs opp identitet + om requesten er autentisert (gjelder begge auth-modi). + function resolveAuth(req) { + if (authMode !== 'app') return { user: userFromRequest(req), authed: true, via: 'proxy' }; + const sUser = auth.verifyToken(parseCookies(req.headers.cookie)[SESSION_COOKIE]); + if (sUser) return { user: sUser, authed: true, via: 'session' }; + if (trustProxyHeader) { + const pu = proxyUser(req); + if (pu) return { user: pu, authed: true, via: 'proxy' }; + } + return { user: null, authed: false, via: null }; + } + + function sessionCookie(req, token, maxAgeSec) { + const secure = (req.headers['x-forwarded-proto'] === 'https' || req.socket.encrypted) ? '; Secure' : ''; + const age = maxAgeSec != null ? `; Max-Age=${maxAgeSec}` : ''; + return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${secure}${age}`; + } const app = express(); app.disable('x-powered-by'); app.use(express.json({ limit: '2mb' })); app.use((req, res, next) => { - req.user = userFromRequest(req); + const a = resolveAuth(req); + req.user = a.user; + req.authed = a.authed; + req.authVia = a.via; res.setHeader('X-Content-Type-Options', 'nosniff'); if (req.path.startsWith('/api/')) res.setHeader('Cache-Control', 'no-store'); next(); }); - app.use(express.static(path.join(__dirname, 'public'))); const server = http.createServer(app); @@ -81,6 +127,7 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) { server.on('upgrade', (req, socket, head) => { if (!req.url.startsWith('/ws')) { socket.destroy(); return; } + if (!resolveAuth(req).authed) { socket.destroy(); return; } // krev innlogging i app-modus wss.handleUpgrade(req, socket, head, ws => { wss.emit('connection', ws, req); }); @@ -114,7 +161,7 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) { } wss.on('connection', (ws, req) => { - const user = userFromRequest(req); + const user = resolveAuth(req).user || 'anon'; clients.set(ws, { user, editing: null, isAlive: true }); store.rememberUser(user); @@ -156,8 +203,65 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) { // ---------- REST API ---------- const todayStr = () => new Date().toISOString().slice(0, 10); const validDate = d => typeof d === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d); + const LOGIN_PAGE = path.join(__dirname, 'public', 'login.html'); + // ----- Offentlige ruter (krever ikke innlogging) ----- app.get('/healthz', (req, res) => res.json({ ok: true, tasks: store.tasks.length })); + app.get('/login', (req, res) => res.sendFile(LOGIN_PAGE)); + + app.get('/api/auth/status', (req, res) => { + res.json({ + mode: authMode, + authed: req.authed, + user: req.user, + via: req.authVia, + hasPassword: req.authed ? auth.hasPassword(req.user) : false, + }); + }); + + app.post('/api/auth/login', (req, res) => { + if (authMode !== 'app') return res.status(400).json({ error: 'App-innlogging er ikke aktivert' }); + const user = sanitizeUser(req.body && req.body.user); + const password = req.body && req.body.password; + if (!user || !auth.verify(user, password)) return res.status(401).json({ error: 'Feil brukernavn eller passord' }); + res.setHeader('Set-Cookie', sessionCookie(req, auth.makeToken(user), auth.ttlSeconds())); + store.rememberUser(user); + res.json({ user }); + }); + + app.post('/api/auth/logout', (req, res) => { + res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); + res.json({ ok: true }); + }); + + // Sett/endre eget passord. Krever innlogging (sesjon eller proxy-bypass). + // Har du allerede passord og er innlogget via sesjon, kreves nåværende passord. + app.post('/api/auth/password', (req, res) => { + if (authMode !== 'app') return res.status(400).json({ error: 'App-innlogging er ikke aktivert' }); + if (!req.authed) return res.status(401).json({ error: 'Ikke innlogget' }); + const user = req.user; + if (auth.hasPassword(user) && req.authVia === 'session') { + if (!auth.verify(user, req.body && req.body.current)) { + return res.status(403).json({ error: 'Feil nåværende passord' }); + } + } + try { auth.setPassword(user, req.body && req.body.password); } + catch (e) { return res.status(400).json({ error: e.message }); } + // Logg inn via sesjon umiddelbart (så proxy-auth kan skrus av etterpå). + res.setHeader('Set-Cookie', sessionCookie(req, auth.makeToken(user), auth.ttlSeconds())); + res.json({ ok: true, user }); + }); + + // ----- Guard: alt under her krever innlogging (kun i app-modus) ----- + app.use((req, res, next) => { + if (req.authed) return next(); + if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Ikke innlogget', login: true }); + if (req.method === 'GET' && (req.headers.accept || '').includes('text/html')) return res.sendFile(LOGIN_PAGE); + return res.status(401).end(); + }); + + // Statiske filer (innloggede brukere; login.html serveres separat over) + app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/state', (req, res) => { store.rememberUser(req.user); @@ -165,7 +269,8 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) { const plan = store.getPlan(req.user, today); res.json({ user: req.user, - canSetName: COOKIE_AUTH_ENABLED, + canSetName: COOKIE_AUTH_ENABLED && authMode !== 'app', + auth: { mode: authMode, via: req.authVia, hasPassword: auth.hasPassword(req.user) }, tasks: store.list(), activity: store.activity, known_users: [...store.knownUsers].sort(), @@ -303,18 +408,26 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) { res.status(code >= 500 ? 500 : code).json({ error: err.message || 'Ukjent feil' }); }); - return { app, server, store, wss, clients }; + return { app, server, store, auth, wss, clients }; } if (require.main === module) { const port = Number(process.env.PORT) || 3000; const host = process.env.HOST || '0.0.0.0'; const dataDir = process.env.DATA_DIR || path.join(__dirname, 'data'); - const { server, store } = createServer({ dataDir }); + const authMode = process.env.AUTH_MODE === 'app' ? 'app' : 'proxy'; + const { server, store, auth } = createServer({ dataDir }); server.listen(port, host, () => { console.log(`NetOps To-Do kjører på http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`); console.log(`Datakatalog: ${dataDir} (${store.tasks.length} oppgaver)`); - if (!COOKIE_AUTH_ENABLED) console.log('Cookie-auth er deaktivert — bruker kun proxy-headere'); + if (authMode === 'app') { + console.log(`Auth: app-innlogging (innebygd). ${auth.anyPassword() ? '' : 'Ingen passord satt ennå — '}` + + (auth.anyPassword() ? 'Sesjons-cookie aktiv.' : 'logg inn via proxy og sett passord under brukermenyen.')); + if (process.env.TRUST_PROXY_HEADER === '0') console.log(' Proxy-bypass er AV (TRUST_PROXY_HEADER=0).'); + } else { + console.log('Auth: proxy-modus (X-Remote-User / Basic Auth / navn-cookie).'); + if (!COOKIE_AUTH_ENABLED) console.log(' Navn-cookie er deaktivert — kun proxy-headere.'); + } }); } diff --git a/netops-todo-node/setup-debian13.sh b/netops-todo-node/setup-debian13.sh index 9ed5287..793b411 100755 --- a/netops-todo-node/setup-debian13.sh +++ b/netops-todo-node/setup-debian13.sh @@ -13,7 +13,9 @@ # Valgfrie miljøvariabler: # PORT=3000 lytteport (default 3000) # APP_DIR=/opt/netops hvor repoet havner ved kloning -# DISABLE_COOKIE_AUTH=1 skru av selvvalgt navn i UI (når proxyen gjør auth) +# AUTH_MODE=app innebygd innlogging (sesjons-cookie + passord) +# TRUST_PROXY_HEADER=0 slå av proxy-bypass i app-modus +# DISABLE_COOKIE_AUTH=1 proxy-modus: skru av selvvalgt navn i UI # # Kjør skriptet på nytt etter en `git pull` for å oppgradere (idempotent). @@ -88,6 +90,8 @@ ExecStart=$(command -v node) server.js Environment=PORT=$PORT Environment=HOST=0.0.0.0 Environment=DATA_DIR=/var/lib/$SERVICE +${AUTH_MODE:+Environment=AUTH_MODE=$AUTH_MODE} +${TRUST_PROXY_HEADER:+Environment=TRUST_PROXY_HEADER=$TRUST_PROXY_HEADER} ${DISABLE_COOKIE_AUTH:+Environment=DISABLE_COOKIE_AUTH=1} Restart=on-failure diff --git a/netops-todo-node/test/api.test.js b/netops-todo-node/test/api.test.js index 9c12ef0..cb92d83 100644 --- a/netops-todo-node/test/api.test.js +++ b/netops-todo-node/test/api.test.js @@ -402,3 +402,109 @@ test('cookie-auth: login setter cookie, ugyldig navn avvises', async () => { }); assert.equal(res.status, 400); }); + +// ---------- App-modus innlogging (AUTH_MODE=app) ---------- +let appServer, appUrl, appDir; + +function aReq(method, p, body, headers = {}) { + return fetch(appUrl + p, { + method, + headers: { 'Content-Type': 'application/json', ...headers }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} +function cookieFrom(res) { + const sc = res.headers.get('set-cookie') || ''; + const m = sc.match(/netops_session=([^;]*)/); + return m ? `netops_session=${m[1]}` : ''; +} + +before(async () => { + appDir = fs.mkdtempSync(path.join(os.tmpdir(), 'netops-app-')); + ({ server: appServer } = createServer({ dataDir: appDir, authMode: 'app', sessionTtlDays: 7 })); + await new Promise(r => appServer.listen(0, '127.0.0.1', r)); + appUrl = `http://127.0.0.1:${appServer.address().port}`; +}); +after(() => { + appServer.close(); + fs.rmSync(appDir, { recursive: true, force: true }); +}); + +test('app-auth: uten innlogging gir 401 på API med login-flagg', async () => { + const res = await aReq('GET', '/api/state'); + assert.equal(res.status, 401); + assert.equal((await res.json()).login, true); +}); + +test('app-auth: navigasjon uten innlogging serverer login-siden', async () => { + const res = await aReq('GET', '/', undefined, { Accept: 'text/html' }); + assert.equal(res.status, 200); + const html = await res.text(); + assert.match(html, /NetOps/); + assert.match(html, /api\/auth\/login/); // login-skjemaet +}); + +test('app-auth: proxy-header gir trusted bypass', async () => { + const res = await aReq('GET', '/api/state', undefined, { 'X-Remote-User': 'jon' }); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.user, 'jon'); + assert.equal(data.auth.via, 'proxy'); + assert.equal(data.auth.hasPassword, false); +}); + +test('app-auth: bootstrap — sett passord via bypass, logg så inn med passord', async () => { + // Sett passord mens vi er innlogget via proxy + let res = await aReq('POST', '/api/auth/password', { password: 'hemmelig123' }, { 'X-Remote-User': 'jon' }); + assert.equal(res.status, 200); + assert.ok(cookieFrom(res), 'fikk sesjons-cookie ved passord-setting'); + + // Feil passord avvises + res = await aReq('POST', '/api/auth/login', { user: 'jon', password: 'feil' }); + assert.equal(res.status, 401); + + // Riktig passord gir sesjons-cookie (uten proxy-header) + res = await aReq('POST', '/api/auth/login', { user: 'jon', password: 'hemmelig123' }); + assert.equal(res.status, 200); + const cookie = cookieFrom(res); + assert.ok(cookie); + + // Sesjons-cookien gir tilgang uten proxy-header + res = await aReq('GET', '/api/state', undefined, { Cookie: cookie }); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.user, 'jon'); + assert.equal(data.auth.via, 'session'); + assert.equal(data.auth.hasPassword, true); +}); + +test('app-auth: for kort passord avvises', async () => { + const res = await aReq('POST', '/api/auth/password', { password: 'kort' }, { 'X-Remote-User': 'kari' }); + assert.equal(res.status, 400); +}); + +test('app-auth: logout tømmer cookien', async () => { + const res = await aReq('POST', '/api/auth/logout'); + assert.match(res.headers.get('set-cookie') || '', /netops_session=;|Max-Age=0/); +}); + +test('app-auth: TRUST_PROXY_HEADER=false ignorerer proxy-header', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'netops-app2-')); + const { server } = createServer({ dataDir: dir, authMode: 'app', trustProxyHeader: false }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + const url = `http://127.0.0.1:${server.address().port}`; + try { + const res = await fetch(url + '/api/state', { headers: { 'X-Remote-User': 'jon' } }); + assert.equal(res.status, 401); // bypass av + } finally { + server.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('proxy-modus (default): ingen guard, alt åpent', async () => { + // baseUrl-serveren kjører i proxy-modus — bekreft at /api/state er åpent uten cookie + const res = await fetch(baseUrl + '/api/state', { headers: { 'X-Remote-User': 'whoever' } }); + assert.equal(res.status, 200); + assert.equal((await res.json()).auth.mode, 'proxy'); +});