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:
Jon Vanvik 2026-06-15 14:27:13 +02:00
parent 7287f33e47
commit f6268440d9
9 changed files with 711 additions and 42 deletions

View file

@ -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 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 ).
*/
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.');
}
});
}