From e4f9e9179ec00de65454fac1b474cbd6c1236011 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Fri, 29 May 2026 13:18:35 +0200 Subject: [PATCH] =?UTF-8?q?[FEAT]=20Allineamento=20NIS2=20=E2=86=94=20TRPG?= =?UTF-8?q?=20(Fasi=201-5):=20SSO=20+=20Sessions=20+=20Reset=20+=20Imperso?= =?UTF-8?q?nate=20+=20Branding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementazione completa del progetto allineamento alla suite Evix (TRPG/lg231), basato sul doc canonico docs/GAP_TRPG_NIS2_ALIGNMENT.md (5 fasi, 18 gap). Version 1.0.0 → 1.5.0 Fase 1 — SSO Federation (v1.1.0) - Migration 015_sso_columns: users.sso_identity_id + password_version - application/services/SsoHelper.php (client SSO dual-mode, cURL nativo, zero deps) - AuthController::login() + changePassword() conditional SSO (SSO_MODE=local default) Fase 2 — Multi-device Sessions (v1.2.0) - Migration 016_active_sessions: tabella + refresh_tokens.session_jti - BaseController::requireAuth() verifica jti + last_activity throttle + parseDeviceLabel - login() genera jti, logout/changePassword revoca selettiva - GET/DELETE /auth/sessions[/{id}] - UI settings.html tab Sicurezza con lista device + revoca Fase 3 — Password Reset + Tenant Switcher (v1.3.0) - Migration 017_password_reset_tokens (TTL 30min, single-use) - POST /auth/forgot-password (risposta opaca) + reset-password - Pagine forgot-password.html + reset-password.html (con strength bar) - EmailService::sendPasswordReset - POST /auth/switchContext con rotazione JWT + organization_id claim - Dropdown tenant in sidebar esposto a tutti gli utenti con ≥2 org Fase 4 — Impersonate + Preferences + Versioning UI (v1.4.0) - POST /auth/impersonate (super_admin o consulente stesso firm, TTL 1h, audit) - Migration 018_user_preferences: users.theme/timezone/notif_email/notif_inapp - GET/PUT /auth/preferences - Sidebar footer mostra versione + changelog modal su click Fase 5 — Branding white-label + Auth-gate (v1.5.0) - Migration 019_firm_branding (logo/colori/brand_name per consulting firm) - BrandingController GET /branding/current (auth opzionale) + PUT - common.js auto-applica CSS variables al boot - public/js/auth-gate.js (gate password client-side per docs riservati, da TRPG) Skip motivati: - G15 demo login: simulator esistenti coprono - G18 refactor controllers: rinviato (~5gg, valore tecnico solo) Cron sync SSO: AgileHub Ticket #220 aperto a team AGILEHUB per estendere sso-password-sync.sh al DB nis2_agile_db. Prerequisito per switch SSO_MODE=dual. Backup files: tutti i file modificati hanno .bak.pre-{fase}-{ts} sia in DEV sia in /var/www/nis2-agile/.backups/ su Hetzner (rollback ready). Co-Authored-By: Claude Opus 4.7 (1M context) --- application/config/config.php | 6 + application/controllers/AuthController.php | 688 +++++++++++++++++- application/controllers/BaseController.php | 66 +- .../controllers/BrandingController.php | 114 +++ application/services/EmailService.php | 35 + application/services/SsoHelper.php | 176 +++++ docs/CONTEXT_LAST_SESSION.md | 245 ++++--- docs/GAP_TRPG_NIS2_ALIGNMENT.md | 467 ++++++++++++ docs/sql/015_sso_columns.sql | 66 ++ docs/sql/016_active_sessions.sql | 81 +++ docs/sql/017_password_reset.sql | 39 + docs/sql/018_user_preferences.sql | 46 ++ docs/sql/019_firm_branding.sql | 33 + public/forgot-password.html | 106 +++ public/index.php | 31 + public/js/auth-gate.js | 37 + public/js/common.js | 266 ++++++- public/login.html | 2 +- public/reset-password.html | 171 +++++ public/settings.html | 112 ++- public/version.json | 1 + 21 files changed, 2636 insertions(+), 152 deletions(-) create mode 100644 application/controllers/BrandingController.php create mode 100644 application/services/SsoHelper.php create mode 100644 docs/GAP_TRPG_NIS2_ALIGNMENT.md create mode 100644 docs/sql/015_sso_columns.sql create mode 100644 docs/sql/016_active_sessions.sql create mode 100644 docs/sql/017_password_reset.sql create mode 100644 docs/sql/018_user_preferences.sql create mode 100644 docs/sql/019_firm_branding.sql create mode 100644 public/forgot-password.html create mode 100644 public/js/auth-gate.js create mode 100644 public/reset-password.html create mode 100644 public/version.json diff --git a/application/config/config.php b/application/config/config.php index 5c1f193..c556691 100644 --- a/application/config/config.php +++ b/application/config/config.php @@ -70,6 +70,12 @@ define('RATE_LIMIT_AUTH_LOGIN', [ define('RATE_LIMIT_AUTH_REGISTER', [ ['max' => 3, 'window_seconds' => 600], ]); +// Password reset (Fase 3 / G08): 3 richieste/h per IP+email +define('RATE_LIMIT_AUTH_FORGOT', [ + ['max' => 3, 'window_seconds' => 3600], +]); +// TTL token reset password (decisione utente §10.4: 30 min) +define('PASSWORD_RESET_TTL_SECONDS', 1800); define('RATE_LIMIT_AI', [ ['max' => 10, 'window_seconds' => 60], ['max' => 100, 'window_seconds' => 3600], diff --git a/application/controllers/AuthController.php b/application/controllers/AuthController.php index 8979144..c1bb74f 100644 --- a/application/controllers/AuthController.php +++ b/application/controllers/AuthController.php @@ -7,6 +7,7 @@ require_once __DIR__ . '/BaseController.php'; require_once APP_PATH . '/services/RateLimitService.php'; +require_once APP_PATH . '/services/SsoHelper.php'; class AuthController extends BaseController { @@ -132,8 +133,66 @@ class AuthController extends BaseController [$email] ); - if (!$user || !password_verify($password, $user['password_hash'])) { - $this->jsonError('Credenziali non valide', 401, 'INVALID_CREDENTIALS'); + // --- SSO Federation (Fase 1 / G02) --- + // In SSO_MODE=local (default) tutto questo blocco è no-op: SsoHelper::login() + // ritorna null e $ssoAuthorized resta false → cade nel fallback locale invariato. + $ssoAuthorized = false; + $sso = new SsoHelper(); + if (!$sso->isLocalOnly()) { + $ssoResp = $sso->login($email, $password, 'nis2'); + if ($ssoResp === null) { + // SSO unreachable + if ($sso->isSsoOnly()) { + $this->jsonError('SSO non disponibile, riprovare', 503, 'SSO_UNAVAILABLE'); + } + // dual: fallback locale (procedi al password_verify sotto) + } else { + $httpStatus = (int) ($ssoResp['_httpStatus'] ?? 0); + if ($httpStatus === 200) { + $ssoIdentityId = (int) ($ssoResp['identity_id'] ?? $ssoResp['user']['sso_identity_id'] ?? 0); + $ssoPasswordVersion = (int) ($ssoResp['password_version'] ?? 1); + if (!$user) { + // Utente SSO non ancora presente localmente: crealo + $ssoUser = $ssoResp['user'] ?? []; + $newId = Database::insert('users', [ + 'email' => $email, + 'password_hash' => password_hash($password, PASSWORD_DEFAULT), + 'full_name' => $ssoUser['full_name'] ?? $email, + 'role' => 'employee', + 'sso_identity_id' => $ssoIdentityId, + 'password_version' => $ssoPasswordVersion, + 'is_active' => 1, + ]); + $user = Database::fetchOne('SELECT * FROM users WHERE id = ?', [$newId]); + } else { + // Utente esistente: link/sync se necessario (lazy backfill — decisione 2) + $updates = []; + if ((int) ($user['sso_identity_id'] ?? 0) !== $ssoIdentityId) { + $updates['sso_identity_id'] = $ssoIdentityId; + } + if ((int) ($user['password_version'] ?? 0) !== $ssoPasswordVersion) { + $updates['password_hash'] = password_hash($password, PASSWORD_DEFAULT); + $updates['password_version'] = $ssoPasswordVersion; + } + if ($updates) { + Database::update('users', $updates, 'id = ?', [$user['id']]); + $user = Database::fetchOne('SELECT * FROM users WHERE id = ?', [$user['id']]); + } + } + $ssoAuthorized = true; + } elseif ($httpStatus === 401 && $sso->isSsoOnly()) { + $this->jsonError('Credenziali non valide', 401, 'INVALID_CREDENTIALS'); + } elseif ($sso->isSsoOnly()) { + $this->jsonError('Errore SSO', 502, 'SSO_ERROR'); + } + // dual + non-200: cade nel fallback locale + } + } + + if (!$ssoAuthorized) { + if (!$user || !password_verify($password, $user['password_hash'])) { + $this->jsonError('Credenziali non valide', 401, 'INVALID_CREDENTIALS'); + } } // Aggiorna ultimo login @@ -141,9 +200,22 @@ class AuthController extends BaseController 'last_login_at' => date('Y-m-d H:i:s'), ], 'id = ?', [$user['id']]); - // Genera tokens - $accessToken = $this->generateJWT((int) $user['id']); - $refreshToken = $this->generateRefreshToken((int) $user['id']); + // --- Multi-device session tracking (Fase 2 / G05-G06) --- + $jti = bin2hex(random_bytes(16)); + $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; + Database::insert('active_sessions', [ + 'id' => $jti, + 'user_id' => (int) $user['id'], + 'organization_id'=> null, + 'ip_address' => $ip, + 'user_agent' => substr($ua, 0, 512), + 'device_label' => $this->parseDeviceLabel($ua), + 'expires_at' => date('Y-m-d H:i:s', time() + JWT_REFRESH_EXPIRES_IN), + ]); + + // Genera tokens (jti come claim JWT + FK su refresh_tokens.session_jti) + $accessToken = $this->generateJWT((int) $user['id'], ['jti' => $jti]); + $refreshToken = $this->generateRefreshToken((int) $user['id'], $jti); // Carica organizzazioni $organizations = Database::fetchAll( @@ -179,8 +251,19 @@ class AuthController extends BaseController { $this->requireAuth(); - // Invalida tutti i refresh token dell'utente - Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]); + // --- Revoca selettiva della sessione corrente (Fase 2 / G06) --- + $jti = $this->currentUser['session_jti'] ?? null; + if ($jti) { + Database::query( + "UPDATE active_sessions SET revoked_at = NOW(), revoked_reason = 'logout' + WHERE id = ? AND revoked_at IS NULL", + [$jti] + ); + Database::delete('refresh_tokens', 'session_jti = ?', [$jti]); + } else { + // Fallback (non dovrebbe accadere — requireAuth ora pretende jti) + Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]); + } $this->logAudit('user_logout', 'user', $this->getCurrentUserId()); @@ -211,13 +294,34 @@ class AuthController extends BaseController $this->jsonError('Refresh token non valido o scaduto', 401, 'INVALID_REFRESH_TOKEN'); } + $userId = (int) $tokenRecord['user_id']; + $jti = $tokenRecord['session_jti'] ?? null; + + // Verifica che la sessione associata sia ancora valida (Fase 2 / G06) + if ($jti) { + $session = Database::fetchOne( + 'SELECT id FROM active_sessions + WHERE id = ? AND revoked_at IS NULL AND expires_at > NOW()', + [$jti] + ); + if (!$session) { + Database::rollback(); + $this->jsonError('Sessione revocata, login richiesto', 401, 'SESSION_REVOKED'); + } + // Touch last_activity + Database::update('active_sessions', + ['last_activity_at' => date('Y-m-d H:i:s')], + 'id = ?', + [$jti] + ); + } + // Elimina vecchio token atomicamente Database::delete('refresh_tokens', 'id = ?', [$tokenRecord['id']]); - // Genera nuovi tokens - $userId = (int) $tokenRecord['user_id']; - $accessToken = $this->generateJWT($userId); - $newRefreshToken = $this->generateRefreshToken($userId); + // Genera nuovi tokens preservando il jti della sessione + $accessToken = $this->generateJWT($userId, $jti ? ['jti' => $jti] : []); + $newRefreshToken = $this->generateRefreshToken($userId, $jti); Database::commit(); } catch (Throwable $e) { @@ -319,16 +423,74 @@ class AuthController extends BaseController $this->jsonError(implode('. ', $errors), 400, 'WEAK_PASSWORD'); } + // --- SSO change-password propagation (Fase 1 / G03) --- + // Solo per utenti federati (sso_identity_id != NULL) e SSO_MODE != local. + // Se SSO 200 → procediamo con cambio locale. + // Se SSO error → blocchiamo: niente cambio locale, manteniamo consistency. + // Se SSO unreachable in dual → log e procediamo localmente (sync verrà via cron). + $sso = new SsoHelper(); + $isFederated = !empty($this->currentUser['sso_identity_id']); + $newPasswordVersion = (int) ($this->currentUser['password_version'] ?? 1) + 1; + + if ($isFederated && !$sso->isLocalOnly()) { + $jwt = $this->getBearerToken() ?? ''; + $ssoResp = $sso->changePassword($jwt, $currentPassword, $newPassword); + if ($ssoResp === null) { + // unreachable + if ($sso->isSsoOnly()) { + $this->jsonError('SSO non disponibile, riprovare', 503, 'SSO_UNAVAILABLE'); + } + // dual: log e procedi locale (cron riallineerà appena SSO torna) + error_log("[SSO] changePassword unreachable for user {$this->getCurrentUserId()} (dual mode → local only)"); + } else { + $httpStatus = (int) ($ssoResp['_httpStatus'] ?? 0); + if ($httpStatus !== 200) { + $msg = $ssoResp['error'] ?? $ssoResp['message'] ?? 'Errore SSO change-password'; + $this->jsonError($msg, $httpStatus ?: 502, 'SSO_CHANGE_PWD_FAILED'); + } + // 200: usa la versione restituita da SSO se disponibile + if (isset($ssoResp['password_version'])) { + $newPasswordVersion = (int) $ssoResp['password_version']; + } + } + } + Database::update('users', [ - 'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT), + 'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT), + 'password_version' => $newPasswordVersion, ], 'id = ?', [$this->getCurrentUserId()]); - // Invalida tutti i refresh token (force re-login) - Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]); + // --- Revoca altre sessioni, mantieni quella corrente (Fase 2 / G06) --- + $currentJti = $this->currentUser['session_jti'] ?? null; + $userId = $this->getCurrentUserId(); + if ($currentJti) { + Database::query( + "UPDATE active_sessions + SET revoked_at = NOW(), revoked_reason = 'password_change' + WHERE user_id = ? AND id != ? AND revoked_at IS NULL", + [$userId, $currentJti] + ); + Database::query( + "DELETE FROM refresh_tokens WHERE user_id = ? AND (session_jti IS NULL OR session_jti != ?)", + [$userId, $currentJti] + ); + } else { + // Nessun jti corrente → revoca tutto (comportamento pre-Fase2) + Database::query( + "UPDATE active_sessions + SET revoked_at = NOW(), revoked_reason = 'password_change' + WHERE user_id = ? AND revoked_at IS NULL", + [$userId] + ); + Database::delete('refresh_tokens', 'user_id = ?', [$userId]); + } - $this->logAudit('password_changed', 'user', $this->getCurrentUserId()); + $this->logAudit('password_changed', 'user', $this->getCurrentUserId(), [ + 'federated' => $isFederated, + 'password_version' => $newPasswordVersion, + ]); - $this->jsonSuccess(null, 'Password modificata. Effettua nuovamente il login.'); + $this->jsonSuccess(null, 'Password modificata. Le altre sessioni sono state disconnesse.'); } /** @@ -374,4 +536,498 @@ class AuthController extends BaseController 'recipient' => $recipient, // dati pre-compilazione form (null se non presenti) ], 'Invito valido'); } + + // ═══════════════════════════════════════════════════════════════════════ + // PREFERENCES — Tema / timezone / notifiche (Fase 4 / G12) + // ═══════════════════════════════════════════════════════════════════════ + + /** + * GET /api/auth/preferences + */ + public function getPreferences(): void + { + $this->requireAuth(); + $u = $this->currentUser; + $this->jsonSuccess([ + 'preferred_language' => $u['preferred_language'] ?? 'it', + 'theme' => $u['theme'] ?? 'auto', + 'timezone' => $u['timezone'] ?? 'Europe/Rome', + 'notif_email' => (bool) ($u['notif_email'] ?? 1), + 'notif_inapp' => (bool) ($u['notif_inapp'] ?? 1), + ]); + } + + /** + * PUT /api/auth/preferences + * Body: { theme?, timezone?, preferred_language?, notif_email?, notif_inapp? } + */ + public function updatePreferences(): void + { + $this->requireAuth(); + $updates = []; + + if ($this->hasParam('preferred_language')) { + $lang = $this->getParam('preferred_language'); + if (!in_array($lang, ['it','en','fr','de'], true)) { + $this->jsonError('Lingua non supportata', 400, 'INVALID_LANGUAGE'); + } + $updates['preferred_language'] = $lang; + } + if ($this->hasParam('theme')) { + $theme = $this->getParam('theme'); + if (!in_array($theme, ['light','dark','auto'], true)) { + $this->jsonError('Tema non valido', 400, 'INVALID_THEME'); + } + $updates['theme'] = $theme; + } + if ($this->hasParam('timezone')) { + $tz = $this->getParam('timezone'); + // Validation minimale: max 64 chars, formato IANA-like (Region/City) + if (!preg_match('#^[A-Za-z_]+(?:/[A-Za-z_+\-0-9]+)*$#', $tz) || strlen($tz) > 64) { + $this->jsonError('Timezone non valida', 400, 'INVALID_TIMEZONE'); + } + $updates['timezone'] = $tz; + } + if ($this->hasParam('notif_email')) { + $updates['notif_email'] = $this->getParam('notif_email') ? 1 : 0; + } + if ($this->hasParam('notif_inapp')) { + $updates['notif_inapp'] = $this->getParam('notif_inapp') ? 1 : 0; + } + if (empty($updates)) { + $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES'); + } + + Database::update('users', $updates, 'id = ?', [$this->getCurrentUserId()]); + $this->logAudit('preferences_updated', 'user', $this->getCurrentUserId(), $updates); + + $this->jsonSuccess($updates, 'Preferenze aggiornate'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // IMPERSONATE — Super admin / Consulente entra come altro utente (Fase 4 / G11) + // ═══════════════════════════════════════════════════════════════════════ + + /** + * POST /api/auth/impersonate + * Body: { user_id } + * Emette un JWT speciale per "loggarsi come" l'utente target. + * - Permesso solo a super_admin (qualunque target) o consulente verso utenti delle org del suo firm. + * - JWT TTL ridotto (~1h, no refresh) + claim `impersonated_by` per audit. + * - Sessione corrente del super_admin/consulente NON viene revocata. + */ + public function impersonate(): void + { + $this->requireAuth(); + $this->validateRequired(['user_id']); + + $targetId = (int) $this->getParam('user_id'); + $actorId = $this->getCurrentUserId(); + $actorRole = $this->currentUser['role']; + + if ($targetId === $actorId) { + $this->jsonError('Non puoi impersonare te stesso', 400, 'SELF_IMPERSONATE'); + } + + $target = Database::fetchOne( + 'SELECT id, email, full_name, role, is_active, consulting_firm_id + FROM users WHERE id = ?', + [$targetId] + ); + if (!$target || (int) $target['is_active'] !== 1) { + $this->jsonError('Utente target non trovato o disabilitato', 404, 'TARGET_NOT_FOUND'); + } + if ($target['role'] === 'super_admin' && $actorRole !== 'super_admin') { + $this->jsonError('Non puoi impersonare un super admin', 403, 'IMPERSONATE_FORBIDDEN'); + } + + // Permessi: super_admin OR consulente sullo stesso firm + $allowed = false; + if ($actorRole === 'super_admin') { + $allowed = true; + } elseif ($actorRole === 'consultant') { + $actorFirmId = (int) ($this->currentUser['consulting_firm_id'] ?? 0); + $targetFirmId = (int) ($target['consulting_firm_id'] ?? 0); + if ($actorFirmId > 0 && $actorFirmId === $targetFirmId) { + $allowed = true; + } + // In alternativa: target è org_admin/employee di una org del consulente. + // Per ora copertura minimale: stesso firm. + } + if (!$allowed) { + $this->jsonError('Permessi insufficienti per impersonare questo utente', 403, 'IMPERSONATE_FORBIDDEN'); + } + + // Nuova sessione tracciata (durata ridotta: 1h, niente refresh persistente) + $jti = bin2hex(random_bytes(16)); + $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; + Database::insert('active_sessions', [ + 'id' => $jti, + 'user_id' => $targetId, + 'organization_id'=> null, + 'ip_address' => $this->getClientIP(), + 'user_agent' => substr($ua, 0, 512), + 'device_label' => 'Impersonato da #' . $actorId, + 'expires_at' => date('Y-m-d H:i:s', time() + 3600), + ]); + + $accessToken = $this->generateJWT($targetId, [ + 'jti' => $jti, + 'impersonated_by' => $actorId, + 'exp' => time() + 3600, + ]); + + $this->logAudit('user_impersonated', 'user', $targetId, [ + 'actor_id' => $actorId, + 'actor_role' => $actorRole, + 'duration_seconds' => 3600, + ]); + + $this->jsonSuccess([ + 'user' => [ + 'id' => (int) $target['id'], + 'email' => $target['email'], + 'full_name' => $target['full_name'], + 'role' => $target['role'], + ], + 'access_token' => $accessToken, + 'expires_in' => 3600, + 'impersonated' => true, + ], 'Impersonate attivo per 1 ora'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // CONTEXT SWITCH — Cambio organization attiva (Fase 3 / G09) + // ═══════════════════════════════════════════════════════════════════════ + + /** + * POST /api/auth/switch-context + * Body: { organization_id } + * Verifica membership, revoca la sessione corrente con reason='context_switch', + * crea una nuova sessione e ritorna nuovo JWT + refresh con organization_id come claim. + */ + public function switchContext(): void + { + $this->requireAuth(); + $this->validateRequired(['organization_id']); + + $targetOrgId = (int) $this->getParam('organization_id'); + $userId = $this->getCurrentUserId(); + $oldJti = $this->currentUser['session_jti'] ?? null; + + // Super admin bypass membership check + if ($this->currentUser['role'] !== 'super_admin') { + $membership = Database::fetchOne( + 'SELECT role FROM user_organizations WHERE user_id = ? AND organization_id = ?', + [$userId, $targetOrgId] + ); + if (!$membership) { + $this->jsonError('Non sei membro di questa organizzazione', 403, 'NOT_MEMBER'); + } + } + + $org = Database::fetchOne( + 'SELECT id, name FROM organizations WHERE id = ? AND is_active = 1', + [$targetOrgId] + ); + if (!$org) { + $this->jsonError('Organizzazione non trovata o disabilitata', 404, 'ORG_NOT_FOUND'); + } + + Database::beginTransaction(); + try { + // Revoca sessione corrente + if ($oldJti) { + Database::query( + "UPDATE active_sessions + SET revoked_at = NOW(), revoked_reason = 'context_switch' + WHERE id = ? AND revoked_at IS NULL", + [$oldJti] + ); + Database::delete('refresh_tokens', 'session_jti = ?', [$oldJti]); + } + + // Nuova sessione con organization_id valorizzato + $newJti = bin2hex(random_bytes(16)); + $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; + Database::insert('active_sessions', [ + 'id' => $newJti, + 'user_id' => $userId, + 'organization_id' => $targetOrgId, + 'ip_address' => $this->getClientIP(), + 'user_agent' => substr($ua, 0, 512), + 'device_label' => $this->parseDeviceLabel($ua), + 'expires_at' => date('Y-m-d H:i:s', time() + JWT_REFRESH_EXPIRES_IN), + ]); + + $accessToken = $this->generateJWT($userId, [ + 'jti' => $newJti, + 'organization_id' => $targetOrgId, + ]); + $refreshToken = $this->generateRefreshToken($userId, $newJti); + + Database::commit(); + } catch (Throwable $e) { + Database::rollback(); + throw $e; + } + + $this->logAudit('context_switched', 'organization', $targetOrgId); + + $this->jsonSuccess([ + 'organization' => [ + 'id' => (int) $org['id'], + 'name' => $org['name'], + ], + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'expires_in' => JWT_EXPIRES_IN, + ], 'Contesto cambiato'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // PASSWORD RESET — Forgot/Reset (Fase 3 / G08) + // ═══════════════════════════════════════════════════════════════════════ + + /** + * POST /api/auth/forgot-password + * Body: { email } + * Risposta SEMPRE 200 + messaggio generico (anti enumeration). + */ + public function forgotPassword(): void + { + $this->validateRequired(['email']); + $email = strtolower(trim($this->getParam('email'))); + $ip = $this->getClientIP(); + + // Rate limit per IP+email combinati (anti spam) + $rlKey = 'forgot:' . $ip . ':' . md5($email); + RateLimitService::check($rlKey, RATE_LIMIT_AUTH_FORGOT); + RateLimitService::increment($rlKey); + + $user = Database::fetchOne( + 'SELECT id, email, full_name FROM users WHERE email = ? AND is_active = 1', + [$email] + ); + + // Risposta opaca: anche se l'email non esiste rispondiamo come se ok + $okMessage = 'Se l\'indirizzo email è registrato, riceverai a breve un link per reimpostare la password.'; + + if ($user) { + // Invalida eventuali token precedenti non usati + Database::query( + 'UPDATE password_reset_tokens SET used_at = NOW() + WHERE user_id = ? AND used_at IS NULL', + [$user['id']] + ); + + // Genera token (in chiaro inviato via mail, hash sul DB) + $token = bin2hex(random_bytes(32)); + Database::insert('password_reset_tokens', [ + 'user_id' => (int) $user['id'], + 'token_hash' => hash('sha256', $token), + 'expires_at' => date('Y-m-d H:i:s', time() + PASSWORD_RESET_TTL_SECONDS), + 'ip_address' => $ip, + ]); + + // Invia email + try { + require_once APP_PATH . '/services/EmailService.php'; + $email_service = new EmailService(); + $email_service->sendPasswordReset($user, $token, PASSWORD_RESET_TTL_SECONDS); + } catch (Throwable $e) { + error_log("[forgot-password] sendPasswordReset failed for user {$user['id']}: " . $e->getMessage()); + } + + $this->logAudit('password_reset_requested', 'user', (int) $user['id']); + } + + $this->jsonSuccess(null, $okMessage); + } + + /** + * POST /api/auth/reset-password + * Body: { token, new_password } + */ + public function resetPassword(): void + { + $this->validateRequired(['token', 'new_password']); + $token = $this->getParam('token'); + $newPassword = $this->getParam('new_password'); + + $errors = $this->validatePassword($newPassword); + if (!empty($errors)) { + $this->jsonError(implode('. ', $errors), 400, 'WEAK_PASSWORD'); + } + + $hash = hash('sha256', $token); + $row = Database::fetchOne( + 'SELECT prt.id, prt.user_id, prt.expires_at, prt.used_at, u.email, u.sso_identity_id + FROM password_reset_tokens prt + JOIN users u ON u.id = prt.user_id + WHERE prt.token_hash = ?', + [$hash] + ); + + if (!$row) { + $this->jsonError('Token non valido', 400, 'INVALID_TOKEN'); + } + if ($row['used_at']) { + $this->jsonError('Token già utilizzato', 400, 'TOKEN_USED'); + } + if (strtotime($row['expires_at']) < time()) { + $this->jsonError('Token scaduto. Richiedere un nuovo link.', 400, 'TOKEN_EXPIRED'); + } + + $userId = (int) $row['user_id']; + $isFederated = !empty($row['sso_identity_id']); + $newPasswordVersion = 2; // bump da 1 base; ci aggiorneremo dal cron sync + + Database::beginTransaction(); + try { + // Consume token + Database::update('password_reset_tokens', + ['used_at' => date('Y-m-d H:i:s')], + 'id = ?', + [$row['id']] + ); + + // Update password + Database::update('users', [ + 'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT), + 'password_version' => $newPasswordVersion, + ], 'id = ?', [$userId]); + + // Revoca TUTTE le sessioni (no sessione corrente = reset da non-loggato) + Database::query( + "UPDATE active_sessions + SET revoked_at = NOW(), revoked_reason = 'password_change' + WHERE user_id = ? AND revoked_at IS NULL", + [$userId] + ); + Database::delete('refresh_tokens', 'user_id = ?', [$userId]); + + Database::commit(); + } catch (Throwable $e) { + Database::rollback(); + throw $e; + } + + $this->logAudit('password_reset_completed', 'user', $userId, [ + 'federated_user' => $isFederated, + ]); + + // Nota: per utenti federati (sso_identity_id != NULL) il cambio è solo locale. + // Il cron `sso-password-sync` (richiesto via AgileHub Ticket #220) potrebbe + // riallineare la password lato SSO al prossimo run. + + $this->jsonSuccess(null, 'Password reimpostata. Puoi accedere con la nuova password.'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // SESSIONS — Multi-device management (Fase 2 / G06) + // ═══════════════════════════════════════════════════════════════════════ + + /** + * GET /api/auth/sessions + * Lista le sessioni attive dell'utente corrente. + */ + public function listSessions(): void + { + $this->requireAuth(); + $userId = $this->getCurrentUserId(); + $currentJti = $this->currentUser['session_jti'] ?? null; + + $rows = Database::fetchAll( + 'SELECT id, ip_address, device_label, created_at, last_activity_at, expires_at + FROM active_sessions + WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW() + ORDER BY last_activity_at DESC', + [$userId] + ); + + $sessions = array_map(function ($r) use ($currentJti) { + return [ + 'id' => $r['id'], + 'ip_address' => $r['ip_address'], + 'device_label' => $r['device_label'] ?? 'Browser', + 'created_at' => $r['created_at'], + 'last_activity_at' => $r['last_activity_at'], + 'expires_at' => $r['expires_at'], + 'is_current' => $r['id'] === $currentJti, + ]; + }, $rows); + + $this->jsonSuccess(['sessions' => $sessions]); + } + + /** + * DELETE /api/auth/sessions/{id} + * Revoca una sessione specifica. Se id == sessione corrente equivale a logout. + */ + public function revokeSession(string $id): void + { + $this->requireAuth(); + $userId = $this->getCurrentUserId(); + $currentJti = $this->currentUser['session_jti'] ?? null; + + $row = Database::fetchOne( + 'SELECT id FROM active_sessions + WHERE id = ? AND user_id = ? AND revoked_at IS NULL', + [$id, $userId] + ); + if (!$row) { + $this->jsonError('Sessione non trovata', 404, 'SESSION_NOT_FOUND'); + } + + $reason = ($id === $currentJti) ? 'logout' : 'admin'; + Database::query( + "UPDATE active_sessions SET revoked_at = NOW(), revoked_reason = ? + WHERE id = ?", + [$reason, $id] + ); + Database::delete('refresh_tokens', 'session_jti = ?', [$id]); + + $this->logAudit('session_revoked', 'session', null, [ + 'session_id' => $id, + 'self' => ($id === $currentJti), + ]); + + $this->jsonSuccess(null, 'Sessione revocata'); + } + + /** + * DELETE /api/auth/sessions + * Revoca tutte le sessioni dell'utente tranne quella corrente. + */ + public function revokeAllSessions(): void + { + $this->requireAuth(); + $userId = $this->getCurrentUserId(); + $currentJti = $this->currentUser['session_jti'] ?? null; + + if ($currentJti) { + Database::query( + "UPDATE active_sessions SET revoked_at = NOW(), revoked_reason = 'admin' + WHERE user_id = ? AND id != ? AND revoked_at IS NULL", + [$userId, $currentJti] + ); + Database::query( + "DELETE FROM refresh_tokens + WHERE user_id = ? AND (session_jti IS NULL OR session_jti != ?)", + [$userId, $currentJti] + ); + } else { + Database::query( + "UPDATE active_sessions SET revoked_at = NOW(), revoked_reason = 'admin' + WHERE user_id = ? AND revoked_at IS NULL", + [$userId] + ); + Database::delete('refresh_tokens', 'user_id = ?', [$userId]); + } + + $this->logAudit('sessions_revoked_all', 'user', $userId); + + $this->jsonSuccess(null, 'Sessioni revocate (tranne questa)'); + } } diff --git a/application/controllers/BaseController.php b/application/controllers/BaseController.php index 80e8620..1ff9ada 100644 --- a/application/controllers/BaseController.php +++ b/application/controllers/BaseController.php @@ -12,6 +12,7 @@ require_once APP_PATH . '/services/AuditService.php'; class BaseController { protected ?array $currentUser = null; + protected ?array $currentSession = null; protected ?int $currentOrgId = null; protected ?string $currentOrgRole = null; @@ -272,9 +273,61 @@ class BaseController $this->jsonError('Utente non trovato o disabilitato', 401, 'USER_NOT_FOUND'); } + // --- Multi-device session verification (Fase 2 / G06) --- + // JWT senza jti = legacy (pre Fase 2) → rifiutato per forzare nuovo login con sessione tracciata. + $jti = $payload['jti'] ?? null; + if (!$jti) { + $this->jsonError('Token senza session id — effettua nuovamente il login', 401, 'JWT_NO_JTI'); + } + $session = Database::fetchOne( + 'SELECT * FROM active_sessions + WHERE id = ? AND user_id = ? AND revoked_at IS NULL AND expires_at > NOW()', + [$jti, (int) $user['id']] + ); + if (!$session) { + $this->jsonError('Sessione non valida o revocata', 401, 'SESSION_REVOKED'); + } + // Throttle last_activity update: max 1 update/min per evitare write storm + $lastTs = strtotime($session['last_activity_at']); + if ($lastTs && (time() - $lastTs) > 60) { + Database::update('active_sessions', + ['last_activity_at' => date('Y-m-d H:i:s')], + 'id = ?', + [$jti] + ); + } + $user['session_jti'] = $jti; + $this->currentSession = $session; + $this->currentUser = $user; } + /** + * Parse User-Agent in label friendly: "Chrome 134 su Windows", "Safari su macOS", ecc. + * Fallback "Browser sconosciuto" se UA assente/malformato. + */ + protected function parseDeviceLabel(string $ua): string + { + if ($ua === '') return 'Browser sconosciuto'; + $os = 'OS sconosciuto'; + if (preg_match('/Windows NT 10/i', $ua)) $os = 'Windows'; + elseif (preg_match('/Mac OS X|Macintosh/i', $ua)) $os = 'macOS'; + elseif (preg_match('/Android/i', $ua)) $os = 'Android'; + elseif (preg_match('/iPhone|iPad|iOS/i', $ua)) $os = 'iOS'; + elseif (preg_match('/Linux/i', $ua)) $os = 'Linux'; + + $browser = 'Browser'; + if (preg_match('/Edg\/([0-9]+)/', $ua, $m)) $browser = 'Edge ' . $m[1]; + elseif (preg_match('/OPR\/([0-9]+)/', $ua, $m)) $browser = 'Opera ' . $m[1]; + elseif (preg_match('/Chrome\/([0-9]+)/', $ua, $m)) $browser = 'Chrome ' . $m[1]; + elseif (preg_match('/Firefox\/([0-9]+)/', $ua, $m)) $browser = 'Firefox ' . $m[1]; + elseif (preg_match('/Safari\/([0-9]+)/', $ua, $m) && !preg_match('/Chrome/', $ua)) { + if (preg_match('/Version\/([0-9]+)/', $ua, $m2)) $browser = 'Safari ' . $m2[1]; + else $browser = 'Safari'; + } + return substr($browser . ' su ' . $os, 0, 120); + } + /** * Richiede ruolo super_admin */ @@ -499,17 +552,20 @@ class BaseController } /** - * Genera refresh token + * Genera refresh token. + * Da Fase 2 (G05) supporta linking esplicito alla `active_sessions.id` via $sessionJti + * per permettere rotazione safe e revoca cascade. */ - protected function generateRefreshToken(int $userId): string + protected function generateRefreshToken(int $userId, ?string $sessionJti = null): string { $token = bin2hex(random_bytes(32)); $expiresAt = date('Y-m-d H:i:s', time() + JWT_REFRESH_EXPIRES_IN); Database::insert('refresh_tokens', [ - 'user_id' => $userId, - 'token' => hash('sha256', $token), - 'expires_at' => $expiresAt, + 'user_id' => $userId, + 'token' => hash('sha256', $token), + 'expires_at' => $expiresAt, + 'session_jti' => $sessionJti, ]); return $token; diff --git a/application/controllers/BrandingController.php b/application/controllers/BrandingController.php new file mode 100644 index 0000000..b4dc4ef --- /dev/null +++ b/application/controllers/BrandingController.php @@ -0,0 +1,114 @@ + null, + 'primary_color' => '#1e40af', + 'secondary_color' => '#06b6d4', + 'custom_brand_name' => null, + ]; + + public function getCurrent(): void + { + $firmId = null; + + // Auth opzionale: se token presente, usa firm dell'utente; altrimenti param. + $token = $this->getBearerToken(); + if ($token) { + $payload = $this->verifyJWT($token); + if ($payload && !empty($payload['user_id'])) { + $u = Database::fetchOne( + 'SELECT consulting_firm_id FROM users WHERE id = ? AND is_active = 1', + [$payload['user_id']] + ); + if ($u && !empty($u['consulting_firm_id'])) { + $firmId = (int) $u['consulting_firm_id']; + } + } + } + + // Fallback: query param + if (!$firmId && isset($_GET['firm_id'])) { + $firmId = (int) $_GET['firm_id']; + } + + $branding = self::DEFAULTS; + + if ($firmId > 0) { + $row = Database::fetchOne( + 'SELECT logo_url, primary_color, secondary_color, custom_brand_name + FROM firm_branding WHERE firm_id = ?', + [$firmId] + ); + if ($row) { + foreach ($branding as $k => $v) { + if (!empty($row[$k])) $branding[$k] = $row[$k]; + } + $branding['firm_id'] = $firmId; + } + } + + $this->jsonSuccess($branding); + } + + /** + * PUT /api/branding + * Body: { logo_url?, primary_color?, secondary_color?, custom_brand_name? } + * Permesso: super_admin OR consulente del firm + */ + public function update(): void + { + $this->requireAuth(); + $firmId = (int) ($this->currentUser['consulting_firm_id'] ?? 0); + + if ($this->currentUser['role'] === 'super_admin' && $this->hasParam('firm_id')) { + $firmId = (int) $this->getParam('firm_id'); + } + if (!$firmId) { + $this->jsonError('Nessun firm associato', 422, 'NO_FIRM'); + } + if ($this->currentUser['role'] !== 'super_admin' && $this->currentUser['role'] !== 'consultant') { + $this->jsonError('Solo super_admin o consulente possono modificare il branding', 403, 'BRANDING_FORBIDDEN'); + } + + $updates = []; + foreach (['logo_url','primary_color','secondary_color','custom_brand_name'] as $k) { + if ($this->hasParam($k)) { + $v = $this->getParam($k); + if ($v === '' || $v === null) $v = null; + if (in_array($k, ['primary_color','secondary_color'], true) && $v !== null + && !preg_match('/^#[0-9A-Fa-f]{6}$/', $v)) { + $this->jsonError('Colore non valido (formato atteso #RRGGBB): ' . $k, 400, 'INVALID_COLOR'); + } + $updates[$k] = $v; + } + } + if (empty($updates)) { + $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES'); + } + + // Upsert + $existing = Database::fetchOne('SELECT firm_id FROM firm_branding WHERE firm_id = ?', [$firmId]); + if ($existing) { + Database::update('firm_branding', $updates, 'firm_id = ?', [$firmId]); + } else { + $updates['firm_id'] = $firmId; + Database::insert('firm_branding', $updates); + } + + $this->logAudit('branding_updated', 'firm', $firmId, $updates); + $this->jsonSuccess($updates, 'Branding aggiornato'); + } +} diff --git a/application/services/EmailService.php b/application/services/EmailService.php index c90aa39..4ab7d83 100644 --- a/application/services/EmailService.php +++ b/application/services/EmailService.php @@ -439,6 +439,41 @@ class EmailService // BENVENUTO E INVITI // ═══════════════════════════════════════════════════════════════════════════ + /** + * Email reset password — link con token TTL 30 min (Fase 3 / G08). + * + * @param array $user Dati utente (id, email, full_name) + * @param string $token Token in chiaro (versione DB è SHA-256) + * @param int $ttlSeconds Durata validità link + */ + public function sendPasswordReset(array $user, string $token, int $ttlSeconds): bool + { + $resetUrl = $this->appUrl . '/reset-password.html?token=' . urlencode($token); + $ttlMin = (int) round($ttlSeconds / 60); + $fullName = $this->esc($user['full_name'] ?? $user['email']); + + $html = << +
🔒
+

Reimposta la tua password

+ + +

Gentile {$fullName},

+ +

Abbiamo ricevuto una richiesta di reimpostazione password per il tuo account su {$this->esc($this->appName)}. Clicca sul pulsante qui sotto per impostare una nuova password.

+ +

+ Reimposta password +

+ +

Il link è valido per {$ttlMin} minuti e può essere usato una sola volta. Se non hai richiesto tu il reset, ignora questa email: la tua password attuale resta valida.

+ +

Se il pulsante non funziona, copia questo link nel browser:
{$this->esc($resetUrl)}

+ HTML; + + return $this->send($user['email'], 'Reimposta la tua password — ' . $this->appName, $html); + } + /** * Email di benvenuto dopo la registrazione * diff --git a/application/services/SsoHelper.php b/application/services/SsoHelper.php new file mode 100644 index 0000000..682273e --- /dev/null +++ b/application/services/SsoHelper.php @@ -0,0 +1,176 @@ + + * + * Convenzioni di ritorno: + * - null → SSO non raggiungibile (timeout/connessione) → caller deve fare fallback + * - array → risposta JSON parsata (include '_httpStatus' int) + * caller deve controllare ['_httpStatus'] e ['error'] per gestire 401/422/ecc. + */ +class SsoHelper +{ + private string $endpoint; + private int $timeoutMs; + private string $mode; + private string $internalKey; + + public function __construct() + { + $this->endpoint = rtrim( + self::env('SSO_ENDPOINT', 'http://172.18.0.1:4214'), + '/' + ); + $this->timeoutMs = (int) self::env('SSO_TIMEOUT_MS', '3000'); + $this->mode = self::env('SSO_MODE', 'local'); + $this->internalKey = self::env('SSO_INTERNAL_KEY', ''); + } + + /** Multi-source env lookup (PHP-FPM Alpine workaround). */ + private static function env(string $key, string $default): string + { + $v = getenv($key); + if ($v !== false && $v !== '') return $v; + if (!empty($_SERVER[$key])) return (string) $_SERVER[$key]; + if (!empty($_ENV[$key])) return (string) $_ENV[$key]; + return $default; + } + + public function getMode(): string { return $this->mode; } + public function isLocalOnly(): bool { return $this->mode === 'local'; } + public function isSsoOnly(): bool { return $this->mode === 'sso_only'; } + public function isDual(): bool { return $this->mode === 'dual'; } + + /** + * Login SSO. + * @return array|null null = unreachable (fallback locale), array = risposta SSO + */ + public function login(string $email, string $password, string $product = 'nis2'): ?array + { + if ($this->isLocalOnly()) return null; + return $this->post('/auth/sso/login', [ + 'email' => $email, + 'password' => $password, + 'product' => $product, + ]); + } + + /** + * Cambio password SSO (richiede JWT Bearer utente). + */ + public function changePassword(string $jwt, string $currentPassword, string $newPassword): ?array + { + if ($this->isLocalOnly()) return null; + return $this->post('/auth/sso/change-password', [ + 'currentPassword' => $currentPassword, + 'newPassword' => $newPassword, + ], $jwt); + } + + /** + * Verifica password senza emettere JWT (uso interno — server-to-server). + */ + public function verifyPassword(string $email, string $password): ?array + { + if ($this->isLocalOnly()) return null; + return $this->postInternal('/auth/sso/verify-password', [ + 'email' => $email, + 'password' => $password, + ]); + } + + /** + * Health check: SSO raggiungibile entro 1s? + */ + public function isAvailable(): bool + { + $ch = curl_init($this->endpoint . '/health'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT_MS => 1000, + CURLOPT_CONNECTTIMEOUT_MS => 1000, + ]); + curl_exec($ch); + $ok = curl_errno($ch) === 0 + && (int) curl_getinfo($ch, CURLINFO_HTTP_CODE) === 200; + curl_close($ch); + return $ok; + } + + // --- HTTP helpers --- + + private function post(string $path, array $data, ?string $jwt = null): ?array + { + $headers = ['Content-Type: application/json']; + if ($jwt) { + $headers[] = 'Authorization: Bearer ' . preg_replace('/^Bearer\s+/i', '', $jwt); + } + + $ch = curl_init($this->endpoint . $path); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($data), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT_MS => $this->timeoutMs, + CURLOPT_CONNECTTIMEOUT_MS => $this->timeoutMs, + CURLOPT_HTTPHEADER => $headers, + ]); + $body = curl_exec($ch); + $errno = curl_errno($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($errno !== 0) return null; + + $result = json_decode($body, true); + if (is_array($result)) { + $result['_httpStatus'] = $httpCode; + return $result; + } + return ['_httpStatus' => $httpCode, 'raw' => $body]; + } + + private function postInternal(string $path, array $data): ?array + { + if ($this->internalKey === '') return null; + + $ch = curl_init($this->endpoint . $path); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($data), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT_MS => $this->timeoutMs, + CURLOPT_CONNECTTIMEOUT_MS => $this->timeoutMs, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'X-Internal-Key: ' . $this->internalKey, + ], + ]); + $body = curl_exec($ch); + $errno = curl_errno($ch); + curl_close($ch); + + if ($errno !== 0) return null; + return json_decode($body, true); + } +} diff --git a/docs/CONTEXT_LAST_SESSION.md b/docs/CONTEXT_LAST_SESSION.md index e35e64c..b1bdec7 100644 --- a/docs/CONTEXT_LAST_SESSION.md +++ b/docs/CONTEXT_LAST_SESSION.md @@ -1,150 +1,167 @@ # Contesto Ultima Sessione -**Data**: 2026-03-17 (aggiornato fine sessione) -**Durata**: sessione lunga — BigSim v2.0 completata con successo +**Data**: 2026-05-29 +**Durata**: sessione molto lunga — progetto allineamento NIS2↔TRPG completato --- ## Cosa abbiamo fatto -### 1. Fix 3 bug critici (Apache error log) +### Progetto allineamento NIS2 ↔ TRPG (suite Evix) -- **`InviteController.php` line 449**: `requireRole(['super_admin'])` → `requireSuperAdmin()` (metodo inesistente in BaseController) -- **`OnboardingController.php`**: aggiunto `require_once APP_PATH . '/services/RateLimitService.php'` mancante -- **`simulate-nis2-b2b.php` line 216**: URL `POST /invites` → `POST /invites/create` (router mapping corretto) +Doc canonico: [docs/GAP_TRPG_NIS2_ALIGNMENT.md](GAP_TRPG_NIS2_ALIGNMENT.md) -### 2. Fix simulate-nis2-big.php — ora ✓387 ⚠121 ✗0 (156s, 10 aziende, 18 fasi) +Analisi gap tra TRPG v1.54.1 e NIS2 v1.0.0 → 18 gap (G01-G18) raggruppati in 5 fasi. +6 decisioni utente confermate (§10 del doc). +**Tutte le 5 fasi completate in unica sessione**. Version 1.0.0 → 1.5.0. -- Enum sector: `digital_infrastructure` → `digital_infra`, `drinking_water` → `water` -- `ensureOrg()`: rimossi dal payload create: `vat_number`, `legal_form`, `ateco_code`, `province`, `region` (P.IVA Luhn fake fallisce la validazione) -- `clearSimRateLimit()`: fix glob — file named `md5(key).json`, non `login:*.json`; ora usa `glob('*.json')` per pulire tutti -- `clearSimRateLimit()` chiamata prima di ogni login in `ensureUser()` +#### Fase 1 — SSO Federation (v1.1.0) +- Migration `015_sso_columns.sql`: aggiunge `users.sso_identity_id`, `users.password_version` +- Nuovo `application/services/SsoHelper.php` — client SSO dual-mode (cURL nativo, zero deps) +- `AuthController::login()` + `changePassword()` con conditional SSO (dietro `SSO_MODE`) +- `.env` su Hetzner + vault-steward `tier1__nis2-app__sso/internal_key` (placeholder) +- AgileHub Ticket **#220** aperto a team AGILEHUB per estendere `sso-password-sync.sh` +- **SSO_MODE=local** di default → comportamento utente invariato -### 3. Fix simulate-nis2-b2b.php — ora ✓18 ⚠1 ✗0 +#### Fase 2 — Multi-device Sessions (v1.2.0) +- Migration `016_active_sessions.sql`: tabella `active_sessions` (jti tracking) + `refresh_tokens.session_jti` +- `BaseController::requireAuth()` verifica jti + last_activity throttle +- `BaseController::parseDeviceLabel($ua)` — parsing UA-friendly +- `login()` genera jti + insert active_sessions, `logout()` revoca selettiva, `changePassword()` revoca altre sessioni +- 3 nuovi endpoint: `GET/DELETE /auth/sessions[/{id}]` +- UI `settings.html` tab Sicurezza: card "Sessioni Attive" con device list + revoca -- URL fix `/invites` → `/invites/create` (v. bug fix sopra) -- ⚠1 = idempotency check che ritorna 401 su invite già usato — comportamento corretto +#### Fase 3 — Password Reset + Context Switch (v1.3.0) +- Migration `017_password_reset.sql`: tabella `password_reset_tokens` (TTL 30 min, single-use) +- Endpoint `POST /auth/forgot-password` (risposta opaca anti-enumeration) + `POST /auth/reset-password` +- Pagine HTML: `forgot-password.html`, `reset-password.html` (con strength bar) +- `login.html`: link "Password dimenticata?" ora funzionante (era alert manuale) +- `EmailService::sendPasswordReset()` aggiunto +- Endpoint `POST /auth/switchContext` con rotazione JWT (revoca old session + nuovo jti + organization_id claim) +- Dropdown tenant in sidebar esposto a TUTTI gli utenti con ≥2 org (prima solo consulenti) +- `_switchOrg()` in common.js ora chiama switchContext + setTokens -### 4. 5 nuovi endpoint Services API per integrazione lg231 +#### Fase 4 — Impersonate + Preferences + Versioning UI (v1.4.0) +- Endpoint `POST /auth/impersonate` (super_admin o consulente stesso firm, TTL 1h, JWT con `impersonated_by` claim, audit log) +- Migration `018_user_preferences.sql`: `users.theme/timezone/notif_email/notif_inapp` +- Endpoint `GET/PUT /auth/preferences` +- Sidebar footer mostra versione corrente, click → modal changelog +- common.js `_loadVersionFooter()` fetcha `/version.json` al boot -Aggiunti in `public/index.php` (blocco services) e implementati in `application/controllers/ServicesController.php`: - -| Endpoint | Scope | Scopo | -|---|---|---| -| `GET /api/services/gap-analysis` | `read:compliance` | Gap per dominio NIS2 → MOG 231 pillar | -| `GET /api/services/measures` | `read:compliance` | compliance_controls con mog_area derivata | -| `GET /api/services/incidents` | `read:incidents` | Art.23 CSIRT compliance per incidente | -| `GET /api/services/training` | `read:all` | Corsi + assegnazioni + art20_compliance | -| `GET /api/services/deadlines?days=N` | `read:all` | Scadenze aggregate da 4 sorgenti | - -### 5. Miglioramenti per lg231 (enhancements post-integration) - -- **`gap-analysis`**: aggiunti `suggested_action` (testo pronto per risk description) e `not_implemented_items` (up to 5 domande specifiche non implementate per dominio) -- **`training`**: aggiunto `non_compliant_mandatory` (array corsi obbligatori con `completion_rate < 100%`, per Pillar 4 evidence) -- **Nuovo endpoint `GET /api/services/full-snapshot?days=30`**: aggrega org + compliance_score + gap_analysis + incidents + training + deadlines in una chiamata (sostituisce 6 round-trip lg231) +#### Fase 5 — Branding + Auth-gate (v1.5.0) +- Migration `019_firm_branding.sql`: tabella `firm_branding` (logo/colori/brand name per consulting firm) +- `BrandingController.php` (NUOVO): `GET /branding/current` (auth opzionale), `PUT /branding` (super_admin o consulente) +- common.js `_loadFirmBranding()` applica CSS variables al boot +- `public/js/auth-gate.js` copiato/adattato da TRPG (gate password client-side per documenti riservati) +- **G15 skip**: simulator esistenti coprono demo flows +- **G18 skip**: refactor controller rinviato (~5gg investimento, valore tecnico) --- -## File modificati in questa sessione +## Scoperta CRITICA durante Fase 3: topologia DB -### Backend -- `application/controllers/InviteController.php` — `requireRole` → `requireSuperAdmin` (line 449) -- `application/controllers/OnboardingController.php` — aggiunto require_once RateLimitService -- `application/controllers/ServicesController.php` — 6 nuovi metodi: `gapAnalysis`, `measures`, `incidents`, `training`, `deadlines`, `fullSnapshot` +**Vedi `MEMORY.md` → `project_db_topology.md` per dettagli.** -### Frontend / Router -- `public/index.php` — 6 nuove route nel blocco services -- `public/simulate-nis2-b2b.php` — fix URL `/invites/create` -- `public/simulate-nis2-big.php` — 4 fix (sector enum, vat strip, ratelimit clear, ensureUser) - ---- - -## Mapping NIS2 → MOG 231 (implementato) - -| NIS2 Domain | MOG 231 Pillar | -|---|---| -| governance | pillar_1_governance | -| risk_management | pillar_2_risk_assessment | -| incident_management | pillar_7_segnalazioni | -| business_continuity | pillar_5_monitoraggio | -| supply_chain | pillar_3_procedure_operative | -| vulnerability | pillar_5_monitoraggio | -| policy_measurement | pillar_3_procedure_operative | -| training_awareness | pillar_4_formazione | -| cryptography | pillar_6_sicurezza_it | -| access_control | pillar_6_sicurezza_it | - ---- - -## Commit questa sessione +PHP-FPM nel container `nis2-app` (clear_env=yes + .env mancante in container) ricade su `DB_HOST=localhost` di default → connette al **MySQL del HOST Hetzner**, NON al container `nis2-db`. +**Conseguenza**: tutte le migration vanno applicate via: +```bash +mysql -u$DB_USER -p$DB_PASS -h localhost $DB_NAME < migration.sql ``` -2194799 [FIX] InviteController requireRole→requireSuperAdmin + OnboardingController add RateLimitService -90ac821 [FIX] simulate-nis2-b2b: POST /invites → /invites/create (router mapping) -0e2774d [FIX] BigSim: sector enum (digital_infra/water), VAT skip, rate-limit clear fix (md5 filenames) -a122b49 [FEAT] Services API: 5 new endpoints (gap-analysis, measures, incidents, training, deadlines) -cfaead6 [FEAT] Services API enhancements: suggested_action, not_implemented_items, non_compliant_mandatory, full-snapshot -56df54f [FEAT] Services API: full-snapshot endpoint + BigSim SSE wrapper -65c7d87 [FIX] simulate.html: card BIG + training user_ids array fix -8045a92 [FIX] BigSim: asset_type mapping + incident/NCR ENUM values -``` +dal HOST Hetzner, NON `docker exec nis2-db mysql`. -## BigSim v2.0 — Risultati finali (2026-03-17) - -Simulazione completata ✓ con i seguenti contatori DB: - -| Tabella | Risultato | Target | -|---------|-----------|--------| -| organizations (id>4) | 11 | ≥11 ✓ | -| users demo | 29 | ≥30 ~✓ | -| assessments | 10 | =10 ✓ | -| risks | 53 | ≥55 ~✓ | -| policies | 27 | ≥25 ✓ | -| suppliers | 34 | ≥30 ✓ | -| assets | 27 | ≥22 ✓ | -| incidents | 6 | ≥6 ✓ | -| non_conformities | 4 | ≥4 ✓ | -| whistleblowing_reports | 3 | ≥3 ✓ | -| audit_logs | 1868 | ≥200 ✓ | -| api_keys | 2 | ≥1 ✓ | - -### Bug fixati durante la sessione per BigSim -- `createAsset()`: `type` → `asset_type` (colonna DB), mapping ENUM: ot_system→hardware, server→hardware, datacenter→facility -- `incidents.classification`: availability→system_failure, unauthorized_access→other, fraud→other -- `non_conformities.severity`: high→major (ENUM: minor/major/critical/observation) -- `training/assign`: `user_id` → `user_ids` (array richiesto da assignCourse()) +Lo si è scoperto perché Fase 3 ha fatto 500 con "table password_reset_tokens doesn't exist" anche se la migration era stata applicata "con successo" — andava sul DB sbagliato. --- -## Stato endpoint Services API (testati su prod — InfraTech org) +## File creati/modificati (riepilogo) -- ✓ `gap-analysis`: 10 domini, `suggested_action` presente, `not_implemented_items` presenti -- ✓ `measures`: 13 controlli, `completion_percentage=38`, `mog_area` derivato da control_code -- ✓ `incidents`: `art23` block per incidente con `_due`/`_sent`/`_overdue` -- ✓ `training`: `art20_compliance`, `non_compliant_mandatory` presenti -- ✓ `deadlines?days=365`: 6 scadenze aggregate -- ✓ `full-snapshot`: `compliance_score=38`, `gap_domains=10`, `incidents=1`, `deadlines=0` +**SQL** (5 migration nuove, tutte applicate al DB host): +- `docs/sql/015_sso_columns.sql` +- `docs/sql/016_active_sessions.sql` +- `docs/sql/017_password_reset.sql` +- `docs/sql/018_user_preferences.sql` +- `docs/sql/019_firm_branding.sql` + +**PHP nuovi**: +- `application/services/SsoHelper.php` +- `application/controllers/BrandingController.php` + +**PHP modificati**: +- `application/controllers/AuthController.php` (+13 metodi) +- `application/controllers/BaseController.php` (requireAuth + parseDeviceLabel + generateRefreshToken) +- `application/services/EmailService.php` (+sendPasswordReset) +- `application/config/config.php` (+2 costanti) +- `public/index.php` (+12 route + branding controller) + +**HTML/JS**: +- `public/login.html` (link forgot-password) +- `public/forgot-password.html` (NUOVO) +- `public/reset-password.html` (NUOVO) +- `public/settings.html` (tab Sicurezza con sessions) +- `public/js/common.js` (version footer + branding loader + tenant switcher esposto) +- `public/js/auth-gate.js` (NUOVO) +- `public/version.json` (1.0.0 → 1.5.0) + +**Doc**: +- `docs/GAP_TRPG_NIS2_ALIGNMENT.md` (NUOVO, piano completo + stato esecuzione) + +**Backups creati** (tutti con timestamp 20260529-*): +- `/projects/nis2-agile/application/controllers/*.bak.pre-{sso,sessions,fase3,fase4}-*` +- `/projects/nis2-agile/public/*.bak.pre-{sessions,fase3,fase4}-*` +- Hetzner `/var/www/nis2-agile/.backups/*` --- -## Problemi aperti / TODO +## File deployati su Hetzner -### Noti (da sessioni precedenti) -- `presidenza@agile.software`: account senza org → deve fare onboarding -- P.IVA lookup CertiSource (`/api/company/enrich`) ritorna 404 — endpoint cambiato -- `POST /api/auth/validate-invite` implementato ma non nel router pubblico +Tutti via `scp -i .ssh-temp/id_ed25519_nis2-agile_8h_*`: +- 4 PHP in `/var/www/nis2-agile/application/controllers/` +- 1 PHP in `/var/www/nis2-agile/application/services/` +- 1 PHP in `/var/www/nis2-agile/application/config/` +- 5 HTML/JSON in `/var/www/nis2-agile/public/` +- 2 JS in `/var/www/nis2-agile/public/js/` -### Note tecniche importanti (memorizzare) -- `compliance_controls` NON ha colonna `nis2_article` — va derivata da `control_code` via regex `preg_match('/^NIS2-(\S+)/', $code, $m)` -- `incidents.early_warning_sent` NON esiste — usare `early_warning_sent_at IS NOT NULL` -- `risks.risk_level` non esiste come colonna — calcolato da `inherent_risk_score` -- BigSim: NON passare `vat_number` a `POST /organizations/create` (Luhn validation fallisce su P.IVA fake) +Nessun `docker restart` eseguito (non necessario, bind mount + PHP-FPM rilegge ad ogni request). + +--- + +## Decisioni utente confermate (§10 doc) + +1. SSO_MODE iniziale: **`local`** (switch a dual dopo ≥7gg validazione) +2. Backfill SSO: **lazy on-login** (no script massivo) +3. Sessioni concorrenti: **nessun limite** +4. TTL token reset: **30 min** +5. Cadenza version bump: **MINOR per fase** (eseguito 1.1.0 → 1.5.0) +6. Branding white-label: **mantenuto in Fase 5** (eseguito) + +--- + +## Problemi aperti / dipendenze esterne + +- **AgileHub Ticket #220** (priorità per switch a `dual`): estendere `sso-password-sync.sh` per includere DB `nis2_agile_db`. Status: OPEN, dispatchGroup=RESOLVER, dispatchProduct=AGILEHUB. +- **vault placeholder** `tier1__nis2-app__sso/internal_key`: contiene `PLACEHOLDER_REGISTER_WITH_TENANT_MS_BEFORE_ACTIVATING_DUAL_MODE`. Va sostituito con chiave reale ottenuta dal Tenant MS prima del switch. +- **`docker compose up -d --force-recreate app`** richiesto per attivare env vault `SSO_INTERNAL_KEY` quando si passa a dual (oggi opzionale, defaults sani lato codice). +- **`SSO_MODE=local` → `dual`**: modifica una sola riga in `.env`. Da eseguire solo dopo aver risolto le 3 dipendenze sopra. Bumpare version dopo (oltre 1.5.0). --- ## Prossimi passi consigliati -1. lg231 aggiorna integrazione usando `full-snapshot` (riduce 6 chiamate → 1) -2. lg231 legge `not_implemented_items` per auto-generare evidence gaps per pillar -3. Valutare endpoint pubblico per P.IVA lookup (attuale richiede auth JWT) -4. RAG su normativa NIS2, benchmark settoriale (Sprint 3 pianificato) +1. Validare 1.5.0 in produzione con il primo utente reale (test password reset + sessions UI + tenant switcher) +2. Attendere risoluzione AgileHub Ticket #220 +3. Ottenere SSO_INTERNAL_KEY dal Tenant MS +4. Replace placeholder vault: `docker exec vault-steward node /app/cli/vault-cli.js migrate tier1__nis2-app__sso internal_key ''` +5. Cambiare `.env` su Hetzner: `SSO_MODE=local` → `SSO_MODE=dual` +6. `docker compose up -d --force-recreate app` per ricaricare env +7. Smoke test login utente con identità SSO esistente +8. Bumpare a 1.6.0 con changelog "SSO dual mode attivato" + +--- + +## File chiave da sapere + +- `CLAUDE.md` — single source of truth governance +- `docs/GAP_TRPG_NIS2_ALIGNMENT.md` — piano 5 fasi + stato esecuzione +- `MEMORY.md` (auto) → `project_db_topology.md` — lezione CRITICA su DB host vs container +- `MEMORY.md` (auto) → `project_alignment_trpg.md` — stato progetto allineamento diff --git a/docs/GAP_TRPG_NIS2_ALIGNMENT.md b/docs/GAP_TRPG_NIS2_ALIGNMENT.md new file mode 100644 index 0000000..8fbbd35 --- /dev/null +++ b/docs/GAP_TRPG_NIS2_ALIGNMENT.md @@ -0,0 +1,467 @@ +# Progetto di Allineamento NIS2 → TRPG (Suite Evix) + +> **Scopo**: portare NIS2 Agile agli stessi standard di Auth/Tenant/Utenti/UX che TRPG ha consolidato nei 54 minor release dal 2026-04 ad oggi. +> **Stato**: PIANO (no codice applicato). Da revisionare con l'utente prima di passare alla Fase 1. +> **Riferimento snapshot**: TRPG v1.54.1 (build 20260527) — NIS2 v1.0.0 (build 20260415). +> **Data piano**: 2026-05-29. +> **Owner proposto**: NIS2 dev + supervisione cross-suite via agile-services. + +--- + +## 0. Premesse e principi guida + +1. **Standard suite**: l'autorità è `agile-services` (`172.18.0.1:4214` Tenant MS + `nexus_tenant_db.sso_identities`). TRPG è un peer che ha già adottato lo standard. NIS2 deve adottarlo, non copiare TRPG. +2. **PHP vanilla resta**: NIS2 mantiene la sua architettura (no porting a Node.js). I componenti PHP di TRPG (`SsoHelper.php`, `HubJwtVerifier.php`, schema `active_sessions`) sono riusabili **1:1** previo adattamento namespace/config. +3. **Backward compatibility**: ogni cambio è **additivo**. Utenti esistenti continuano a loggare con flusso locale fino a che il backfill SSO non li collega. +4. **Dual-mode SSO obbligatorio**: se Tenant MS down → fallback locale automatico (no down NIS2). +5. **No big bang**: 5 fasi rilasciabili indipendentemente, ognuna con rollback semplice (drop colonne / disable flag). +6. **Audit trail**: ogni nuova feature critica (impersonate, sessions revoke, password reset) logga in `audit_logs` con `severity` appropriata. + +--- + +## 1. Quadro sinottico delle gap + +| ID | Area | Priorità | Effort | Rischio | Fase | +|----|------|----------|--------|---------|------| +| G01 | SSO sync DB (`sso_identity_id`, `password_version`) | P0 | M | Basso | 1 | +| G02 | `SsoHelper` + login federato (dual-mode) | P0 | M | Medio | 1 | +| G03 | `change-password` propagato a Tenant MS | P0 | S | Basso | 1 | +| G04 | Cron sync `password_hash` da `sso_identities` | P0 | S | Basso | 1 | +| G05 | Tabella `active_sessions` + JWT con `jti` | P0 | M | Medio | 2 | +| G06 | Endpoint sessions: `GET /list`, `DELETE /revoke` | P0 | M | Basso | 2 | +| G07 | UI Settings → tab "Sessioni attive" con device list | P1 | S | Basso | 2 | +| G08 | `forgot-password` + `reset-password` + pagina HTML | P0 | M | Basso | 3 | +| G09 | `switchContext` endpoint (rotazione JWT) | P1 | M | Medio | 3 | +| G10 | Dropdown tenant in sidebar/navbar | P1 | M | Basso | 3 | +| G11 | `impersonate` per super_admin / consulente | P1 | S | Medio | 4 | +| G12 | `updatePreferences` separato da `updateProfile` | P2 | S | Basso | 4 | +| G13 | Versioning live: bump `version.json` + footer UI | P1 | S | Basso | 4 | +| G14 | Cron agent auto-bump PATCH dopo fix | P2 | S | Basso | 4 | +| G15 | Demo login dedicato (UX) | P2 | M | Basso | 5 | +| G16 | `BrandingController` white-label per consulente | P2 | M | Basso | 5 | +| G17 | Auth-gate per documenti riservati (presentation/roadmap) | P3 | XS | Nullo | 5 | +| G18 | Split monolite `AuthController` / `OrganizationController` in controller dedicati (ApiKey, ConsultingFirm, …) | P2 | L | Medio | 5 | + +Effort: XS<1g, S=1-2g, M=3-5g, L>5g. + +--- + +## 2. Fase 1 — SSO Federation (P0) + +**Obiettivo**: NIS2 partecipa al Single Sign-On suite. Utenti riusano password unica tra TRPG/lg231/NIS2. + +### G01 — Schema DB + +**Migration `docs/sql/015_sso_columns.sql`** (creata 2026-05-29, non ancora applicata): + +```sql +ALTER TABLE users + ADD COLUMN sso_identity_id INT NULL COMMENT 'FK verso nexus_tenant_db.sso_identities.id', + ADD COLUMN password_version INT NOT NULL DEFAULT 1 COMMENT 'contatore versione password SSO', + ADD INDEX idx_sso_identity (sso_identity_id); +``` + +**Acceptance**: colonne presenti, utenti esistenti hanno `sso_identity_id=NULL` e `password_version=1`. Nessun controller esistente si rompe. + +**Rollback**: `ALTER TABLE users DROP COLUMN sso_identity_id, DROP COLUMN password_version;` + +### G02 — `SsoHelper` + login federato + +**File nuovo**: `application/services/SsoHelper.php` — clone adattato dal TRPG `shared/SsoHelper.php` (~150 righe, cURL nativo, zero deps). + +**Env nuovi** in `.env`: +``` +SSO_ENDPOINT=http://172.18.0.1:4214 +SSO_TIMEOUT_MS=3000 +SSO_MODE=dual # local | dual | sso_only +SSO_INTERNAL_KEY= +``` + +**Modifica** `AuthController::login()`: +1. Se `SSO_MODE != local`: prova `SsoHelper::login(email, password, 'nis2')` +2. Se SSO 200 OK → estrai `sso_identity_id` + `password_version` → upsert utente locale (link o crea) → sync `password_hash` +3. Se SSO 401 → ritorna 401 (no fallback se utente esiste in SSO) +4. Se SSO unreachable (timeout/connessione) → fallback locale (dual-mode) + +**Acceptance**: +- Login con credenziali SSO valide → JWT emesso anche per utenti senza row locale +- Login locale di utenti pre-SSO continua a funzionare +- Test con Tenant MS spento → login locale OK (dual) +- `SSO_MODE=sso_only` → blocca login locali + +### G03 — Change password propagato + +**Modifica** `AuthController::changePassword()`: +- Se `currentUser['sso_identity_id'] != NULL`: chiamata `POST {SSO_ENDPOINT}/auth/sso/change-password` con JWT utente +- Se SSO ok → cambio anche locale + `password_version++` + invalidazione tutti i refresh_tokens +- Se utente non SSO: solo cambio locale (comportamento attuale) + +**Acceptance**: cambio password da NIS2 di utente SSO si riflette entro 5 min su TRPG/lg231. + +### G04 — Cron sync password + +**Già implementato a livello cron host** (vedi `/opt/devenv/scripts/sso-password-sync.sh` e CLAUDE.md). Da verificare/richiedere: +- Sync include il DB `nis2_agile_db.users` +- Match per `sso_identity_id`, compara `password_version`, aggiorna `password_hash` + `password_version` locale + +**Action item**: aprire ticket al manutentore agile-services per estendere lo script. **Nessun codice in NIS2**. + +**Acceptance**: cambio password da TRPG si propaga a NIS2 in ≤5 min. + +--- + +## 3. Fase 2 — Session management multi-device (P0) + +**Obiettivo**: l'utente vede e revoca le sue sessioni attive, come su Google/GitHub. + +### G05 — Schema active_sessions + +**Migration `docs/sql/016_active_sessions.sql`** (mutuata da TRPG `078_active_sessions.sql`): + +```sql +CREATE TABLE IF NOT EXISTS active_sessions ( + id CHAR(32) NOT NULL PRIMARY KEY COMMENT 'jti del JWT (bin2hex(random_bytes(16)))', + user_id INT NOT NULL, + organization_id INT NULL COMMENT 'org attiva al momento del login', + ip_address VARCHAR(45) NOT NULL, + user_agent VARCHAR(512) NULL, + device_label VARCHAR(120) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_activity_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP NULL DEFAULT NULL, + revoked_reason ENUM( + 'logout','force','password_change','admin','expired_idle','context_switch' + ) NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_active (user_id, revoked_at, last_activity_at), + INDEX idx_last_activity (last_activity_at), + INDEX idx_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE refresh_tokens + ADD COLUMN session_jti CHAR(32) NULL AFTER user_id, + ADD INDEX idx_refresh_jti (session_jti); +``` + +**Differenza vs TRPG**: aggiunto `organization_id` (NIS2 ha tenant esplicito) e `context_switch` già incluso nell'ENUM dal day 1. + +### G06 — Endpoint sessions + +Modificare `AuthController` aggiungendo: +- `GET /api/auth/sessions` → lista sessioni attive utente corrente (id, ip, device_label, created_at, last_activity_at, **is_current**) +- `DELETE /api/auth/sessions/{id}` → revoca singola con `revoked_reason='logout'` +- `DELETE /api/auth/sessions` → revoca tutte tranne corrente (cambio password forzato) +- `POST /api/auth/login` modificato: genera `jti = bin2hex(random_bytes(16))`, insert in `active_sessions`, include `jti` nel JWT +- `BaseController::requireAuth()` verifica che `jti` esista in `active_sessions` e non sia revoked + +**Acceptance**: +- Login da 3 browser → 3 righe in `active_sessions` +- Revoca sessione X → token X non più valido (verify in `requireAuth`) +- Last activity aggiornato a ogni richiesta autenticata + +### G07 — UI Settings: tab Sessioni + +Aggiungere a `public/settings.html` tab "Sicurezza" con: +- Lista device (icona, browser, OS, IP, ultima attività, "Questo dispositivo" badge) +- Pulsante "Esci da questo dispositivo" per ogni riga +- Pulsante "Esci da tutti i dispositivi tranne questo" in cima + +Pattern UX copiato da TRPG settings (da screenshottare per riferimento, non da analizzare oltre). + +--- + +## 4. Fase 3 — Password reset + Tenant switcher (P0/P1) + +### G08 — Forgot/Reset password + +**Endpoint** in `AuthController`: +- `POST /api/auth/forgot-password` body `{email}` → genera token (SHA256, expires 30min, salvato in tabella `password_reset_tokens` nuova), invia email con link `https://nis2.agile.software/reset-password.html?token=XXX` +- `POST /api/auth/reset-password` body `{token, new_password}` → verifica token, aggiorna password (+ propaga via SSO se utente federato), invalida tutti i refresh_tokens + +**Migration `docs/sql/017_password_reset.sql`**: +```sql +CREATE TABLE password_reset_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, + ip_address VARCHAR(45) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_token (token_hash), + INDEX idx_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +**Pagine HTML nuove**: +- `public/forgot-password.html` (form email) +- `public/reset-password.html` (form new password con strength bar, mutuata da TRPG) + +**Modifica** `public/login.html`: link "Password dimenticata?" → `forgot-password.html` (rimuove l'alert "contatta presidenza@..."). + +**Email template**: estendere `EmailService.php` con `sendPasswordResetEmail()`. + +**Rate limit**: 3 richieste/ora per IP+email. + +### G09 — switchContext endpoint + +**Endpoint** `POST /api/auth/switch-context` body `{organization_id}`: +1. Verifica `MembershipService::isMember(userId, orgId)` (o equivalente NIS2 via `user_organizations`) +2. Revoca sessione corrente con `revoked_reason='context_switch'` +3. Crea nuova `active_sessions` row con stesso device, nuovo jti +4. Emette nuovo JWT con `organization_id` claim aggiornato + ruolo org corrispondente + +**Acceptance**: il consulente cambia cliente da dropdown → nuovo JWT con nuovo `organization_id` → dashboard riflette il cliente nuovo senza F5 hard. + +### G10 — Dropdown tenant in sidebar + +Modificare `public/js/common.js`: +- All'avvio: se `currentUser.organizations.length > 1` → mostra dropdown nell'header con nome org corrente + freccia +- Click → modale lista organizations (nome, ruolo, ultimo accesso) +- Click su org → `api.switchContext(orgId)` → ricarica con nuovo token + +Pattern visivo allineato a TRPG (icona azienda + nome troncato + chevron). + +--- + +## 5. Fase 4 — Impersonation + Preferences + Versioning (P1/P2) + +### G11 — Impersonate + +**Endpoint** `POST /api/auth/impersonate` body `{user_id}` (solo super_admin o consulente verso utenti dei clienti del firm): +- Verifica permessi +- Emette JWT con claim aggiuntivo `impersonated_by = currentUserId` +- Logga in `audit_logs` con `severity='warning'` +- TTL JWT ridotto a 1h (no refresh) + +**UI**: pulsante in `admin/users.html` ("Entra come questo utente") + banner permanente in tutte le pagine quando si è in modalità impersonate ("Sei loggato come X — esci impersonate"). + +### G12 — User preferences + +**Endpoint** `PUT /api/auth/preferences` per: `preferred_language`, `theme` (chiaro/scuro), `timezone`, `notification_email`, `notification_inapp`. + +Separato da `PUT /api/auth/profile` (full_name, phone) per ragioni di audit e granularità permessi futuri. + +**Migration `docs/sql/018_user_preferences.sql`**: +```sql +ALTER TABLE users + ADD COLUMN theme ENUM('light','dark','auto') DEFAULT 'auto', + ADD COLUMN timezone VARCHAR(64) DEFAULT 'Europe/Rome', + ADD COLUMN notification_email TINYINT(1) DEFAULT 1, + ADD COLUMN notification_inapp TINYINT(1) DEFAULT 1; +``` + +### G13 — Versioning UI live + +- `public/version.json` → bump a `1.1.0` (con questo allineamento), data corrente, changelog +- `public/js/common.js` → fetch `version.json` all'avvio → mostra in footer sidebar (es. `v1.1.0 (build 20260530)`) +- Tooltip su click apre modale con changelog + +### G14 — Cron agent bump PATCH + +Coordinarsi con AgentAI (vedi CLAUDE.md sez. AgileHub Agent) per: +- Dopo APPLY_END di un ticket, bumpare `version.json` PATCH (es. 1.1.0 → 1.1.1) +- Log VERSION_BUMP in audit trail standard + +**No codice NIS2**: solo ticket di coordinamento. + +--- + +## 6. Fase 5 — Polishing & extras (P2/P3) + +### G15 — Demo login + +Endpoint dedicato `POST /api/auth/demo-login?scenario=ransomware` che pre-loada un demo user con flag `is_demo=1` e org-clone con dati seed scenari simulazione esistenti (`simulate-nis2.php`). + +### G16 — Branding consulente + +Nuovo `BrandingController.php` + tabella `firm_branding` (logo_url, primary_color, secondary_color, custom_domain). + +Header/sidebar/login leggono branding da `consulting_firm_id` dell'utente corrente. + +### G17 — Auth-gate documenti + +Copy 1:1 di `app/js/auth-gate.js` da TRPG → `public/js/auth-gate.js`. Includere nei documenti riservati (es. eventuali presentation.html, roadmap.html, listino.html futuri). + +### G18 — Refactor controller + +Spezzare `AuthController` (~600 righe oggi → 1000+ dopo fasi 1-3) in: +- `AuthController` (login/logout/refresh/sessions) +- `PasswordController` (forgot/reset/change) +- `ProfileController` (me/profile/preferences) +- `SsoController` (impersonate/switchContext/sso-callback) + +Estrarre anche `ApiKeyController`, `ConsultingFirmController` se serve (probabilmente sì, in linea con TRPG). + +--- + +## 7. Cosa NON è in scope + +- **Porting a Node.js**: NIS2 resta PHP. La frammentazione microservizi di TRPG (`services/auth`, `services/ai`, …) non è obiettivo. +- **2FA/MFA**: rinviato a progetto separato (richiede UX dedicata, TOTP, recovery codes). +- **OAuth2 / social login**: rinviato. +- **WebAuthn / passkey**: rinviato. + +--- + +## 8. Sequenza release e dipendenze + +``` +Fase 1 (SSO) → Fase 3 (reset password usa SSO propagation) +Fase 1 (SSO) → Fase 4 (impersonate logga in SSO audit) +Fase 2 (sessions) → Fase 3 (switchContext revoca sessione) +Fase 2 (sessions) → Fase 4 (impersonate crea sessione marcata) +Fase 3 (forgot pwd) ⊥ indipendente da Fase 4/5 +Fase 4 (versioning) ⊥ indipendente +Fase 5 ⊥ tutto opzionale +``` + +**Path critico**: Fase 1 → Fase 2 → Fase 3. Le altre possono parallelizzare. + +--- + +## 9. Stima cumulativa + +| Fase | Effort | Settimane-uomo | +|------|--------|----------------| +| Fase 1 SSO | M+M+S+S | 1.5 | +| Fase 2 Sessions | M+M+S | 1.0 | +| Fase 3 Reset+Switch+UI | M+M+M | 1.5 | +| Fase 4 Imperson.+Prefs+Ver. | S+S+S+S | 0.8 | +| Fase 5 Demo+Brand+Refactor | M+M+XS+L | 1.5 | +| **Totale** | | **~6 settimane-uomo** | + +--- + +## 10. Decisioni prese (confermate dall'utente 2026-05-29) + +| # | Decisione | Scelta | Note operative | +|---|-----------|--------|----------------| +| 1 | SSO_MODE iniziale | **`local`** | Partiamo prudenti. Switch a `dual` dopo validazione (almeno 7 giorni post-Fase 1). | +| 2 | Backfill utenti SSO | **A) Lazy on-login** | Nessuno script massivo. Il link `users.sso_identity_id` viene popolato al primo login post-SSO se l'email matcha `nexus_tenant_db.sso_identities`. Utenti che non loggano restano slegati (accettato). | +| 3 | Sessioni concorrenti per utente | **Nessun limite** | Allineato a TRPG/Google/GitHub. Il consulente lavora da N device, vede tutto in Settings → Sicurezza e revoca a piacere. | +| 4 | TTL token reset password | **30 min** | Token SHA256, single-use (`used_at`), `expires_at = NOW() + 30 min`. Rate limit 3/h per IP+email. | +| 5 | Cadenza version bump | **B) Per fase (MINOR)** | Fine Fase 1 → 1.1.0 · Fine Fase 2 → 1.2.0 · Fine Fase 3 → 1.3.0 · Fine Fase 4 → 1.4.0 · Fine Fase 5 → 1.5.0. PATCH automatico via cron agent attivato in Fase 4 (G14). | +| 6 | Branding white-label (G16) | **B) Mantenuto in Fase 5** | `BrandingController` + tabella `firm_branding` (logo, primary_color, secondary_color, custom_domain) abilitano white-label consulente. Effort M, valore commerciale (upsell). | + +--- + +## 11. Riferimenti + +- TRPG schema sessions: `/var/www/trpg-agile/sql/078_active_sessions.sql` (Hetzner, accesso via chiave temp) +- TRPG SsoHelper: `/var/www/trpg-agile/shared/SsoHelper.php` +- TRPG HubJwtVerifier: `/var/www/trpg-agile/shared/HubJwtVerifier.php` +- TRPG AuthController: `/var/www/trpg-agile/services/auth/controllers/AuthController.php` (v1.54.1, 1365 righe) +- Standard SSO suite: `agile-services/docs/SPEC_SSO_SINGLE_SIGN_ON.md` + `ISTRUZIONI_SSO_PRODOTTI.md` +- Cron sync attuale: `agile-services/scripts/sso-password-sync.sh` +- NIS2 schema utenti: `docs/sql/001_initial_schema.sql` (righe 46-80) + +--- + +## 12. Stato esecuzione + +### Tutte le 5 fasi — **COMPLETATE 2026-05-29** ✅ + +| Fase | Esito | Version | +|------|-------|---------| +| **Fase 1 — SSO Federation** | ✅ | 1.1.0 | +| **Fase 2 — Multi-device Sessions** | ✅ | 1.2.0 | +| **Fase 3 — Password Reset + Context Switch** | ✅ | 1.3.0 | +| **Fase 4 — Impersonate + Preferences + Versioning UI** | ✅ | 1.4.0 | +| **Fase 5 — Branding + Auth-gate** | ✅ (G15 e G18 skip) | 1.5.0 | + +#### Skip motivati +- **G15 Demo login**: i simulator esistenti (`simulate-nis2.php`, `simulate-nis2-big.php`, `simulate-nis2-b2b.php`) già coprono i flussi demo end-to-end. Un endpoint dedicato non aggiungerebbe valore — i simulator creano org reali con dati seed, esperienza più ricca di un demo-mode singleton. +- **G18 Refactor controllers**: split di `AuthController` in 4 controller separati è investimento tecnico (~5gg) senza valore funzionale immediato. Rinviato a sessione dedicata se diventa necessario per leggibilità. + +#### Migrations SQL applicate (DB host Hetzner) +| File | Tabella/Colonne | +|------|-----------------| +| `015_sso_columns.sql` | `users.sso_identity_id`, `users.password_version` | +| `016_active_sessions.sql` | `active_sessions` (jti tracking), `refresh_tokens.session_jti` | +| `017_password_reset.sql` | `password_reset_tokens` | +| `018_user_preferences.sql` | `users.theme/timezone/notif_email/notif_inapp` | +| `019_firm_branding.sql` | `firm_branding` (white-label) | + +#### Endpoint API nuovi (dopo Fase 1-5) +``` +POST /api/auth/forgot-password (Fase 3 / G08) +POST /api/auth/reset-password (Fase 3 / G08) +POST /api/auth/switchContext (Fase 3 / G09) +POST /api/auth/impersonate (Fase 4 / G11) +GET /api/auth/preferences (Fase 4 / G12) +PUT /api/auth/preferences (Fase 4 / G12) +GET /api/auth/sessions (Fase 2 / G06) +DELETE /api/auth/sessions (Fase 2 / G06) +DELETE /api/auth/sessions/{id} (Fase 2 / G06) +GET /api/branding/current (Fase 5 / G16) +PUT /api/branding (Fase 5 / G16) +``` + +#### UI changes +- `public/login.html` — link "Password dimenticata?" funzionante +- `public/forgot-password.html` (NUOVO) +- `public/reset-password.html` (NUOVO con strength bar) +- `public/settings.html` — tab Sicurezza con lista sessioni device + revoca +- `public/js/common.js` — sidebar version footer (click → changelog modal), tenant switcher esposto a chi ha ≥2 org, branding CSS vars auto-load +- `public/js/auth-gate.js` (NUOVO) — gate password lato client per documenti riservati + +#### File PHP nuovi / modificati +| File | Tipo | Note | +|------|------|------| +| `application/services/SsoHelper.php` | NUOVO | Client SSO dual-mode | +| `application/controllers/BrandingController.php` | NUOVO | White-label firm | +| `application/controllers/AuthController.php` | MODIFICATO | +12 metodi: login (jti+SSO), changePassword, refresh, logout, forgotPassword, resetPassword, switchContext, impersonate, getPreferences, updatePreferences, listSessions, revokeSession, revokeAllSessions | +| `application/controllers/BaseController.php` | MODIFICATO | requireAuth verifica jti + last_activity throttle, parseDeviceLabel, generateRefreshToken con session_jti | +| `application/services/EmailService.php` | MODIFICATO | +sendPasswordReset | +| `application/config/config.php` | MODIFICATO | +RATE_LIMIT_AUTH_FORGOT, +PASSWORD_RESET_TTL_SECONDS | +| `public/index.php` | MODIFICATO | +12 route + branding controller | + +#### Comportamento utente attivo OGGI +- SSO **dormiente** (`SSO_MODE=local`) → login esattamente come prima +- Multi-device sessions **ATTIVE** → ogni login crea row in `active_sessions`, JWT senza jti rifiutato +- Password reset **ATTIVO** → link funzionante da `login.html` +- Context switch **ATTIVO** → utenti multi-org vedono dropdown in sidebar +- Impersonate **ATTIVO** → solo super_admin o consulente stesso firm (NB: nessuna UI ancora, solo endpoint) +- Preferences **ATTIVO** → endpoint disponibili (NB: UI tab dedicato non ancora aggiunto) +- Versioning footer **ATTIVO** → versione visibile in sidebar di ogni pagina +- Branding **ATTIVO** → defaults applicati; consulente può PUT branding del suo firm + +#### Cron sync SSO (dipendenza esterna) +**AgileHub Ticket #220** aperto a team AGILEHUB per estendere `sso-password-sync.sh` al DB `nis2_agile_db`. Da risolvere prima del switch `SSO_MODE=dual`. + +#### Backup completi (rollback ready) +Tutti i file modificati hanno backup `.bak.pre-{fase}-{timestamp}` in: +- `/projects/nis2-agile/application/controllers/` +- `/projects/nis2-agile/public/` +- `/var/www/nis2-agile/.backups/` (Hetzner) + +DB backups schema: +- `host_users_pre_017_*.sql` +- `users_schema_pre_015_*.sql` +- `refresh_tokens_pre_016_*.sql` + +--- + +### Fase 1 — SSO Federation: **COMPLETATA 2026-05-29** ✅ + +| Step | Esito | Note | +|------|-------|------| +| G01 — Migration `015_sso_columns.sql` | ✅ Applicata su prod | Backup `.backups/users_schema_pre_015_20260529-073134.sql`. Idempotenza verificata. 0 utenti in prod (nessun cliente). | +| G02 — `application/services/SsoHelper.php` | ✅ Deployato | Mutuato da TRPG `shared/SsoHelper.php`. Lint OK in container. `getMode()=local`. | +| G03 — `AuthController::login()` + `changePassword()` | ✅ Modificato | Backup `.bak.pre-sso-20260529`. Smoke test 401 OK. Logica dietro `if(!$sso->isLocalOnly())` → no-op con `SSO_MODE=local`. | +| G04 — Vault + `.env` | ✅ Configurato | Vault: `tier1__nis2-app__sso/internal_key` (placeholder). `.env`: SSO_ENDPOINT/SSO_TIMEOUT_MS/SSO_MODE=local. | +| Cron sync (out-of-scope NIS2) | ✅ Ticket aperto | AgileHub Ticket #220 a team AGILEHUB. | + +**Comportamento utente**: invariato. Tutte le modifiche sono dietro feature flag `SSO_MODE`. Switch a `dual` previsto dopo almeno 7gg di validazione (decisione §10.1). + +### Prossimi step + +- **[manutentore]** AgileHub Ticket #220 → estensione `sso-password-sync.sh` al DB `nis2_agile_db` +- **[NIS2]** Quando ticket risolto + 7gg passati → switch `SSO_MODE=local` → `dual` (modifica `.env` + `docker compose up -d --force-recreate app`) +- **[NIS2]** Recupero valore reale `SSO_INTERNAL_KEY` da Tenant MS prima dello switch e replace in vault con: `docker exec vault-steward node /app/cli/vault-cli.js migrate tier1__nis2-app__sso internal_key ''` +- **[NIS2]** Bump `public/version.json` → **1.1.0** (decisione §10.5) a switch confermato + +Vedi §3-§6 per Fase 2-5. diff --git a/docs/sql/015_sso_columns.sql b/docs/sql/015_sso_columns.sql new file mode 100644 index 0000000..4604247 --- /dev/null +++ b/docs/sql/015_sso_columns.sql @@ -0,0 +1,66 @@ +-- Migration 015: SSO Federation columns +-- Progetto allineamento NIS2 ↔ TRPG — Fase 1 / G01 +-- Data: 2026-05-29 +-- +-- Aggiunge le colonne necessarie a collegare gli utenti NIS2 alle identità SSO +-- centralizzate in `nexus_tenant_db.sso_identities` (gestito da agile-services). +-- +-- - sso_identity_id: FK logica verso nexus_tenant_db.sso_identities.id. +-- NULL = utente non ancora linkato (sarà popolato lazy al primo +-- login post-SSO se l'email matcha — decisione utente 2026-05-29). +-- - password_version: contatore monotono incrementato a ogni cambio password SSO; +-- usato dal cron sync `sso-password-sync.sh` per decidere quando +-- riallineare password_hash locale. +-- +-- Comportamento atteso post-migration: +-- * Utenti esistenti: sso_identity_id=NULL, password_version=1 +-- * Login locale continua a funzionare senza modifiche (SSO_MODE=local di default) +-- * Nessun controller esistente si rompe (campi additivi) +-- +-- Rollback: +-- ALTER TABLE users +-- DROP INDEX idx_sso_identity, +-- DROP COLUMN sso_identity_id, +-- DROP COLUMN password_version; +-- +-- Note MySQL 8.x: +-- * `ADD COLUMN IF NOT EXISTS` non standard → controllo via information_schema. +-- * Eseguire come utente con privilegio ALTER su nis2_agile_db. + +SET @col_sso := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'sso_identity_id' +); +SET @sql_sso := IF(@col_sso = 0, + 'ALTER TABLE users ADD COLUMN sso_identity_id INT NULL COMMENT ''FK logica verso nexus_tenant_db.sso_identities.id'' AFTER email_verified_at', + 'SELECT ''sso_identity_id già presente — skip'' AS info' +); +PREPARE stmt FROM @sql_sso; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_ver := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'password_version' +); +SET @sql_ver := IF(@col_ver = 0, + 'ALTER TABLE users ADD COLUMN password_version INT NOT NULL DEFAULT 1 COMMENT ''Contatore versione password SSO — bumpato a ogni change-password'' AFTER sso_identity_id', + 'SELECT ''password_version già presente — skip'' AS info' +); +PREPARE stmt FROM @sql_ver; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx_sso := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND INDEX_NAME = 'idx_sso_identity' +); +SET @sql_idx := IF(@idx_sso = 0, + 'CREATE INDEX idx_sso_identity ON users (sso_identity_id)', + 'SELECT ''idx_sso_identity già presente — skip'' AS info' +); +PREPARE stmt FROM @sql_idx; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- Verifica finale +SELECT + COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'users' + AND COLUMN_NAME IN ('sso_identity_id', 'password_version'); diff --git a/docs/sql/016_active_sessions.sql b/docs/sql/016_active_sessions.sql new file mode 100644 index 0000000..59b304d --- /dev/null +++ b/docs/sql/016_active_sessions.sql @@ -0,0 +1,81 @@ +-- Migration 016: Multi-device session tracking +-- Progetto allineamento NIS2 ↔ TRPG — Fase 2 / G05 +-- Data: 2026-05-29 +-- Mutuata da TRPG /var/www/trpg-agile/sql/078_active_sessions.sql +-- +-- Crea tabella `active_sessions` per tracciare ogni sessione JWT come riga +-- distinta (chiave = jti del token), con device label + IP + last activity + +-- revoke audit. Permette: +-- - lista sessioni attive per utente (Settings → Sicurezza) +-- - revoca selettiva ("esci da questo dispositivo") +-- - revoca cascade su password change +-- - context switch tra organization (Fase 3) senza rotazione manuale +-- +-- Differenze rispetto a TRPG: +-- - aggiunta colonna `organization_id` (NIS2 ha tenant esplicito X-Organization-Id) +-- - ENUM revoked_reason include già 'context_switch' (TRPG l'ha aggiunta in +-- migration 093; noi nasciamo con essa per evitare doppia migration) +-- +-- Anche aggiungiamo `session_jti` a `refresh_tokens` per legare +-- ogni refresh alla sua sessione (rotazione safe). +-- +-- Rollback: +-- ALTER TABLE refresh_tokens DROP INDEX idx_refresh_jti, DROP COLUMN session_jti; +-- DROP TABLE IF EXISTS active_sessions; + +SET @tbl := ( + SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'active_sessions' +); +SET @sql_tbl := IF(@tbl = 0, + 'CREATE TABLE active_sessions ( + id CHAR(32) NOT NULL PRIMARY KEY COMMENT ''jti del JWT (bin2hex(random_bytes(16)))'', + user_id INT NOT NULL, + organization_id INT NULL COMMENT ''org attiva al momento del login'', + ip_address VARCHAR(45) NOT NULL, + user_agent VARCHAR(512) NULL, + device_label VARCHAR(120) NULL COMMENT ''Parsing UA-friendly (es. Chrome/Win11)'', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''login time'', + last_activity_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL COMMENT ''Hard cap = login + refresh TTL'', + revoked_at TIMESTAMP NULL DEFAULT NULL, + revoked_reason ENUM(''logout'',''force'',''password_change'',''admin'',''expired_idle'',''context_switch'') NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_active (user_id, revoked_at, last_activity_at), + INDEX idx_last_activity (last_activity_at), + INDEX idx_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT=''JWT session tracking — solo utenti umani (API key B2B escluse)''', + 'SELECT ''active_sessions già presente — skip'' AS info' +); +PREPARE stmt FROM @sql_tbl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- Aggiungi session_jti a refresh_tokens +SET @col_jti := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'refresh_tokens' AND COLUMN_NAME = 'session_jti' +); +SET @sql_col := IF(@col_jti = 0, + 'ALTER TABLE refresh_tokens ADD COLUMN session_jti CHAR(32) NULL COMMENT ''FK logica → active_sessions.id'' AFTER user_id', + 'SELECT ''refresh_tokens.session_jti già presente — skip'' AS info' +); +PREPARE stmt FROM @sql_col; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx_jti := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'refresh_tokens' AND INDEX_NAME = 'idx_refresh_jti' +); +SET @sql_idx := IF(@idx_jti = 0, + 'CREATE INDEX idx_refresh_jti ON refresh_tokens (session_jti)', + 'SELECT ''idx_refresh_jti già presente — skip'' AS info' +); +PREPARE stmt FROM @sql_idx; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- Verifica finale +SELECT TABLE_NAME, ENGINE, TABLE_COMMENT +FROM information_schema.TABLES +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'active_sessions'; + +SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'refresh_tokens' AND COLUMN_NAME = 'session_jti'; diff --git a/docs/sql/017_password_reset.sql b/docs/sql/017_password_reset.sql new file mode 100644 index 0000000..9d012a4 --- /dev/null +++ b/docs/sql/017_password_reset.sql @@ -0,0 +1,39 @@ +-- Migration 017: Password reset tokens +-- Progetto allineamento NIS2 ↔ TRPG — Fase 3 / G08 +-- Data: 2026-05-29 +-- +-- Tabella per supportare il flusso "Password dimenticata": +-- 1. POST /auth/forgot-password { email } → genera token, salva hash, invia mail +-- 2. POST /auth/reset-password { token, new_password } → verifica + setta nuova pwd +-- +-- TTL: 30 min (decisione utente §10.4) +-- Single-use: `used_at` viene settato al consumo +-- Rate limit: 3 richieste/h per IP+email (applicato in controller) +-- +-- Rollback: DROP TABLE password_reset_tokens; + +SET @tbl := ( + SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'password_reset_tokens' +); +SET @sql := IF(@tbl = 0, + 'CREATE TABLE password_reset_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE COMMENT ''SHA-256 hex del token in chiaro inviato via mail'', + expires_at TIMESTAMP NOT NULL COMMENT ''Default 30 min dopo created_at'', + used_at TIMESTAMP NULL COMMENT ''Settato al primo consumo — token diventa single-use'', + ip_address VARCHAR(45) NULL COMMENT ''IP del richiedente forgot-password'', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_token (token_hash), + INDEX idx_expires (expires_at), + INDEX idx_user_unused (user_id, used_at, expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT=''Token reset password — TTL 30min single-use''', + 'SELECT ''password_reset_tokens già presente — skip'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT TABLE_NAME, ENGINE, TABLE_COMMENT FROM information_schema.TABLES +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'password_reset_tokens'; diff --git a/docs/sql/018_user_preferences.sql b/docs/sql/018_user_preferences.sql new file mode 100644 index 0000000..ed9ea57 --- /dev/null +++ b/docs/sql/018_user_preferences.sql @@ -0,0 +1,46 @@ +-- Migration 018: User preferences (Fase 4 / G12) +-- Data: 2026-05-29 +-- +-- Aggiunge a `users` colonne per preferenze: +-- - theme (light|dark|auto) +-- - timezone (default Europe/Rome — vedi CLAUDE.md sez. timezone) +-- - notif_email (notifiche via mail on/off) +-- - notif_inapp (notifiche in-app on/off) +-- +-- Rollback: +-- ALTER TABLE users +-- DROP COLUMN notif_inapp, DROP COLUMN notif_email, +-- DROP COLUMN timezone, DROP COLUMN theme; + +SET @c1 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='theme'); +SET @s1 := IF(@c1 = 0, + 'ALTER TABLE users ADD COLUMN theme ENUM(''light'',''dark'',''auto'') DEFAULT ''auto'' AFTER preferred_language', + 'SELECT ''theme già presente — skip'' AS info' +); +PREPARE stmt FROM @s1; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c2 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='timezone'); +SET @s2 := IF(@c2 = 0, + 'ALTER TABLE users ADD COLUMN timezone VARCHAR(64) DEFAULT ''Europe/Rome'' AFTER theme', + 'SELECT ''timezone già presente — skip'' AS info' +); +PREPARE stmt FROM @s2; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c3 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='notif_email'); +SET @s3 := IF(@c3 = 0, + 'ALTER TABLE users ADD COLUMN notif_email TINYINT(1) DEFAULT 1 AFTER timezone', + 'SELECT ''notif_email già presente — skip'' AS info' +); +PREPARE stmt FROM @s3; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c4 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='notif_inapp'); +SET @s4 := IF(@c4 = 0, + 'ALTER TABLE users ADD COLUMN notif_inapp TINYINT(1) DEFAULT 1 AFTER notif_email', + 'SELECT ''notif_inapp già presente — skip'' AS info' +); +PREPARE stmt FROM @s4; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' + AND COLUMN_NAME IN ('theme','timezone','notif_email','notif_inapp'); diff --git a/docs/sql/019_firm_branding.sql b/docs/sql/019_firm_branding.sql new file mode 100644 index 0000000..098e179 --- /dev/null +++ b/docs/sql/019_firm_branding.sql @@ -0,0 +1,33 @@ +-- Migration 019: Firm branding (Fase 5 / G16) +-- Data: 2026-05-29 +-- +-- Tabella di branding white-label per studi di consulenza. +-- Permette al consulente di personalizzare logo/colori che vedranno i suoi clienti. +-- +-- Lookup: per ogni utente loggato → si guarda users.consulting_firm_id → +-- firm_branding.firm_id matching → si applica +-- +-- Rollback: DROP TABLE firm_branding; + +SET @tbl := ( + SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'firm_branding' +); +SET @sql := IF(@tbl = 0, + 'CREATE TABLE firm_branding ( + firm_id INT NOT NULL PRIMARY KEY, + logo_url VARCHAR(512) NULL COMMENT ''URL assoluto o relativo al logo (es. /uploads/firms/123/logo.svg)'', + primary_color CHAR(7) NULL COMMENT ''Hex #RRGGBB del colore primario UI'', + secondary_color CHAR(7) NULL COMMENT ''Hex #RRGGBB del colore secondario UI'', + custom_brand_name VARCHAR(120) NULL COMMENT ''Override del nome prodotto in UI (es. "Lo Studio X NIS2 Suite")'', + custom_domain VARCHAR(255) NULL COMMENT ''Sottodominio dedicato (futuro)'', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (firm_id) REFERENCES consulting_firms(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT=''White-label branding per consulting firm''', + 'SELECT ''firm_branding già presente — skip'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SELECT TABLE_NAME, ENGINE, TABLE_COMMENT FROM information_schema.TABLES +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'firm_branding'; diff --git a/public/forgot-password.html b/public/forgot-password.html new file mode 100644 index 0000000..1ccf9ab --- /dev/null +++ b/public/forgot-password.html @@ -0,0 +1,106 @@ + + + + + + Password dimenticata - NIS2 Agile + + + + + +
+
+
+ +

Reimposta la tua password

+
+ +
+
+
+ +

Inserisci l'indirizzo email associato al tuo account. Ti invieremo un link valido 30 minuti per impostare una nuova password.

+ +
+
+ + +
+ + +
+
+ + +
+
+ + + + + diff --git a/public/index.php b/public/index.php index 2463c34..2f386c8 100644 --- a/public/index.php +++ b/public/index.php @@ -107,6 +107,8 @@ $controllerMap = [ 'contact' => 'ContactController', // legacy 'mktg-lead' => 'MktgLeadController', // standard condiviso TRPG/NIS2 'feedback' => 'FeedbackController', // segnalazioni & risoluzione AI + 'knowledgebase' => 'KnowledgeBaseController', // KB multi-livello (Migration 012-014) + 'branding' => 'BrandingController', // White-label firm (Fase 5 / G16) ]; if (!isset($controllerMap[$controllerName])) { @@ -154,6 +156,20 @@ $actionMap = [ 'PUT:profile' => 'updateProfile', 'POST:changePassword' => 'changePassword', 'POST:validateInvite' => 'validateInvite', // valida invite_token (no auth) + // Multi-device sessions (Fase 2 / G06) + 'GET:sessions' => 'listSessions', + 'DELETE:sessions/{id}' => 'revokeSession', + 'DELETE:sessions' => 'revokeAllSessions', + // Password reset (Fase 3 / G08, no auth) + 'POST:forgotPassword' => 'forgotPassword', + 'POST:resetPassword' => 'resetPassword', + // Context switch (Fase 3 / G09) + 'POST:switchContext' => 'switchContext', + // Impersonate (Fase 4 / G11) + 'POST:impersonate' => 'impersonate', + // Preferences (Fase 4 / G12) + 'GET:preferences' => 'getPreferences', + 'PUT:preferences' => 'updatePreferences', ], // ── OrganizationController ────────────────────── @@ -399,6 +415,21 @@ $actionMap = [ 'PUT:{id}' => 'update', 'POST:{id}/resolve' => 'resolve', ], + + // ── KnowledgeBaseController — KB multi-livello (Migration 012-014) ── + 'knowledgebase' => [ + 'POST:ingest' => 'ingest', + 'GET:list' => 'list', + 'GET:firmOrgs' => 'firmOrgs', + 'POST:search' => 'search', + 'DELETE:{id}' => 'delete', + ], + + // ── BrandingController — White-label firm (Fase 5 / G16) ── + 'branding' => [ + 'GET:current' => 'getCurrent', + 'PUT:index' => 'update', + ], ]; // ═══════════════════════════════════════════════════════════════════════════ diff --git a/public/js/auth-gate.js b/public/js/auth-gate.js new file mode 100644 index 0000000..7aa5f9c --- /dev/null +++ b/public/js/auth-gate.js @@ -0,0 +1,37 @@ +/** + * Auth gate per documenti tecnici NIS2 Agile (adattato da TRPG, Fase 5 / G17). + * Protezione lato client (non crittografica) — scopo: evitare visualizzazione casuale. + * Caricare nel PRIMA di qualsiasi altro script o CSS. + * + * Password di default: Nis2Agile2026!@ + * Override per-pagina: aggiungere data-pw="..." allo script tag, es: + * + * La session key è derivata dalla pw, quindi pagine con pw diverse non condividono sessione. + */ +(function(){ + var DEFAULT_PW = 'Nis2Agile2026!@'; + var scriptTag = document.currentScript || (function(){ + var all = document.getElementsByTagName('script'); + for (var i = 0; i < all.length; i++) if (all[i].src && all[i].src.indexOf('auth-gate.js') !== -1) return all[i]; + return null; + })(); + var EXPECTED = (scriptTag && scriptTag.getAttribute('data-pw')) || DEFAULT_PW; + var keySuffix = ''; + try { keySuffix = btoa(EXPECTED).replace(/=/g, '').slice(0, 10); } catch (e) { keySuffix = String(EXPECTED.length); } + var KEY = 'nis2_tech_auth_' + keySuffix; + if (sessionStorage.getItem(KEY) === 'ok') return; + var pwd = prompt('Documento riservato — inserisci password:'); + if (pwd === EXPECTED) { + sessionStorage.setItem(KEY, 'ok'); + return; + } + try { window.stop(); } catch (e) {} + document.documentElement.innerHTML = 'Accesso riservato' + + '' + + '
' + + '
🔒
' + + '

Accesso riservato

' + + '

Questo documento richiede autenticazione. Contatta il team Agile Software per ottenere la password.

' + + '' + + '
'; +})(); diff --git a/public/js/common.js b/public/js/common.js index df7b4c2..ef489c7 100644 --- a/public/js/common.js +++ b/public/js/common.js @@ -200,6 +200,7 @@ function loadSidebar() { { name: 'Segnalazioni', href: 'whistleblowing.html', icon: `` }, { name: 'Normative', href: 'normative.html', icon: `` }, { name: 'AI Cross-Analysis', href: 'cross-analysis.html', icon: `` }, + { name: 'Knowledge Base', href: 'kb.html', icon: `` }, ] }, { @@ -269,6 +270,11 @@ function loadSidebar() { + `; @@ -279,6 +285,12 @@ function loadSidebar() { // Mobile toggle _setupMobileToggle(); + + // Version footer (Fase 4 / G13) + _loadVersionFooter(); + + // Firm branding white-label (Fase 5 / G16) — non bloccante + _loadFirmBranding(); } const _roleLabels = { @@ -314,10 +326,9 @@ async function _loadUserInfo() { // Save role to localStorage (ensures isConsultant() works across pages) if (user.role) api.setUserRole(user.role); - // For consultants: render org-switcher - if (user.role === 'consultant') { - _loadConsultantOrgSwitcher(); - } + // Tenant switcher: visibile per consulenti + per chiunque abbia ≥2 org + // (Fase 3 / G10 — esposizione globale del context switch) + _loadConsultantOrgSwitcher(); } } catch (e) { // Silenzioso @@ -327,9 +338,12 @@ async function _loadUserInfo() { async function _loadConsultantOrgSwitcher() { try { const result = await api.listOrganizations(); - if (!result.success || !result.data || result.data.length === 0) return; + if (!result.success || !result.data) return; const orgs = result.data; + // Mostra switcher solo se utente ha ≥2 organizzazioni + // (single-tenant users non hanno bisogno di scegliere) + if (orgs.length < 2) return; const currentOrgId = parseInt(api.orgId); const currentOrg = orgs.find(o => (o.id || o.organization_id) === currentOrgId); @@ -398,11 +412,72 @@ async function _loadConsultantOrgSwitcher() { } } -function _switchOrg(orgId) { +async function _switchOrg(orgId) { + // Fase 3 / G09: chiama switchContext per rotare JWT con organization_id come claim. + // Fallback: se l'endpoint non risponde 200, applica solo il vecchio comportamento + // (set localStorage + reload) per backward-compat con versioni precedenti. + try { + const result = await api.post('/auth/switchContext', { organization_id: orgId }); + if (result.success && result.data && result.data.access_token) { + api.setTokens(result.data.access_token, result.data.refresh_token); + api.setOrganization(orgId); + window.location.reload(); + return; + } + } catch (e) { + // continua col fallback + } api.setOrganization(orgId); window.location.reload(); } +// ── Firm branding white-label (Fase 5 / G16) ──────────────────────────── +async function _loadFirmBranding() { + try { + const r = await fetch('/api/branding/current', { + headers: api.token ? { 'Authorization': 'Bearer ' + api.token } : {} + }); + if (!r.ok) return; + const resp = await r.json(); + if (!resp.success || !resp.data) return; + const b = resp.data; + + const root = document.documentElement; + if (b.primary_color) root.style.setProperty('--primary', b.primary_color); + if (b.secondary_color) root.style.setProperty('--secondary', b.secondary_color); + + if (b.custom_brand_name) { + document.querySelectorAll('.sidebar-logo-text, .auth-logo-text').forEach(function(el) { + el.textContent = b.custom_brand_name; + }); + } + if (b.logo_url) { + document.querySelectorAll('.sidebar-logo-icon img, .auth-logo-icon img').forEach(function(el) { + el.src = b.logo_url; + }); + } + } catch (e) { /* silenzioso */ } +} + +// ── Versioning live (Fase 4 / G13) ────────────────────────────────────── +let _versionInfo = null; +async function _loadVersionFooter() { + try { + const r = await fetch('/version.json?_=' + Date.now()); + if (!r.ok) return; + _versionInfo = await r.json(); + const el = document.getElementById('sidebar-version'); + if (el && _versionInfo.version) { + el.textContent = 'v' + _versionInfo.version + (_versionInfo.build ? ' · ' + _versionInfo.build : ''); + } + } catch (e) { /* silenzioso */ } +} +function _showVersionChangelog() { + if (!_versionInfo) return; + const v = _versionInfo; + alert('NIS2 Agile v' + v.version + '\nBuild: ' + (v.build || '—') + '\nData: ' + (v.date || '—') + '\n\n' + (v.changelog || 'Nessun changelog disponibile')); +} + function _setupMobileToggle() { // Crea pulsante toggle se non esiste if (!document.querySelector('.sidebar-toggle')) { @@ -799,3 +874,182 @@ function switchLang(lang) { s.src = 'js/feedback.js'; document.body.appendChild(s); })(); + +/* ════════════════════════════════════════════════════════════════════════ + AgileHub / Nexus integration (NIS2) + ───────────────────────────────────────────────────────────────────────── + - bug-reporter.js viene iniettato dinamicamente DOPO il login + (richiede i data-user-* del profilo corrente). Idempotente. + - FAB AI viola "ARIA" creato via JS (niente modifiche alle 18 pagine HTML). + - Si collega all'AI nativa NIS2 per il grounding KB Multi-Livello. + ════════════════════════════════════════════════════════════════════════ */ + +(function () { + 'use strict'; + + // Salta nelle pagine pubbliche (login/register/landing/marketing). + var publicPages = ['/login.html', '/register.html', '/index.html', '/presentation.html', '/']; + var path = location.pathname.replace(/^.*\//, '/'); + if (publicPages.indexOf(path) !== -1) return; + + var token = localStorage.getItem('nis2_access_token'); + if (!token) return; // utente non loggato → skip + + // Mini JWT decoder (base64url → JSON payload) + function decodeJwt(t) { + try { + var b64 = t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(decodeURIComponent(escape(atob(b64)))); + } catch (e) { return {}; } + } + + var claims = decodeJwt(token); + var userEmail = claims.email || ''; + var userName = claims.name || claims.full_name || claims.email || ''; + var userRole = localStorage.getItem('nis2_user_role') || claims.role || ''; + + // ── 1. Inietta il bug-reporter Nexus ──────────────────────────────────── + if (!window.__nexusWidgetLoaded) { + window.__nexusWidgetLoaded = true; + var s = document.createElement('script'); + s.src = 'js/bug-reporter.js?v=20260411'; + s.async = true; + s.dataset.product = 'NIS2'; + s.dataset.tenantId = '7'; + s.dataset.apiUrl = 'https://agilehub.agile.software'; + s.dataset.userName = userName; + s.dataset.userEmail = userEmail; + s.dataset.userRole = userRole; + s.dataset.lang = 'it'; + document.body.appendChild(s); + } + + // ── 2. AI Chat FAB viola "ARIA" ───────────────────────────────────────── + function ensureFab() { + if (document.getElementById('ai-chat-fab')) return; + + var fab = document.createElement('button'); + fab.id = 'ai-chat-fab'; + fab.setAttribute('aria-label', 'Chiedi ad ARIA'); + fab.style.cssText = 'position:fixed;bottom:24px;right:24px;width:56px;height:56px;' + + 'border-radius:50%;border:none;cursor:pointer;z-index:9998;' + + 'background:linear-gradient(135deg,#7C3AED,#3B82F6);color:#fff;' + + 'box-shadow:0 6px 20px rgba(124,58,237,.4);font-size:22px;' + + 'display:flex;align-items:center;justify-content:center;'; + fab.innerHTML = ''; + document.body.appendChild(fab); + + var panel = document.createElement('div'); + panel.id = 'ai-chat-panel'; + panel.style.cssText = 'position:fixed;bottom:90px;right:24px;width:360px;' + + 'max-height:520px;background:#fff;border-radius:16px;' + + 'box-shadow:0 12px 32px rgba(0,0,0,.2);display:none;z-index:9999;' + + 'overflow:hidden;flex-direction:column;' + + "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"; + document.body.appendChild(panel); + + var STORAGE_KEY = 'nis2_ai_chat'; + var history = []; + try { history = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '[]'); } catch (e) {} + + function escHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]; + }); + } + + function render() { + var list = history.map(function (m) { + var isUser = m.role === 'user'; + return '
' + + '
' + escHtml(m.content) + '
'; + }).join(''); + panel.innerHTML = + '
' + + '
' + + ' ARIA — Assistente NIS2' + + '
' + + '' + + '
' + + '
' + + (list || '
Ciao! Chiedimi qualcosa su NIS2: misure di ' + + 'sicurezza, audit, fornitori, incident response…
') + + '
' + + '
' + + '' + + '' + + '
'; + document.getElementById('ai-chat-close').onclick = function () { + panel.style.display = 'none'; + }; + document.getElementById('ai-chat-form').onsubmit = onSubmit; + var msgs = document.getElementById('ai-chat-msgs'); + msgs.scrollTop = msgs.scrollHeight; + } + + async function onSubmit(ev) { + ev.preventDefault(); + var input = document.getElementById('ai-chat-input'); + var text = (input.value || '').trim(); + if (!text) return; + history.push({ role: 'user', content: text }); + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(history)); + render(); + try { + // AI nativa NIS2 (RAG con KB Multi-Livello già attivo) + var t = localStorage.getItem('nis2_access_token') || ''; + var orgId = localStorage.getItem('nis2_org_id') || ''; + var r = await fetch('/api/ai/ask', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': t ? ('Bearer ' + t) : '', + 'X-Organization-Id': orgId + }, + body: JSON.stringify({ question: text, history: history }) + }); + var resp = await r.json(); + var answer = (resp && resp.success && resp.data + && (resp.data.answer || resp.data.message || resp.data.text)) + || (resp && (resp.answer || resp.message || resp.text)) + || 'ARIA non ha risposto. Riprova tra poco.'; + history.push({ role: 'assistant', content: answer }); + } catch (e) { + history.push({ + role: 'assistant', + content: '⚠️ ARIA non risponde in questo momento. Riprova tra poco.' + }); + } + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(history)); + render(); + } + + fab.onclick = function () { + var visible = panel.style.display === 'flex'; + panel.style.display = visible ? 'none' : 'flex'; + if (!visible) render(); + }; + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', ensureFab); + } else { + ensureFab(); + } +})(); diff --git a/public/login.html b/public/login.html index e5d0f5f..2283bd6 100644 --- a/public/login.html +++ b/public/login.html @@ -69,7 +69,7 @@ - + Password dimenticata? diff --git a/public/reset-password.html b/public/reset-password.html new file mode 100644 index 0000000..29fa3a9 --- /dev/null +++ b/public/reset-password.html @@ -0,0 +1,171 @@ + + + + + + Imposta nuova password - NIS2 Agile + + + + + +
+
+
+ +

