Innebygd innlogging (AUTH_MODE=app) med neon-login og proxy-bypass
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 <noreply@anthropic.com>
This commit is contained in:
parent
7287f33e47
commit
f6268440d9
9 changed files with 711 additions and 42 deletions
|
|
@ -36,18 +36,21 @@ Nytt mot PHP-versjonen:
|
||||||
|
|
||||||
```
|
```
|
||||||
netops-todo-node/
|
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,
|
├── lib/store.js Datalager: normalisering, validering, aktivitetslogg,
|
||||||
│ atomisk JSON-persistens. Kilden til sannhet for skjema.
|
│ atomisk JSON-persistens. Kilden til sannhet for skjema.
|
||||||
|
├── lib/auth.js Innebygd auth: scrypt-passord, signert sesjons-token.
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── index.html Markup. Ingen templating — app.js fyller alt.
|
│ ├── index.html Markup. Ingen templating — app.js fyller alt.
|
||||||
│ ├── app.js All frontend-logikk (vanilla JS, ingen rammeverk).
|
│ ├── 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.
|
│ └── style.css Mørkt tema, samme visuelle identitet som PHP-versjonen.
|
||||||
├── seed.json 16 eksempel-oppgaver.
|
├── seed.json 16 eksempel-oppgaver.
|
||||||
├── setup-debian13.sh Engangs-oppsett på Debian 13 LXC (apt + npm ci + systemd).
|
├── 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.
|
├── test/api.test.js Integrasjonstester (node:test) — REST + WS ende-til-ende.
|
||||||
├── data/ Opprettes ved kjøring: tasks.json, activity.json,
|
├── 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.
|
└── README.md Installasjon, proxy-oppsett, API-referanse.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -57,9 +60,23 @@ netops-todo-node/
|
||||||
brukes kun til push: endringer, presence, editing-signaler.
|
brukes kun til push: endringer, presence, editing-signaler.
|
||||||
- Én node-prosess eier datakatalogen. Lagring i minnet, skriv til disk er
|
- Én node-prosess eier datakatalogen. Lagring i minnet, skriv til disk er
|
||||||
atomisk (tmp + rename) og serialisert gjennom en promise-kjede i `store.js`.
|
atomisk (tmp + rename) og serialisert gjennom en promise-kjede i `store.js`.
|
||||||
- Auth ligger utenfor appen: `X-Remote-User`-header > Basic Auth > cookie
|
- **To auth-modi** (`AUTH_MODE`, lest per `createServer`-instans så det er
|
||||||
(`netops_user`, kan skrus av med `DISABLE_COOKIE_AUTH=1`) > `anon`.
|
testbart):
|
||||||
Samme oppslag for HTTP og WS-upgrade (`userFromRequest` i server.js).
|
- `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)
|
### 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
|
cookie-auth-modus. Bak proxy-auth gjelder samme vurdering som før
|
||||||
(intern bruk OK; offentlig nett → legg på token).
|
(intern bruk OK; offentlig nett → legg på token).
|
||||||
- `express.json({ limit: '2mb' })` begrenser payload (import av store lister).
|
- `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
|
## Vanlige utvidelser
|
||||||
|
|
||||||
- **Nytt felt per oppgave:** input i modal (index.html) → `saveForm()` og
|
- **Nytt felt per oppgave:** input i modal (index.html) → `saveForm()` og
|
||||||
`parseQuickAdd()` om ønskelig (app.js) → `normalizeTask()` (store.js) →
|
`parseQuickAdd()` om ønskelig (app.js) → `normalizeTask()` (store.js) →
|
||||||
`diffDetail()` for aktivitetsloggen → `renderCard()`/`renderTable()`.
|
`diffDetail()` for aktivitetsloggen → `renderCard()`/`renderTable()`.
|
||||||
- **Ny kategori:** `CATEGORIES`/`CAT_COLORS` i app.js + `VALID_CATEGORIES`
|
- **Ny kategori:** egendefinerte legges til i UI-et («+ Ny kategori …») og
|
||||||
i store.js.
|
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` —
|
- **SQLite:** bytt persistens i `Store` (load/_persist) mot `node:sqlite` —
|
||||||
resten av appen er uberørt siden alt går gjennom Store-metodene.
|
resten av appen er uberørt siden alt går gjennom Store-metodene.
|
||||||
- **Varsler ved frist:** server har allerede deadline per task; en
|
- **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
|
## Testing-status
|
||||||
|
|
||||||
- `npm test`: 26 integrasjonstester (node:test), booter ekte server på
|
- `npm test`: 34 integrasjonstester (node:test), booter ekte servere på
|
||||||
tilfeldig port med temp-datakatalog. Dekker normalisering, versjonskonflikt,
|
tilfeldig port med temp-datakatalog (én i proxy-modus, én i app-modus). Dekker
|
||||||
kommentar-autorisasjon, statuskommentar-på-update, dagsplan (CRUD, per-bruker,
|
normalisering, versjonskonflikt, kommentar-autorisasjon, statuskommentar,
|
||||||
validering, scrub ved sletting, dato-fallback, navn, flytt/merge, deling +
|
dagsplan (CRUD, per-bruker, validering, scrub, dato-fallback, navn, flytt/merge,
|
||||||
tilgang, planIndex per viewer), egendefinerte kategorier, aktivitetslogg,
|
deling + tilgang, planIndex per viewer), egendefinerte kategorier, aktivitetslogg,
|
||||||
WS-broadcast (delt vs. privat plan), persistens, cookie-auth. Alle grønne per
|
WS-broadcast (delt vs. privat plan), persistens, cookie-auth, og app-auth
|
||||||
2026-06-15 (Node 25).
|
(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
|
- Manuell røyk-test over HTTP: kategori-opprettelse (auto-id + farge), navngitt
|
||||||
delt plan, annen brukers tilgang (delt 200 / privat 403), flytt med bevart
|
delt plan, annen brukers tilgang (delt 200 / privat 403), flytt med bevart
|
||||||
navn+delingsstatus, og planIndex verifisert.
|
navn+delingsstatus, og planIndex verifisert.
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,20 @@ npm test
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PORT` | `3000` | HTTP-port |
|
| `PORT` | `3000` | HTTP-port |
|
||||||
| `HOST` | `0.0.0.0` | Lytteadresse |
|
| `HOST` | `0.0.0.0` | Lytteadresse |
|
||||||
| `DATA_DIR` | `./data` | Katalog for tasks.json / activity.json / users.json |
|
| `DATA_DIR` | `./data` | Datakatalog (tasks/activity/users/plans/categories/auth) |
|
||||||
| `DISABLE_COOKIE_AUTH` | (av) | Sett til `1` for å skru av selvvalgt navn via cookie — da gjelder kun proxy-headere |
|
| `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
|
## Autentisering
|
||||||
|
|
||||||
Påloggingslogikk er bevisst holdt utenfor appen. Brukernavn hentes fra,
|
To modi, styrt av `AUTH_MODE`.
|
||||||
i prioritert rekkefølge:
|
|
||||||
|
### `proxy` (default) — auth utenfor appen
|
||||||
|
|
||||||
|
Brukernavn hentes, i prioritert rekkefølge:
|
||||||
|
|
||||||
1. `X-Remote-User`-header — satt av reverse proxy (oauth2-proxy, Authelia, …)
|
1. `X-Remote-User`-header — satt av reverse proxy (oauth2-proxy, Authelia, …)
|
||||||
2. `Authorization: Basic` — Basic Auth terminert i proxy, brukernavnet gjenbrukes
|
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.
|
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)
|
## Rask installasjon på Debian 13 (LXC/VM)
|
||||||
|
|
||||||
```bash
|
```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
|
- *Block Common Exploits:* valgfritt, fungerer fint sammen med appen
|
||||||
2. **SSL-fanen:** *Request a new SSL Certificate* (Let's Encrypt) + *Force SSL*.
|
2. **SSL-fanen:** *Request a new SSL Certificate* (Let's Encrypt) + *Force SSL*.
|
||||||
Frontend bytter selv til `wss:` når siden er https.
|
Frontend bytter selv til `wss:` når siden er https.
|
||||||
3. **Auth (valgfritt):** *Access Lists* → lag liste med brukere (Basic Auth) og
|
3. **Auth — to alternativer:**
|
||||||
knytt den til proxy-hosten. NPM/nginx sender `Authorization`-headeren videre,
|
- *Innebygd app-innlogging (anbefalt — slipper repeterte Basic Auth-prompts):*
|
||||||
og appen bruker brukernavnet derfra — da får hver kollega riktig attribusjon.
|
kjør tjenesten med `AUTH_MODE=app`. Bruk gjerne NPM Access List midlertidig
|
||||||
Sett i så fall `DISABLE_COOKIE_AUTH=1` på tjenesten. Uten access list velger
|
for å bootstrappe (se «app»-seksjonen over), og fjern den når alle har satt
|
||||||
folk navn selv i UI-et (cookie).
|
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:**
|
**nginx:**
|
||||||
|
|
||||||
|
|
@ -122,7 +156,9 @@ Environment=PORT=3000
|
||||||
# container/host: fjern linjen (lytt på alt) og brannmur port 3000 til kun proxyen.
|
# container/host: fjern linjen (lytt på alt) og brannmur port 3000 til kun proxyen.
|
||||||
Environment=HOST=127.0.0.1
|
Environment=HOST=127.0.0.1
|
||||||
Environment=DATA_DIR=/var/lib/netops-todo
|
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
|
Restart=on-failure
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|
@ -166,7 +202,11 @@ optimistisk låsing — send med ved update, få 409 hvis noen andre har lagret.
|
||||||
|
|
||||||
| Metode | Endepunkt | Beskrivelse |
|
| 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) |
|
| 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) |
|
| PUT | `/api/tasks/:id` | Oppdater (delvis OK; send `v` for konfliktsjekk; valgfri `comment` festes som status-kommentar) |
|
||||||
| DELETE | `/api/tasks/:id` | Slett |
|
| DELETE | `/api/tasks/:id` | Slett |
|
||||||
|
|
|
||||||
109
netops-todo-node/lib/auth.js
Normal file
109
netops-todo-node/lib/auth.js
Normal file
|
|
@ -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: { "<user>": { 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 → "<b64url(user)>.<exp>.<hmac>"
|
||||||
|
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 };
|
||||||
|
|
@ -48,6 +48,7 @@ function catName(id) { return (catById(id) || {}).name || id; }
|
||||||
const state = {
|
const state = {
|
||||||
user: 'anon',
|
user: 'anon',
|
||||||
canSetName: false,
|
canSetName: false,
|
||||||
|
auth: { mode: 'proxy', via: null, hasPassword: false },
|
||||||
tasks: [],
|
tasks: [],
|
||||||
activity: [],
|
activity: [],
|
||||||
knownUsers: [],
|
knownUsers: [],
|
||||||
|
|
@ -111,6 +112,8 @@ async function api(method, path, body = null) {
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
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}`);
|
const err = new Error(data.error || `HTTP ${res.status}`);
|
||||||
err.status = res.status;
|
err.status = res.status;
|
||||||
err.data = data;
|
err.data = data;
|
||||||
|
|
@ -301,6 +304,7 @@ async function refreshState() {
|
||||||
const data = await api('GET', '/api/state');
|
const data = await api('GET', '/api/state');
|
||||||
state.user = data.user;
|
state.user = data.user;
|
||||||
state.canSetName = !!data.canSetName;
|
state.canSetName = !!data.canSetName;
|
||||||
|
if (data.auth) state.auth = data.auth;
|
||||||
state.tasks = data.tasks || [];
|
state.tasks = data.tasks || [];
|
||||||
state.activity = data.activity || [];
|
state.activity = data.activity || [];
|
||||||
state.knownUsers = data.known_users || [];
|
state.knownUsers = data.known_users || [];
|
||||||
|
|
@ -439,7 +443,9 @@ function renderAll() {
|
||||||
|
|
||||||
function renderHeader() {
|
function renderHeader() {
|
||||||
$('user-chip').textContent = state.user;
|
$('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' : '');
|
$('anon-banner').className = 'anon-banner' + (state.user === 'anon' && state.canSetName ? ' show' : '');
|
||||||
const others = state.online.filter(u => u !== state.user);
|
const others = state.online.filter(u => u !== state.user);
|
||||||
$('online-row').innerHTML = others.length
|
$('online-row').innerHTML = others.length
|
||||||
|
|
@ -1388,6 +1394,52 @@ async function setName() {
|
||||||
} catch (e) { toast(e.message, { kind: 'err' }); }
|
} 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 ----------
|
// ---------- Import / eksport / seed ----------
|
||||||
function exportJSON() {
|
function exportJSON() {
|
||||||
const blob = new Blob([JSON.stringify(state.tasks, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(state.tasks, null, 2)], { type: 'application/json' });
|
||||||
|
|
@ -1488,7 +1540,18 @@ function bindUI() {
|
||||||
});
|
});
|
||||||
$('seedBtn').addEventListener('click', seedSamples);
|
$('seedBtn').addEventListener('click', seedSamples);
|
||||||
$('setNameBtn').addEventListener('click', setName);
|
$('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)
|
// Sortering (tabell)
|
||||||
document.querySelectorAll('thead th[data-sort]').forEach(th => {
|
document.querySelectorAll('thead th[data-sort]').forEach(th => {
|
||||||
|
|
@ -1510,6 +1573,7 @@ function bindUI() {
|
||||||
const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if ($('status-modal').classList.contains('open')) { resolveStatus(null); return; }
|
if ($('status-modal').classList.contains('open')) { resolveStatus(null); return; }
|
||||||
|
if ($('account-modal').classList.contains('open')) { closeAccount(); return; }
|
||||||
closeModal(); $('drawer').classList.remove('open'); return;
|
closeModal(); $('drawer').classList.remove('open'); return;
|
||||||
}
|
}
|
||||||
if (typing) return;
|
if (typing) return;
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,36 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="account-modal">
|
||||||
|
<div class="modal" style="max-width:440px">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Konto</h2>
|
||||||
|
<button class="icon-btn" id="account-close" style="font-size:18px">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-warning" id="account-status"></div>
|
||||||
|
<div class="modal-body" style="grid-template-columns:1fr">
|
||||||
|
<div class="form-field full" id="account-current-field" style="display:none">
|
||||||
|
<label>Nåværende passord</label>
|
||||||
|
<input type="password" id="account-current" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-field full">
|
||||||
|
<label id="account-pw-label">Nytt passord</label>
|
||||||
|
<input type="password" id="account-newpw" autocomplete="new-password" placeholder="minst 6 tegn">
|
||||||
|
</div>
|
||||||
|
<div class="form-field full">
|
||||||
|
<label>Bekreft passord</label>
|
||||||
|
<input type="password" id="account-newpw2" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="danger" id="account-logout">Logg ut</button>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button id="account-cancel">Avbryt</button>
|
||||||
|
<button class="primary" id="account-save">Lagre passord</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toast-stack" id="toast-stack"></div>
|
<div class="toast-stack" id="toast-stack"></div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|
|
||||||
178
netops-todo-node/public/login.html
Normal file
178
netops-todo-node/public/login.html
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="no">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>NetOps · innlogging</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #06080d;
|
||||||
|
--cyan: #4fe6ff;
|
||||||
|
--magenta: #ff5cf4;
|
||||||
|
--green: #4ade80;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--dim: #8b98a9;
|
||||||
|
--mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
background: var(--bg); color: var(--text); overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 20px;
|
||||||
|
}
|
||||||
|
/* Bevegelig neon-rutenett i bakgrunnen */
|
||||||
|
.grid-bg {
|
||||||
|
position: fixed; inset: -50%; z-index: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(79,230,255,0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,92,244,0.06) 1px, transparent 1px);
|
||||||
|
background-size: 44px 44px;
|
||||||
|
transform: perspective(420px) rotateX(58deg);
|
||||||
|
animation: drift 18s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes drift { from { background-position: 0 0; } to { background-position: 0 440px; } }
|
||||||
|
/* Glød-orbs */
|
||||||
|
.orb { position: fixed; border-radius: 50%; filter: blur(90px); opacity: 0.5; z-index: 0; }
|
||||||
|
.orb.a { width: 380px; height: 380px; background: var(--cyan); top: -120px; left: -100px; animation: float1 12s ease-in-out infinite; }
|
||||||
|
.orb.b { width: 320px; height: 320px; background: var(--magenta); bottom: -120px; right: -80px; animation: float2 14s ease-in-out infinite; }
|
||||||
|
@keyframes float1 { 0%,100% { transform: translate(0,0); } 50% { transform: translate(40px,30px); } }
|
||||||
|
@keyframes float2 { 0%,100% { transform: translate(0,0); } 50% { transform: translate(-30px,-40px); } }
|
||||||
|
/* Scanline-sveip */
|
||||||
|
.scan { position: fixed; left: 0; right: 0; height: 140px; z-index: 1; pointer-events: none;
|
||||||
|
background: linear-gradient(180deg, transparent, rgba(79,230,255,0.06), transparent);
|
||||||
|
animation: sweep 6s linear infinite; }
|
||||||
|
@keyframes sweep { from { top: -140px; } to { top: 100%; } }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative; z-index: 2; width: 100%; max-width: 420px;
|
||||||
|
background: rgba(10,14,22,0.72); backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(79,230,255,0.25); border-radius: 14px; padding: 34px 30px 28px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255,92,244,0.08), 0 24px 70px rgba(0,0,0,0.6), 0 0 60px rgba(79,230,255,0.12);
|
||||||
|
}
|
||||||
|
.prompt { font-family: var(--mono); font-size: 12px; color: var(--cyan); letter-spacing: 1px; margin-bottom: 8px; opacity: 0.85; }
|
||||||
|
.prompt::before { content: "› "; }
|
||||||
|
h1 {
|
||||||
|
font-size: 40px; font-weight: 800; letter-spacing: 1px; line-height: 1;
|
||||||
|
color: #eafcff; text-shadow: 0 0 8px var(--cyan), 0 0 22px rgba(79,230,255,0.7), 0 0 40px rgba(79,230,255,0.4);
|
||||||
|
animation: flicker 4.5s infinite;
|
||||||
|
}
|
||||||
|
h1 .x { color: var(--magenta); text-shadow: 0 0 8px var(--magenta), 0 0 22px rgba(255,92,244,0.7), 0 0 40px rgba(255,92,244,0.4); }
|
||||||
|
@keyframes flicker {
|
||||||
|
0%,18%,22%,25%,53%,57%,100% { opacity: 1; }
|
||||||
|
20%,24%,55% { opacity: 0.72; }
|
||||||
|
}
|
||||||
|
.teaser { color: var(--dim); font-size: 13.5px; line-height: 1.6; margin: 14px 0 22px; }
|
||||||
|
.teaser b { color: var(--text); font-weight: 600; }
|
||||||
|
.ticker { font-family: var(--mono); font-size: 11px; color: var(--green);
|
||||||
|
border-left: 2px solid rgba(74,222,128,0.5); padding-left: 9px; margin: 0 0 24px;
|
||||||
|
min-height: 1.4em; text-shadow: 0 0 8px rgba(74,222,128,0.5); }
|
||||||
|
.ticker .cursor { animation: blink 1s step-end infinite; }
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
form { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
label { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--dim); }
|
||||||
|
input {
|
||||||
|
background: rgba(2,6,12,0.8); color: var(--text); font-size: 15px; font-family: var(--mono);
|
||||||
|
border: 1px solid rgba(79,230,255,0.22); border-radius: 8px; padding: 13px 14px; transition: all 0.18s;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: none; border-color: var(--cyan);
|
||||||
|
box-shadow: 0 0 0 1px var(--cyan), 0 0 18px rgba(79,230,255,0.4);
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
margin-top: 6px; padding: 14px; border-radius: 8px; cursor: pointer; font-size: 15px; font-weight: 700;
|
||||||
|
letter-spacing: 0.5px; color: #02121a; border: none;
|
||||||
|
background: linear-gradient(90deg, var(--cyan), #7fe9ff);
|
||||||
|
box-shadow: 0 0 22px rgba(79,230,255,0.5); transition: all 0.18s;
|
||||||
|
}
|
||||||
|
button:hover { box-shadow: 0 0 34px rgba(79,230,255,0.85); transform: translateY(-1px); }
|
||||||
|
button:disabled { opacity: 0.6; cursor: wait; }
|
||||||
|
.msg { min-height: 1.2em; font-size: 13px; color: var(--magenta); font-family: var(--mono);
|
||||||
|
text-shadow: 0 0 10px rgba(255,92,244,0.5); text-align: center; }
|
||||||
|
.foot { margin-top: 18px; text-align: center; font-family: var(--mono); font-size: 11px; color: #45525f; }
|
||||||
|
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green);
|
||||||
|
box-shadow: 0 0 8px var(--green); margin-right: 5px; animation: pulse 2s infinite; }
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||||
|
@media (max-width: 480px) { h1 { font-size: 32px; } .card { padding: 28px 22px; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="grid-bg"></div>
|
||||||
|
<div class="orb a"></div>
|
||||||
|
<div class="orb b"></div>
|
||||||
|
<div class="scan"></div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="prompt">network-engineer@nms:~$ ssh netops</div>
|
||||||
|
<h1>NetOps<span class="x">//</span></h1>
|
||||||
|
<p class="teaser">
|
||||||
|
Bak denne porten <b>puster nettet</b>. Pakker flyr, rutene konvergerer,
|
||||||
|
og noen P1-er venter på akkurat deg. <b>Logg inn</b> for å se hva som rører seg.
|
||||||
|
</p>
|
||||||
|
<div class="ticker" id="ticker"><span class="cursor">▋</span></div>
|
||||||
|
|
||||||
|
<form id="login-form" autocomplete="on">
|
||||||
|
<div class="field">
|
||||||
|
<label for="user">Brukernavn</label>
|
||||||
|
<input type="text" id="user" name="username" autocomplete="username" autocapitalize="none" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Passord</label>
|
||||||
|
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" id="submit">Koble til ⟶</button>
|
||||||
|
<div class="msg" id="msg"></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="foot"><span class="dot"></span>system online · venter på autentisering</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Teaser-ticker: skriv ut linjer som om noe skjer bak porten.
|
||||||
|
const lines = [
|
||||||
|
'link up · 10GbE · core-sw01 ↔ dist-sw03',
|
||||||
|
'bgp: 4 naboer etablert, 812k prefiks',
|
||||||
|
'ospf: alle adjacencies FULL',
|
||||||
|
'queue: 7 åpne oppgaver venter …',
|
||||||
|
'tilgang kreves for å fortsette_',
|
||||||
|
];
|
||||||
|
const tickerEl = document.getElementById('ticker');
|
||||||
|
let li = 0, ci = 0;
|
||||||
|
function type() {
|
||||||
|
if (li >= lines.length) return;
|
||||||
|
const line = lines[li];
|
||||||
|
ci++;
|
||||||
|
tickerEl.innerHTML = line.slice(0, ci) + '<span class="cursor">▋</span>';
|
||||||
|
if (ci < line.length) { setTimeout(type, 28 + Math.random() * 40); }
|
||||||
|
else { li++; ci = 0; setTimeout(type, 1100); }
|
||||||
|
}
|
||||||
|
setTimeout(type, 500);
|
||||||
|
|
||||||
|
const form = document.getElementById('login-form');
|
||||||
|
const msg = document.getElementById('msg');
|
||||||
|
const btn = document.getElementById('submit');
|
||||||
|
form.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
msg.textContent = '';
|
||||||
|
btn.disabled = true; btn.textContent = 'Autentiserer …';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
user: document.getElementById('user').value.trim(),
|
||||||
|
password: document.getElementById('password').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) { msg.style.color = 'var(--green)'; msg.textContent = '✓ tilgang innvilget'; location.href = '/'; return; }
|
||||||
|
msg.textContent = data.error || 'Innlogging feilet';
|
||||||
|
} catch {
|
||||||
|
msg.textContent = 'Ingen kontakt med server';
|
||||||
|
}
|
||||||
|
btn.disabled = false; btn.textContent = 'Koble til ⟶';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -6,12 +6,14 @@
|
||||||
* Alle mutasjoner går via REST (lett å curl-e og feilsøke); WebSocket brukes
|
* 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.
|
* kun til å pushe endringer, presence og "redigerer nå"-signaler til klientene.
|
||||||
*
|
*
|
||||||
* Brukernavn hentes fra (i denne rekkefølgen):
|
* To auth-modi (AUTH_MODE):
|
||||||
* 1. X-Remote-User-header (satt av reverse proxy / oauth2-proxy o.l.)
|
* proxy (default) — stol på reverse proxy: X-Remote-User > Basic Auth >
|
||||||
* 2. Authorization: Basic (Basic Auth terminert i proxy, brukernavn gjenbrukes)
|
* netops_user-cookie (selvvalgt navn) > 'anon'. Ingen passord.
|
||||||
* 3. netops_user-cookie (selvvalgt navn — for hjemme/dev-bruk;
|
* app — innebygd innlogging: signert sesjons-cookie (lib/auth.js).
|
||||||
* skru av med DISABLE_COOKIE_AUTH=1)
|
* En "trusted bypass" lar en proxy-identitet (X-Remote-User
|
||||||
* 4. 'anon'
|
* 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');
|
const path = require('path');
|
||||||
|
|
@ -20,8 +22,10 @@ const fs = require('fs');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { WebSocketServer } = require('ws');
|
const { WebSocketServer } = require('ws');
|
||||||
const { Store, NotFoundError, ConflictError } = require('./lib/store');
|
const { Store, NotFoundError, ConflictError } = require('./lib/store');
|
||||||
|
const { Auth } = require('./lib/auth');
|
||||||
|
|
||||||
const COOKIE_AUTH_ENABLED = process.env.DISABLE_COOKIE_AUTH !== '1';
|
const COOKIE_AUTH_ENABLED = process.env.DISABLE_COOKIE_AUTH !== '1';
|
||||||
|
const SESSION_COOKIE = 'netops_session';
|
||||||
|
|
||||||
function sanitizeUser(raw) {
|
function sanitizeUser(raw) {
|
||||||
if (typeof raw !== 'string') return '';
|
if (typeof raw !== 'string') return '';
|
||||||
|
|
@ -57,20 +61,62 @@ function userFromRequest(req) {
|
||||||
return 'anon';
|
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);
|
const store = new Store(dataDir);
|
||||||
store.load();
|
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();
|
const app = express();
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
app.use(express.json({ limit: '2mb' }));
|
app.use(express.json({ limit: '2mb' }));
|
||||||
app.use((req, res, next) => {
|
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');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
if (req.path.startsWith('/api/')) res.setHeader('Cache-Control', 'no-store');
|
if (req.path.startsWith('/api/')) res.setHeader('Cache-Control', 'no-store');
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
|
||||||
|
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
|
@ -81,6 +127,7 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) {
|
||||||
|
|
||||||
server.on('upgrade', (req, socket, head) => {
|
server.on('upgrade', (req, socket, head) => {
|
||||||
if (!req.url.startsWith('/ws')) { socket.destroy(); return; }
|
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.handleUpgrade(req, socket, head, ws => {
|
||||||
wss.emit('connection', ws, req);
|
wss.emit('connection', ws, req);
|
||||||
});
|
});
|
||||||
|
|
@ -114,7 +161,7 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
wss.on('connection', (ws, req) => {
|
wss.on('connection', (ws, req) => {
|
||||||
const user = userFromRequest(req);
|
const user = resolveAuth(req).user || 'anon';
|
||||||
clients.set(ws, { user, editing: null, isAlive: true });
|
clients.set(ws, { user, editing: null, isAlive: true });
|
||||||
store.rememberUser(user);
|
store.rememberUser(user);
|
||||||
|
|
||||||
|
|
@ -156,8 +203,65 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) {
|
||||||
// ---------- REST API ----------
|
// ---------- REST API ----------
|
||||||
const todayStr = () => new Date().toISOString().slice(0, 10);
|
const todayStr = () => new Date().toISOString().slice(0, 10);
|
||||||
const validDate = d => typeof d === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d);
|
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('/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) => {
|
app.get('/api/state', (req, res) => {
|
||||||
store.rememberUser(req.user);
|
store.rememberUser(req.user);
|
||||||
|
|
@ -165,7 +269,8 @@ function createServer({ dataDir = path.join(__dirname, 'data') } = {}) {
|
||||||
const plan = store.getPlan(req.user, today);
|
const plan = store.getPlan(req.user, today);
|
||||||
res.json({
|
res.json({
|
||||||
user: req.user,
|
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(),
|
tasks: store.list(),
|
||||||
activity: store.activity,
|
activity: store.activity,
|
||||||
known_users: [...store.knownUsers].sort(),
|
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' });
|
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) {
|
if (require.main === module) {
|
||||||
const port = Number(process.env.PORT) || 3000;
|
const port = Number(process.env.PORT) || 3000;
|
||||||
const host = process.env.HOST || '0.0.0.0';
|
const host = process.env.HOST || '0.0.0.0';
|
||||||
const dataDir = process.env.DATA_DIR || path.join(__dirname, 'data');
|
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, () => {
|
server.listen(port, host, () => {
|
||||||
console.log(`NetOps To-Do kjører på http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`);
|
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)`);
|
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.');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@
|
||||||
# Valgfrie miljøvariabler:
|
# Valgfrie miljøvariabler:
|
||||||
# PORT=3000 lytteport (default 3000)
|
# PORT=3000 lytteport (default 3000)
|
||||||
# APP_DIR=/opt/netops hvor repoet havner ved kloning
|
# 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).
|
# 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=PORT=$PORT
|
||||||
Environment=HOST=0.0.0.0
|
Environment=HOST=0.0.0.0
|
||||||
Environment=DATA_DIR=/var/lib/$SERVICE
|
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}
|
${DISABLE_COOKIE_AUTH:+Environment=DISABLE_COOKIE_AUTH=1}
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -402,3 +402,109 @@ test('cookie-auth: login setter cookie, ugyldig navn avvises', async () => {
|
||||||
});
|
});
|
||||||
assert.equal(res.status, 400);
|
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');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue