'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å */ } // Migrering: eksisterende brukere (med passord) blir admin; alle får epoke. let changed = false; for (const u of Object.values(this.users)) { if (!u || typeof u !== 'object') continue; if (u.password && u.role !== 'admin' && u.role !== 'standard') { u.role = 'admin'; changed = true; } if (typeof u.epoch !== 'number') { u.epoch = 1; changed = true; } } if (changed) { try { this._save(); } catch { /* ignorer */ } } // 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); } exists(user) { return !!(user && this.users[user]); } role(user) { return (this.users[user] && this.users[user].role) || null; } isAdmin(user) { return this.role(user) === 'admin'; } epochOf(user) { return (this.users[user] && this.users[user].epoch) || 1; } adminCount() { return Object.values(this.users).filter(u => u && u.role === 'admin').length; } // role: 'admin' | 'standard'. Udefinert → behold eksisterende, ellers 'standard'. // Hver passord-setting bumper epoke, så alle gamle tokens for brukeren dør. setPassword(user, password, role) { if (!user) throw new Error('Mangler bruker'); if (typeof password !== 'string' || password.length < 6) throw new Error('Passord må være minst 6 tegn'); const ex = this.users[user] || {}; this.users[user] = { ...ex, password: hashPassword(password), role: role || ex.role || 'standard', epoch: (ex.epoch || 0) + 1, updated: new Date().toISOString(), }; this._save(); return this.users[user]; } // Tilbakekall alle aktive sesjoner for brukeren (logg ut overalt). bumpEpoch(user) { if (!this.users[user]) return; this.users[user].epoch = (this.users[user].epoch || 0) + 1; this._save(); } setRole(user, role) { if (!this.users[user]) throw new Error('Ukjent bruker'); if (role !== 'admin' && role !== 'standard') throw new Error('Ugyldig rolle'); if (role !== 'admin' && this.users[user].role === 'admin' && this.adminCount() <= 1) { throw new Error('Kan ikke fjerne den siste administratoren'); } this.users[user].role = role; this._save(); } createUser(user, password, role = 'standard') { if (this.users[user]) throw new Error('Brukeren finnes allerede'); return this.setPassword(user, password, role === 'admin' ? 'admin' : 'standard'); } deleteUser(user) { if (!this.users[user]) throw new Error('Ukjent bruker'); if (this.users[user].role === 'admin' && this.adminCount() <= 1) { throw new Error('Kan ikke slette den siste administratoren'); } delete this.users[user]; this._save(); } listUsers() { return Object.entries(this.users) .map(([user, u]) => ({ user, role: u.role || 'standard', hasPassword: !!u.password, updated: u.updated || null })) .sort((a, b) => a.user.localeCompare(b.user)); } 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}.${this.epochOf(user)}`; 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 !== 4) return null; // gamle 3-delte tokens er ugyldige etter herding const payload = `${parts[0]}.${parts[1]}.${parts[2]}`; const expected = crypto.createHmac('sha256', this.secret).update(payload).digest('base64url'); const a = Buffer.from(parts[3]); const b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null; if (!Number(parts[1]) || Date.now() > Number(parts[1])) return null; let user; try { user = Buffer.from(parts[0], 'base64url').toString('utf8'); } catch { return null; } if (!user || !this.users[user]) return null; // konto slettet → token dør if (Number(parts[2]) !== this.epochOf(user)) return null; // epoke bumpet → token dør return user; } ttlSeconds() { return Math.floor(this.ttlMs / 1000); } } module.exports = { Auth, hashPassword, verifyPassword };