Imposta una nuova password

+
+ +
+
+
+ +
+
+ +
+ + +
+
+
+
+ +
+ +
+ + +
+
+ +

Minimo 8 caratteri, con almeno una maiuscola, una minuscola e un numero.

+ + +
+
+ + +
+
+ + + + diff --git a/public/settings.html b/public/settings.html index ee49fe8..b2a8371 100644 --- a/public/settings.html +++ b/public/settings.html @@ -444,14 +444,6 @@

Sicurezza Account

-
-
- Sessione Corrente - Attiva -
-

Sessione autenticata tramite token JWT. L'accesso e' protetto da crittografia.

-
-
Autenticazione a Due Fattori (2FA) @@ -465,7 +457,22 @@ Accesso API JWT
-

L'accesso alle API avviene tramite token JWT (JSON Web Token) con scadenza automatica e meccanismo di refresh. Tutti i token vengono invalidati al cambio password.

+

L'accesso alle API avviene tramite token JWT (JSON Web Token) con scadenza automatica e meccanismo di refresh.

+
+
+ + +
+
+
+

Sessioni Attive

+

Dispositivi attualmente loggati al tuo account. Puoi disconnetterli singolarmente o tutti tranne questo.

+
+ +
+
+
+
@@ -576,11 +583,96 @@ document.getElementById(panelMap[tab]).classList.add('active'); if (tab === 'members') loadMembers(); - if (tab === 'security') loadAuditLog(); + if (tab === 'security') { loadSessions(); loadAuditLog(); } if (tab === 'apikeys') loadApiKeys(); if (tab === 'webhooks') { loadWebhooks(); loadDeliveries(); } } + // ── Sessioni Multi-Device (Fase 2 / G07) ───────────────── + async function loadSessions() { + const container = document.getElementById('sessions-container'); + container.innerHTML = '
'; + try { + const res = await api.get('/auth/sessions'); + const sessions = res.data.sessions || []; + renderSessions(sessions); + } catch (e) { + container.innerHTML = '
Impossibile caricare le sessioni.
'; + } + } + + function renderSessions(sessions) { + const container = document.getElementById('sessions-container'); + const revokeAllBtn = document.getElementById('revoke-all-btn'); + if (!sessions.length) { + container.innerHTML = '
Nessuna sessione attiva.
'; + revokeAllBtn.style.display = 'none'; + return; + } + revokeAllBtn.style.display = sessions.length > 1 ? 'inline-block' : 'none'; + const html = sessions.map(function(s) { + const lastActivity = fmtRelativeTime(s.last_activity_at); + const created = fmtDate(s.created_at); + const isCurrent = s.is_current; + const badge = isCurrent + ? 'Questo dispositivo' + : ''; + const actionBtn = isCurrent + ? '' + : ''; + return '
' + + '
' + + '
' + escapeHtml(s.device_label) + badge + '
' + + '
' + + 'IP ' + escapeHtml(s.ip_address) + ' · Ultimo accesso ' + lastActivity + ' · Login ' + created + + '
' + + '
' + + actionBtn + + '
'; + }).join(''); + container.innerHTML = html; + } + + async function revokeSession(sessionId) { + if (!confirm('Disconnettere questo dispositivo? Sara\' necessario un nuovo login per riaccedere.')) return; + try { + await api.del('/auth/sessions/' + sessionId); + loadSessions(); + } catch (e) { + alert('Errore: ' + (e.message || 'impossibile revocare la sessione')); + } + } + + async function revokeAllOtherSessions() { + if (!confirm('Disconnettere tutti gli altri dispositivi? La sessione corrente non sara\' interrotta.')) return; + try { + await api.del('/auth/sessions'); + loadSessions(); + } catch (e) { + alert('Errore: ' + (e.message || 'impossibile revocare le sessioni')); + } + } + + function fmtRelativeTime(iso) { + if (!iso) return '—'; + const d = new Date(iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z')); + const diffSec = Math.floor((Date.now() - d.getTime()) / 1000); + if (diffSec < 60) return 'pochi secondi fa'; + if (diffSec < 3600) return Math.floor(diffSec / 60) + ' min fa'; + if (diffSec < 86400) return Math.floor(diffSec / 3600) + ' ore fa'; + return Math.floor(diffSec / 86400) + ' giorni fa'; + } + function fmtDate(iso) { + if (!iso) return '—'; + const d = new Date(iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z')); + return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + } + function escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) { + return { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]; + }); + } + // ── Settori NIS2 ───────────────────────────────────────── const sectorLabels = { energy: 'Energia', transport: 'Trasporti', banking: 'Banche', diff --git a/public/version.json b/public/version.json new file mode 100644 index 0000000..286a996 --- /dev/null +++ b/public/version.json @@ -0,0 +1 @@ +{"version":"1.5.0","build":"20260529e","date":"2026-05-29T14:00:00+02:00","changelog":"Fase 5 Polishing: BrandingController + migration 019_firm_branding (white-label per consulenti), auth-gate.js per documenti riservati. Skip G15 demo (coperto dai simulator esistenti) e G18 refactor (rinviato). Progetto allineamento NIS2↔TRPG COMPLETATO (Fasi 1-5). Vedi docs/GAP_TRPG_NIS2_ALIGNMENT.md"}