auth.js
Rama main · réplica de lectura del repositorio original.
'use strict';
// ============================================================
// InfraNet Pro — Auth module
// Gestione utenti, sessioni, middleware ruoli
// ============================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const session = require('express-session');
const rateLimit = require('express-rate-limit');
// Helper della libreria: normalizza l'IP (mascheratura /56 dell'IPv6) per una
// keyGenerator personalizzata — express-rate-limit v8 lo richiede quando si compone
// la chiave a partire da req.ip, altrimenti avverte del rischio di bypass IPv6.
const ipKeyGenerator = rateLimit.ipKeyGenerator;
const { atomicWriteFile, readJsonWithBak, PRIVATE_MODE, hardenBackup } = require('./server/projects-store');
const { createGate } = require('./lib/concurrency-gate.js');
// Override via INFRANET_USERS_FILE: tiene gli account su un volume dati persistente
// (es. /data/users.json in Docker); default invariato su bare-metal.
const USERS_FILE = process.env.INFRANET_USERS_FILE || path.join(__dirname, 'users.json');
const BCRYPT_COST = 12;
// Hash fittizio (stesso costo di quelli reali) contro cui confrontare la password
// quando lo username NON esiste: equipara il tempo di risposta del login e nega
// l'enumerazione degli utenti validi via timing. Calcolato una volta al boot.
const _DUMMY_HASH = bcrypt.hashSync('infranet-dummy', BCRYPT_COST);
// ── Quanti confronti bcrypt alla volta ───────────────────────────────────────
// Un confronto a costo 12 vale ~340 ms di CPU (misurati) e il processo e' UNO: `compare`
// cede il controllo fra una fetta e l'altra, ma il lavoro si SOMMA. Cento login nello stesso
// istante sono ~34 secondi di CPU prima di rispondere a chiunque altro — anche al
// monitoraggio, anche a chi e' gia' dentro — e il limite per IP non li vede: conta il budget
// di un indirizzo, non quanti ne arrivano insieme (o da quanti indirizzi). Il cancello li
// tiene a INFRANET_LOGIN_CONCURRENCY alla volta (default 2), ne mette in coda al piu'
// INFRANET_LOGIN_QUEUE (default 8) e oltre risponde 503 SUBITO: rifiutare costa nulla.
// Un picco breve (un ufficio che apre alle 9:00) passa, solo un po' piu' lento.
const _intEnv = (name, def, min) => { const v = parseInt(process.env[name], 10); return Number.isFinite(v) && v >= min ? v : def; };
const _bcryptGate = createGate({
max: _intEnv('INFRANET_LOGIN_CONCURRENCY', 2, 1),
queue: _intEnv('INFRANET_LOGIN_QUEUE', 8, 0),
waitMs: 5000,
});
// Esegue `fn` (lavoro bcrypt) dentro un posto del cancello. `{busy:true}` = cancello saturo.
async function _withBcryptSlot(fn) {
const release = await _bcryptGate.acquire();
if (!release) return { busy: true };
try { return { value: await fn() }; } finally { release(); }
}
function _busy(res) {
res.set('Retry-After', '3');
return res.status(503).json({ ok: false, error: 'Server occupato: troppi accessi contemporanei. Riprova tra qualche secondo.' });
}
// ---- Bypass auth SOLO per sviluppo (off di default) -------------------------
// Attivabile con INFRANET_DEV_NO_AUTH=1: inietta una sessione admin fittizia così
// gli strumenti di preview/anteprima possono vedere la UI senza login.
// SEC-M2 (audit 2026-07-21): fail-closed. Il bypass è onorato SOLO se il server è
// legato al loopback (dev locale) e NON è in produzione. Con un HOST di rete
// (0.0.0.0/::/IP) o NODE_ENV=production il flag viene IGNORATO: un no-auth su
// un'interfaccia raggiungibile darebbe accesso admin a chiunque la contatti.
function _computeDevNoAuth(env) {
env = env || {};
if (env.INFRANET_DEV_NO_AUTH !== '1') return { requested: false, enabled: false, reason: '' };
if (env.NODE_ENV === 'production') return { requested: true, enabled: false, reason: 'NODE_ENV=production' };
const host = String(env.HOST || '127.0.0.1').trim().toLowerCase();
const loopback = host === '127.0.0.1' || host === '::1' || host === 'localhost';
if (!loopback) return { requested: true, enabled: false, reason: `HOST=${env.HOST} non è loopback` };
return { requested: true, enabled: true, reason: '' };
}
const _DEV_NO_AUTH = _computeDevNoAuth(process.env);
const DEV_NO_AUTH = _DEV_NO_AUTH.enabled;
const DEV_USER = { id: 0, username: 'dev', role: 'admin' };
// ---- Reverse-proxy TLS ------------------------------------------------------
// Dietro un reverse-proxy che termina il TLS: INFRANET_TRUST_PROXY=1 marca il
// cookie di sessione come `secure` (inviato solo su HTTPS) e fa fidare Express
// dell'header X-Forwarded-*. OFF di default → su HTTP/localhost nulla cambia.
const TRUST_PROXY = process.env.INFRANET_TRUST_PROXY === '1';
// ---- Secret auto-generato (persiste tra riavvii grazie a .session-secret) --
// Override via INFRANET_SESSION_SECRET_FILE: in Docker (utente non-root, /app di
// sola lettura) il secret auto-generato deve poter essere scritto su un path
// scrivibile e persistente (/data), altrimenti l'avvio senza SESSION_SECRET nell'
// ambiente fallirebbe con EACCES. Default invariato su bare-metal.
const SECRET_FILE = process.env.INFRANET_SESSION_SECRET_FILE || path.join(__dirname, '.session-secret');
function _genSecret() {
if (fs.existsSync(SECRET_FILE)) {
// Retrofit permessi: un file preesistente poteva essere stato scritto coi
// permessi di default (world-readable) → il secret firma TUTTI i cookie di
// sessione, quindi chi lo legge forgia sessioni admin. Lo irrigidiamo a 0o600.
try { fs.chmodSync(SECRET_FILE, 0o600); } catch (_) { /* best-effort (no-op su Windows) */ }
return fs.readFileSync(SECRET_FILE, 'utf8').trim();
}
// CSPRNG (NON Math.random): il secret firma i cookie di sessione → deve essere
// imprevedibile. 48 byte casuali → ~64 char base64 ad alta entropia. 0o600:
// leggibile solo dal proprietario (mai da altri utenti dell'host).
const s = crypto.randomBytes(48).toString('base64');
fs.writeFileSync(SECRET_FILE, s, { encoding: 'utf8', mode: 0o600 });
return s;
}
const SESSION_SECRET = process.env.SESSION_SECRET || _genSecret();
// ---- Users storage ----------------------------------------------------------
// Legge e parsa il file utenti distinguendo TRE esiti (serve a ensureDefaultAdmin
// per non rigenerare l'admin sopra un DB corrotto → perdita silenziosa di account):
// { ok:true, users:[...] } file valido (anche vuoto), o recuperato dal .bak
// { ok:false, absent:true } nessun file → primo avvio legittimo
// { ok:false, absent:false } file PRESENTE ma illeggibile/corrotto (e .bak idem)
function _readUsersFile() {
// La politica e' quella comune (`readJsonWithBak`, in projects-store): main, poi
// il backup lasciato da atomicWriteFile, mancante compreso. Qui resta solo cosa
// significano i due esiti senza valore.
const r = readJsonWithBak(USERS_FILE, { shape: 'array', recoverMissing: true });
if (r.value) return { ok: true, users: r.value };
// c'era ma e' corrotto/non-array (e il .bak idem) vs davvero primo avvio
return { ok: false, absent: r.reason === 'missing' };
}
function loadUsers() {
const r = _readUsersFile();
return r.ok ? r.users : [];
}
function saveUsers(users) {
// Scrittura atomica (temp + fsync + rename) con .bak, come i progetti: un crash a
// meta' scrittura lascia intatto il file precedente invece di troncarlo.
atomicWriteFile(USERS_FILE, JSON.stringify(users, null, 2), PRIVATE_MODE); // gli hash delle password: solo il proprietario
hardenBackup(USERS_FILE);
}
function nextUserId(users) {
// reduce() evita il limite dello stack di Math.max(...spread) su array grandi
return users.reduce((max, u) => u.id > max ? u.id : max, 0) + 1;
}
// ---- Policy password --------------------------------------------------------
// Requisito minimo: la LUNGHEZZA. Niente vincoli di composizione (maiuscole/
// simboli obbligatori spingono a password prevedibili — «Password1!» — e sono
// scoraggiati dalle linee guida NIST). Vale a chi CREA o CAMBIA una password
// (createUser, updateUser, cambio-proprio); il LOGIN non la applica, perché si
// deve poter entrare con la password esistente qualunque essa sia. Ritorna un
// messaggio d'errore (stringa) o null se accettabile.
const MIN_PASSWORD_LEN = 8;
function _passwordError(pw) {
if (typeof pw !== 'string' || pw.length < MIN_PASSWORD_LEN) {
return `La password deve avere almeno ${MIN_PASSWORD_LEN} caratteri`;
}
return null;
}
// ---- Invalidazione sessioni: un'EPOCA per utente ----------------------------
//
// Mappa in-memory id utente → epoca (contatore). Ogni sessione porta con sé
// l'epoca letta al login; un cambio di password o ruolo, o la cancellazione,
// incrementano l'epoca e ogni sessione più vecchia muore alla prima richiesta.
// Un NUOVO login non riabilita le vecchie: legge l'epoca corrente, le altre
// restano indietro. (Smoke 06/09: con un Set svuotato al login, una sessione
// declassata e lasciata intatta tornava admin appena l'utente rientrava
// altrove.) Si azzera al riavvio, come le sessioni Express.
const _sessionEpoch = new Map();
const _epochOf = id => _sessionEpoch.get(id) || 0;
const _bumpEpoch = id => _sessionEpoch.set(id, _epochOf(id) + 1);
// Forma pubblica dell'utente in sessione: l'epoca resta dentro il server.
const _publicUser = u => ({ id: u.id, username: u.username, role: u.role });
// ---- Primo avvio: crea admin di default ------------------------------------
function ensureDefaultAdmin() {
const r = _readUsersFile();
// File PRESENTE ma illeggibile/corrotto (e nessun .bak valido): NON rigenerare.
// Sovrascrivere qui con un admin fresco cancellerebbe in silenzio tutti gli
// account. Meglio fermare l'avvio e chiedere intervento umano (ripristino).
if (!r.ok && !r.absent) {
console.error('');
console.error(' ============================================================');
console.error(' FATAL - users.json presente ma ILLEGGIBILE/CORROTTO');
console.error(` (${USERS_FILE})`);
console.error(' NON rigenero l\'admin per non cancellare gli account.');
console.error(' Ripristina il file (o il suo .bak) e riavvia.');
console.error(' ============================================================');
console.error('');
throw new Error('users.json corrupt - refusing to overwrite existing accounts');
}
if (r.ok && r.users.length > 0) return; // gia' inizializzato
const pwd = _randomPassword();
const hash = bcrypt.hashSync(pwd, BCRYPT_COST);
const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
saveUsers([{ id: 1, username: 'admin', passwordHash: hash, role: 'admin', createdAt: now }]);
console.log('');
console.log(' ╔══════════════════════════════════════════════════╗');
console.log(' ║ PRIMO AVVIO — Credenziali amministratore ║');
console.log(' ║ ║');
console.log(` ║ Utente : admin ║`);
console.log(` ║ Password: ${pwd.padEnd(35)} ║`);
console.log(' ║ ║');
console.log(' ║ ⚠ Cambia la password al primo accesso! ║');
console.log(' ╚══════════════════════════════════════════════════╝');
console.log('');
}
function _randomPassword() {
// CSPRNG + keyspace ampio: la password admin di primo avvio non deve essere
// né prevedibile (Math.random) né a basso numero di combinazioni. Prefisso
// leggibile + 12 char casuali url-safe da crypto.randomBytes (~72 bit).
const w = ['Net','Pro','Rack','Infra','Switch','Vlan','Port','Link'];
const word = w[crypto.randomInt(w.length)];
const rand = crypto.randomBytes(9).toString('base64url');
return `${word}-${rand}!`;
}
// ---- Session middleware -----------------------------------------------------
function sessionMiddleware() {
return session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'strict',
secure: TRUST_PROXY, // dietro reverse-proxy TLS → cookie solo su HTTPS
maxAge: 8 * 60 * 60 * 1000, // 8 ore
},
name: 'infranet.sid',
});
}
// ---- Rate limiter login -----------------------------------------------------
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minuti
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { ok: false, error: 'Troppi tentativi. Riprova tra 15 minuti.' },
skipSuccessfulRequests: true,
// Chiave IP+username, non il solo IP. Dietro un reverse-proxy senza
// INFRANET_TRUST_PROXY (o nel bridge di docker-compose) tutte le richieste
// arrivano da un unico IP: con la sola chiave-IP 10 tentativi di CHIUNQUE
// bloccavano il login a TUTTI per 15' (smoke 06/09). Con IP+username l'attacco
// a un utente non consuma il budget degli altri; un anonimo senza username
// ricade sulla sola chiave-IP. `req.body` è già parsato (express.json globale
// gira prima di questa route). Username non-stringa → si usa il solo IP.
keyGenerator: (req) => {
const ipKey = ipKeyGenerator(req.ip);
const u = (req.body && typeof req.body.username === 'string') ? req.body.username.toLowerCase().trim() : '';
return u ? `${ipKey}|${u}` : ipKey;
},
});
// Il limite IP+username sopra non limita NIENTE a chi cambia username a ogni
// tentativo: ogni coppia ha un budget nuovo di 10, e lo username "sconosciuto" costa
// al server un confronto bcrypt intero (v. loginApi: si fa SEMPRE, per non rivelare
// chi esiste). Quindi un secondo limite, per IP SOLO, a soglia molto piu' larga: non
// sostituisce quello per-utente (che protegge dal blocco reciproco dietro un proxy
// non dichiarato) — lo affianca, e mette un tetto a quanti tentativi falliti totali
// un indirizzo puo' far fare al server. 100 in 15' sono ~10 volte quanto sbaglia
// un ufficio intero in condizioni normali, e ~400/ora per IP: un attacco a
// dizionario non e' piu' gratuito. INFRANET_LOGIN_IP_MAX lo regola.
const LOGIN_IP_MAX = Math.max(1, parseInt(process.env.INFRANET_LOGIN_IP_MAX, 10) || 100);
const loginIpLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: LOGIN_IP_MAX,
standardHeaders: true,
legacyHeaders: false,
message: { ok: false, error: 'Troppi tentativi. Riprova tra 15 minuti.' },
skipSuccessfulRequests: true,
keyGenerator: (req) => ipKeyGenerator(req.ip),
});
// ---- Middleware autenticazione ----------------------------------------------
function requireAuth(req, res, next) {
if (DEV_NO_AUTH) { // bypass dev: sessione admin fittizia
if (req.session && !req.session.user) req.session.user = DEV_USER;
return next();
}
if (req.session?.user) {
// Sessione più vecchia dell'epoca corrente (cambio password / ruolo, utente
// cancellato e ricreato con lo stesso id) → distrutta.
if ((req.session.user.epoch || 0) !== _epochOf(req.session.user.id)) {
req.session.destroy(() => {});
if (req.method === 'GET' && !req.path.startsWith('/api/'))
return res.redirect('/login');
return res.status(401).json({ ok: false, error: 'Sessione scaduta — rieffettua il login' });
}
return next();
}
if (req.method === 'GET' && !req.path.startsWith('/api/')) {
return res.redirect('/login');
}
return res.status(401).json({ ok: false, error: 'Non autenticato' });
}
function requireAdmin(req, res, next) {
if (req.session?.user?.role === 'admin') return next();
return res.status(403).json({ ok: false, error: 'Richiede privilegi amministratore' });
}
// ---- Route handlers ---------------------------------------------------------
function loginPage(req, res) {
if (DEV_NO_AUTH || req.session?.user) return res.redirect('/');
res.sendFile(path.join(__dirname, 'login.html'));
}
// ⚠️ ASINCRONO, e non per eleganza: bcrypt a costo 12 e' ~340 ms di CPU (misurati), e con
// `compareSync` il server intero — anche le API del monitoraggio, anche chi e' gia'
// loggato — restava fermo per tutto quel tempo, a OGNI tentativo (riuscito o no, perche'
// il confronto si fa sempre). `compare` cede il controllo fra una fetta e l'altra
// (bcryptjs lavora a fette di <=100 ms): il costo in CPU e' lo stesso, ma il server
// resta vivo. Express 4 non raccoglie le promesse rifiutate di un handler: l'errore si
// prende QUI, o la richiesta resta appesa.
async function loginApi(req, res) {
try {
const { username, password } = req.body ?? {};
// Tipo controllato PRIMA di usarli: un body {"username":{}} arrivava a
// toLowerCase() e usciva come 500 (smoke 06/09).
if (typeof username !== 'string' || typeof password !== 'string' || !username || !password) {
return res.status(400).json({ ok: false, error: 'Username e password obbligatori' });
}
const users = loadUsers();
const user = users.find(u => u.username.toLowerCase() === username.toLowerCase());
// Anti user-enumeration: esegui SEMPRE un compare bcrypt (contro l'hash fittizio
// se l'utente non esiste) così il tempo di risposta non distingue "username
// sconosciuto" da "password errata".
const cmp = await _withBcryptSlot(() => bcrypt.compare(password, user ? user.passwordHash : _DUMMY_HASH));
if (cmp.busy) return _busy(res);
const okPw = cmp.value;
if (!user || !okPw) {
return res.status(401).json({ ok: false, error: 'Credenziali non valide' });
}
// Anti session-fixation: si RIGENERA l'id di sessione al passaggio da anonimo
// ad autenticato, così un id piantato prima del login (via cookie preimpostato)
// non sopravvive all'accesso. Solo DOPO la rigenerazione si scrive l'utente.
req.session.regenerate(regenErr => {
if (regenErr) return res.status(500).json({ ok: false, error: 'Errore sessione' });
// L'epoca CORRENTE entra nella sessione nuova; quelle già aperte restano
// all'epoca in cui sono nate e, se è passata, muoiono alla prossima richiesta.
req.session.user = { id: user.id, username: user.username, role: user.role, epoch: _epochOf(user.id) };
req.session.save(err => {
if (err) return res.status(500).json({ ok: false, error: 'Errore sessione' });
res.json({ ok: true, user: _publicUser(req.session.user) });
});
});
} catch (e) {
console.error('[auth] login:', e && e.message);
if (!res.headersSent) res.status(500).json({ ok: false, error: 'Errore interno' });
}
}
function logoutApi(req, res) {
req.session.destroy(() => {
res.clearCookie('infranet.sid');
res.json({ ok: true });
});
}
function meApi(req, res) {
res.json({ ok: true, user: _publicUser(req.session.user) });
}
// Cambio password del PROPRIO account: ogni utente autenticato, viewer inclusi
// (che non hanno accesso alla gestione utenti admin-only). Richiede la password
// ATTUALE — difende da una sessione lasciata aperta o dirottata — e rispetta la
// policy. Le ALTRE sessioni dell'utente muoiono (epoca+1); QUESTA resta viva
// perché ne riallineiamo l'epoca: cambiare la propria password non deve
// disconnettere chi la sta cambiando.
// Asincrono per la stessa ragione del login (v. loginApi) — e qui pesa di piu': la rotta
// non ha limite di tentativi, quindi anche un viewer poteva fermare il server a
// ripetizione con password attuali sbagliate.
// ⚠️ Gli `await` stanno FUORI dalla sezione che legge e riscrive users.json: con l'I/O
// sincrona di prima nessun'altra richiesta poteva infilarsi fra la lettura e la
// scrittura, ed e' una garanzia che un await in mezzo butterebbe via (due cambi di
// password insieme, o un reset dell'admin, si sovrascriverebbero a vicenda). Quindi: si
// verifica e si calcola l'hash nuovo, POI si rilegge e si scrive senza cedere il
// controllo, controllando che l'hash verificato sia ancora quello dell'utente.
async function changeOwnPassword(req, res) {
try {
const { currentPassword, newPassword } = req.body ?? {};
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string' || !currentPassword || !newPassword) {
return res.status(400).json({ ok: false, error: 'Password attuale e nuova obbligatorie' });
}
const pErr = _passwordError(newPassword);
if (pErr) return res.status(400).json({ ok: false, error: pErr });
const uid = req.session.user.id;
const current = loadUsers().find(u => u.id === uid);
if (!current) return res.status(404).json({ ok: false, error: 'Utente non trovato' });
// Il confronto si fa SEMPRE (non un ramo che salta il bcrypt): la password attuale
// sbagliata è un 403, non un errore di forma.
// Confronto e hash nuovo nello STESSO posto del cancello: sono un'operazione sola.
const done = await _withBcryptSlot(async () => (
(await bcrypt.compare(currentPassword, current.passwordHash))
? { newHash: await bcrypt.hash(newPassword, BCRYPT_COST) }
: { wrong: true }));
if (done.busy) return _busy(res);
if (done.value.wrong) {
return res.status(403).json({ ok: false, error: 'Password attuale errata' });
}
const newHash = done.value.newHash;
// ── sezione critica: SENZA await ──
const users = loadUsers();
const idx = users.findIndex(u => u.id === uid);
if (idx < 0) return res.status(404).json({ ok: false, error: 'Utente non trovato' });
if (users[idx].passwordHash !== current.passwordHash) {
return res.status(409).json({ ok: false, error: 'La password è stata modificata nel frattempo: riprova' });
}
users[idx].passwordHash = newHash;
saveUsers(users);
_bumpEpoch(uid); // le ALTRE sessioni muoiono
req.session.user.epoch = _epochOf(uid); // ...ma non questa
req.session.save(err => {
if (err) return res.status(500).json({ ok: false, error: 'Errore sessione' });
res.json({ ok: true });
});
} catch (e) {
console.error('[auth] cambio password:', e && e.message);
if (!res.headersSent) res.status(500).json({ ok: false, error: 'Errore interno' });
}
}
// ---- User CRUD (admin only) -------------------------------------------------
function listUsers(req, res) {
const users = loadUsers().map(({ id, username, role, createdAt }) =>
({ id, username, role, createdAt }));
res.json(users);
}
function createUser(req, res) {
const { username, password, role } = req.body ?? {};
if (!username || !password) {
return res.status(400).json({ ok: false, error: 'Username e password obbligatori' });
}
if (!['admin', 'viewer'].includes(role)) {
return res.status(400).json({ ok: false, error: 'Ruolo non valido (admin|viewer)' });
}
const users = loadUsers();
if (users.find(u => u.username.toLowerCase() === username.toLowerCase())) {
return res.status(409).json({ ok: false, error: 'Username già esistente' });
}
const pErr = _passwordError(password);
if (pErr) return res.status(400).json({ ok: false, error: pErr });
const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
const user = {
id: nextUserId(users),
username: username.trim(),
passwordHash: bcrypt.hashSync(password, BCRYPT_COST),
role,
createdAt: now,
};
users.push(user);
saveUsers(users);
res.status(201).json({ ok: true, user: { id: user.id, username: user.username, role: user.role, createdAt: user.createdAt } });
}
function updateUser(req, res) {
const id = parseInt(req.params.id);
const users = loadUsers();
const idx = users.findIndex(u => u.id === id);
if (idx < 0) return res.status(404).json({ ok: false, error: 'Utente non trovato' });
const { password, role } = req.body ?? {};
// Se si imposta una nuova password, deve rispettare la policy (lunghezza).
if (password !== undefined) {
const pErr = _passwordError(password);
if (pErr) return res.status(400).json({ ok: false, error: pErr });
}
// Impedisce di cambiare il proprio ruolo
if (req.session.user.id === id && role && role !== users[idx].role) {
return res.status(400).json({ ok: false, error: 'Non puoi cambiare il tuo ruolo' });
}
// Impedisce di eliminare l'ultimo admin
if (role === 'viewer' && users[idx].role === 'admin') {
const adminCount = users.filter(u => u.role === 'admin').length;
if (adminCount <= 1) {
return res.status(400).json({ ok: false, error: 'Deve esistere almeno un amministratore' });
}
}
// Invalida le sessioni attive dell'utente se password o ruolo cambiano
// (check PRIMA di aggiornare users[idx] per confrontare il valore corrente)
if (password || (role && role !== users[idx].role)) _bumpEpoch(id);
if (role) users[idx].role = role;
if (password) users[idx].passwordHash = bcrypt.hashSync(password, BCRYPT_COST);
saveUsers(users);
res.json({ ok: true, user: { id: users[idx].id, username: users[idx].username, role: users[idx].role } });
}
function deleteUser(req, res) {
const id = parseInt(req.params.id);
if (req.session.user.id === id) {
return res.status(400).json({ ok: false, error: 'Non puoi eliminare te stesso' });
}
const users = loadUsers();
const user = users.find(u => u.id === id);
if (!user) return res.status(404).json({ ok: false, error: 'Utente non trovato' });
if (user.role === 'admin') {
const adminCount = users.filter(u => u.role === 'admin').length;
if (adminCount <= 1) {
return res.status(400).json({ ok: false, error: 'Deve esistere almeno un amministratore' });
}
}
_bumpEpoch(id); // ogni sessione dell'utente muore alla prossima richiesta; un futuro utente con lo stesso id non le eredita
saveUsers(users.filter(u => u.id !== id));
res.json({ ok: true, deleted_id: id });
}
// ---- Registra route su app Express -----------------------------------------
function register(app) {
ensureDefaultAdmin();
// Dietro reverse-proxy TLS: fidati dell'header X-Forwarded-Proto così il
// cookie `secure` viene impostato anche se l'hop proxy→app è in chiaro.
if (TRUST_PROXY) app.set('trust proxy', 1);
if (DEV_NO_AUTH) {
console.warn('');
console.warn(' ⚠️ INFRANET_DEV_NO_AUTH=1 — AUTENTICAZIONE DISABILITATA (solo sviluppo)');
console.warn(' Chiunque raggiunga il server entra come admin. NON usare in produzione.');
console.warn('');
} else if (_DEV_NO_AUTH.requested) {
// Richiesto ma RIFIUTATO (fail-closed, SEC-M2): l'auth resta attiva.
console.warn('');
console.warn(' 🔒 INFRANET_DEV_NO_AUTH=1 IGNORATO — auth ATTIVA.');
console.warn(` Motivo: ${_DEV_NO_AUTH.reason}. Il bypass è ammesso solo su loopback e fuori produzione.`);
console.warn('');
}
app.use(sessionMiddleware());
// Pagina login — pubblica
app.get('/login', loginPage);
// API auth — pubbliche
app.post('/api/auth/login', loginIpLimiter, loginLimiter, loginApi);
app.post('/api/auth/logout', logoutApi);
// Tutto il resto richiede autenticazione
app.use(requireAuth);
// API auth — autenticate
app.get('/api/auth/me', meApi);
app.post('/api/auth/password', changeOwnPassword); // cambio password proprio (ogni ruolo)
// API utenti — solo admin
app.get ('/api/auth/users', requireAdmin, listUsers);
app.post ('/api/auth/users', requireAdmin, createUser);
app.put ('/api/auth/users/:id', requireAdmin, updateUser);
app.delete('/api/auth/users/:id', requireAdmin, deleteUser);
}
module.exports = {
register, requireAdmin,
// esposti per i test (F16): store utenti + inizializzazione admin
loadUsers, saveUsers, ensureDefaultAdmin, _readUsersFile, USERS_FILE,
// SEC-M2: guardia pura del bypass auth (loopback + non-produzione)
_computeDevNoAuth,
// policy password (pura, testabile a tavolino)
_passwordError, MIN_PASSWORD_LEN,
// il cancello dei confronti bcrypt: i test ne leggono lo stato (active/waiting) durante una raffica
_bcryptGate,
};