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
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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue