Ctrl K
ComunidadGuías…

auth.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'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,
};