[FEAT] Allineamento NIS2 ↔ TRPG (Fasi 1-5): SSO + Sessions + Reset + Impersonate + Branding

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) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-05-29 13:18:35 +02:00
parent c37423f900
commit e4f9e9179e
21 changed files with 2636 additions and 152 deletions

View File

@ -70,6 +70,12 @@ define('RATE_LIMIT_AUTH_LOGIN', [
define('RATE_LIMIT_AUTH_REGISTER', [ define('RATE_LIMIT_AUTH_REGISTER', [
['max' => 3, 'window_seconds' => 600], ['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', [ define('RATE_LIMIT_AI', [
['max' => 10, 'window_seconds' => 60], ['max' => 10, 'window_seconds' => 60],
['max' => 100, 'window_seconds' => 3600], ['max' => 100, 'window_seconds' => 3600],

View File

@ -7,6 +7,7 @@
require_once __DIR__ . '/BaseController.php'; require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/RateLimitService.php'; require_once APP_PATH . '/services/RateLimitService.php';
require_once APP_PATH . '/services/SsoHelper.php';
class AuthController extends BaseController class AuthController extends BaseController
{ {
@ -132,8 +133,66 @@ class AuthController extends BaseController
[$email] [$email]
); );
if (!$user || !password_verify($password, $user['password_hash'])) { // --- SSO Federation (Fase 1 / G02) ---
$this->jsonError('Credenziali non valide', 401, 'INVALID_CREDENTIALS'); // 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 // Aggiorna ultimo login
@ -141,9 +200,22 @@ class AuthController extends BaseController
'last_login_at' => date('Y-m-d H:i:s'), 'last_login_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$user['id']]); ], 'id = ?', [$user['id']]);
// Genera tokens // --- Multi-device session tracking (Fase 2 / G05-G06) ---
$accessToken = $this->generateJWT((int) $user['id']); $jti = bin2hex(random_bytes(16));
$refreshToken = $this->generateRefreshToken((int) $user['id']); $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 // Carica organizzazioni
$organizations = Database::fetchAll( $organizations = Database::fetchAll(
@ -179,8 +251,19 @@ class AuthController extends BaseController
{ {
$this->requireAuth(); $this->requireAuth();
// Invalida tutti i refresh token dell'utente // --- Revoca selettiva della sessione corrente (Fase 2 / G06) ---
Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]); $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()); $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'); $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 // Elimina vecchio token atomicamente
Database::delete('refresh_tokens', 'id = ?', [$tokenRecord['id']]); Database::delete('refresh_tokens', 'id = ?', [$tokenRecord['id']]);
// Genera nuovi tokens // Genera nuovi tokens preservando il jti della sessione
$userId = (int) $tokenRecord['user_id']; $accessToken = $this->generateJWT($userId, $jti ? ['jti' => $jti] : []);
$accessToken = $this->generateJWT($userId); $newRefreshToken = $this->generateRefreshToken($userId, $jti);
$newRefreshToken = $this->generateRefreshToken($userId);
Database::commit(); Database::commit();
} catch (Throwable $e) { } catch (Throwable $e) {
@ -319,16 +423,74 @@ class AuthController extends BaseController
$this->jsonError(implode('. ', $errors), 400, 'WEAK_PASSWORD'); $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', [ Database::update('users', [
'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT), 'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT),
'password_version' => $newPasswordVersion,
], 'id = ?', [$this->getCurrentUserId()]); ], 'id = ?', [$this->getCurrentUserId()]);
// Invalida tutti i refresh token (force re-login) // --- Revoca altre sessioni, mantieni quella corrente (Fase 2 / G06) ---
Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]); $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) 'recipient' => $recipient, // dati pre-compilazione form (null se non presenti)
], 'Invito valido'); ], '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)');
}
} }

View File

@ -12,6 +12,7 @@ require_once APP_PATH . '/services/AuditService.php';
class BaseController class BaseController
{ {
protected ?array $currentUser = null; protected ?array $currentUser = null;
protected ?array $currentSession = null;
protected ?int $currentOrgId = null; protected ?int $currentOrgId = null;
protected ?string $currentOrgRole = null; protected ?string $currentOrgRole = null;
@ -272,9 +273,61 @@ class BaseController
$this->jsonError('Utente non trovato o disabilitato', 401, 'USER_NOT_FOUND'); $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; $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 * 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)); $token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', time() + JWT_REFRESH_EXPIRES_IN); $expiresAt = date('Y-m-d H:i:s', time() + JWT_REFRESH_EXPIRES_IN);
Database::insert('refresh_tokens', [ Database::insert('refresh_tokens', [
'user_id' => $userId, 'user_id' => $userId,
'token' => hash('sha256', $token), 'token' => hash('sha256', $token),
'expires_at' => $expiresAt, 'expires_at' => $expiresAt,
'session_jti' => $sessionJti,
]); ]);
return $token; return $token;

View File

@ -0,0 +1,114 @@
<?php
/**
* NIS2 Agile - BrandingController
*
* White-label branding per consulting firms (Fase 5 / G16).
* - GET /api/branding/current
* - Se utente autenticato lookup tramite users.consulting_firm_id
* - Altrimenti, query param ?firm_id=N (utile in pagine pre-auth come login)
* - Ritorna logo_url, primary_color, secondary_color, custom_brand_name (o defaults)
*/
require_once __DIR__ . '/BaseController.php';
class BrandingController extends BaseController
{
private const DEFAULTS = [
'logo_url' => 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');
}
}

View File

@ -439,6 +439,41 @@ class EmailService
// BENVENUTO E INVITI // 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 = <<<HTML
<div style="text-align: center; margin-bottom: 30px;">
<div style="font-size: 48px; margin-bottom: 10px;">&#128274;</div>
<h2 style="color: #1e40af; margin: 0;">Reimposta la tua password</h2>
</div>
<p>Gentile <strong>{$fullName}</strong>,</p>
<p>Abbiamo ricevuto una richiesta di reimpostazione password per il tuo account su <strong>{$this->esc($this->appName)}</strong>. Clicca sul pulsante qui sotto per impostare una nuova password.</p>
<p style="text-align:center; margin: 32px 0;">
<a href="{$this->esc($resetUrl)}" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px;">Reimposta password</a>
</p>
<p style="font-size: 13px; color: #6b7280;">Il link è valido per <strong>{$ttlMin} minuti</strong> e può essere usato una sola volta. Se non hai richiesto tu il reset, ignora questa email: la tua password attuale resta valida.</p>
<p style="font-size: 13px; color: #6b7280; word-break: break-all;">Se il pulsante non funziona, copia questo link nel browser:<br>{$this->esc($resetUrl)}</p>
HTML;
return $this->send($user['email'], 'Reimposta la tua password — ' . $this->appName, $html);
}
/** /**
* Email di benvenuto dopo la registrazione * Email di benvenuto dopo la registrazione
* *

View File

@ -0,0 +1,176 @@
<?php
/**
* NIS2 Agile SsoHelper
*
* Client SSO leggero per integrazione con AgileHub Tenant MS (http://172.18.0.1:4214).
* Usa cURL nativo, zero dipendenze Composer.
*
* Adattato 2026-05-29 da TRPG /var/www/trpg-agile/shared/SsoHelper.php
* (Progetto allineamento NIS2 TRPG Fase 1 / G02).
*
* Workaround PHP-FPM Alpine + musl:
* getenv() può ritornare false in worker FPM leggi anche da $_SERVER / $_ENV,
* poi cadi su default hardcoded (stesso pattern usato in VectorService/EmbedService).
*
* Modalità (SSO_MODE):
* - local: nessuna chiamata SSO, login solo locale (default sicuro)
* - dual: prova SSO, fallback locale se Tenant MS unreachable
* - sso_only: solo SSO, nessun fallback (massima centralizzazione)
*
* Configurazione .env:
* SSO_ENDPOINT=http://172.18.0.1:4214
* SSO_TIMEOUT_MS=3000
* SSO_MODE=local
* SSO_INTERNAL_KEY=<da vault-steward>
*
* 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);
}
}

View File

@ -1,150 +1,167 @@
# Contesto Ultima Sessione # Contesto Ultima Sessione
**Data**: 2026-03-17 (aggiornato fine sessione) **Data**: 2026-05-29
**Durata**: sessione lunga — BigSim v2.0 completata con successo **Durata**: sessione molto lunga — progetto allineamento NIS2↔TRPG completato
--- ---
## Cosa abbiamo fatto ## 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) Doc canonico: [docs/GAP_TRPG_NIS2_ALIGNMENT.md](GAP_TRPG_NIS2_ALIGNMENT.md)
- **`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)
### 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` #### Fase 1 — SSO Federation (v1.1.0)
- `ensureOrg()`: rimossi dal payload create: `vat_number`, `legal_form`, `ateco_code`, `province`, `region` (P.IVA Luhn fake fallisce la validazione) - Migration `015_sso_columns.sql`: aggiunge `users.sso_identity_id`, `users.password_version`
- `clearSimRateLimit()`: fix glob — file named `md5(key).json`, non `login:*.json`; ora usa `glob('*.json')` per pulire tutti - Nuovo `application/services/SsoHelper.php` — client SSO dual-mode (cURL nativo, zero deps)
- `clearSimRateLimit()` chiamata prima di ogni login in `ensureUser()` - `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) #### Fase 3 — Password Reset + Context Switch (v1.3.0)
- ⚠1 = idempotency check che ritorna 401 su invite già usato — comportamento corretto - 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`: #### Fase 5 — Branding + Auth-gate (v1.5.0)
- Migration `019_firm_branding.sql`: tabella `firm_branding` (logo/colori/brand name per consulting firm)
| Endpoint | Scope | Scopo | - `BrandingController.php` (NUOVO): `GET /branding/current` (auth opzionale), `PUT /branding` (super_admin o consulente)
|---|---|---| - common.js `_loadFirmBranding()` applica CSS variables al boot
| `GET /api/services/gap-analysis` | `read:compliance` | Gap per dominio NIS2 → MOG 231 pillar | - `public/js/auth-gate.js` copiato/adattato da TRPG (gate password client-side per documenti riservati)
| `GET /api/services/measures` | `read:compliance` | compliance_controls con mog_area derivata | - **G15 skip**: simulator esistenti coprono demo flows
| `GET /api/services/incidents` | `read:incidents` | Art.23 CSIRT compliance per incidente | - **G18 skip**: refactor controller rinviato (~5gg investimento, valore tecnico)
| `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)
--- ---
## File modificati in questa sessione ## Scoperta CRITICA durante Fase 3: topologia DB
### Backend **Vedi `MEMORY.md``project_db_topology.md` per dettagli.**
- `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`
### Frontend / Router 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`.
- `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
**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 dal HOST Hetzner, NON `docker exec nis2-db mysql`.
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
```
## BigSim v2.0 — Risultati finali (2026-03-17) 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.
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())
--- ---
## Stato endpoint Services API (testati su prod — InfraTech org) ## File creati/modificati (riepilogo)
- ✓ `gap-analysis`: 10 domini, `suggested_action` presente, `not_implemented_items` presenti **SQL** (5 migration nuove, tutte applicate al DB host):
- ✓ `measures`: 13 controlli, `completion_percentage=38`, `mog_area` derivato da control_code - `docs/sql/015_sso_columns.sql`
- ✓ `incidents`: `art23` block per incidente con `_due`/`_sent`/`_overdue` - `docs/sql/016_active_sessions.sql`
- ✓ `training`: `art20_compliance`, `non_compliant_mandatory` presenti - `docs/sql/017_password_reset.sql`
- ✓ `deadlines?days=365`: 6 scadenze aggregate - `docs/sql/018_user_preferences.sql`
- ✓ `full-snapshot`: `compliance_score=38`, `gap_domains=10`, `incidents=1`, `deadlines=0` - `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) Tutti via `scp -i .ssh-temp/id_ed25519_nis2-agile_8h_*`:
- `presidenza@agile.software`: account senza org → deve fare onboarding - 4 PHP in `/var/www/nis2-agile/application/controllers/`
- P.IVA lookup CertiSource (`/api/company/enrich`) ritorna 404 — endpoint cambiato - 1 PHP in `/var/www/nis2-agile/application/services/`
- `POST /api/auth/validate-invite` implementato ma non nel router pubblico - 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) Nessun `docker restart` eseguito (non necessario, bind mount + PHP-FPM rilegge ad ogni request).
- `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) ## 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 ## Prossimi passi consigliati
1. lg231 aggiorna integrazione usando `full-snapshot` (riduce 6 chiamate → 1) 1. Validare 1.5.0 in produzione con il primo utente reale (test password reset + sessions UI + tenant switcher)
2. lg231 legge `not_implemented_items` per auto-generare evidence gaps per pillar 2. Attendere risoluzione AgileHub Ticket #220
3. Valutare endpoint pubblico per P.IVA lookup (attuale richiede auth JWT) 3. Ottenere SSO_INTERNAL_KEY dal Tenant MS
4. RAG su normativa NIS2, benchmark settoriale (Sprint 3 pianificato) 4. Replace placeholder vault: `docker exec vault-steward node /app/cli/vault-cli.js migrate tier1__nis2-app__sso internal_key '<real>'`
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

View File

@ -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=<da vault-steward>
```
**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 '<real>'`
- **[NIS2]** Bump `public/version.json`**1.1.0** (decisione §10.5) a switch confermato
Vedi §3-§6 per Fase 2-5.

View File

@ -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');

View File

@ -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';

View File

@ -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';

View File

@ -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');

View File

@ -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';

106
public/forgot-password.html Normal file
View File

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password dimenticata - NIS2 Agile</title>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
.back-link { display:block; text-align:center; margin-top:14px; font-size:.85rem; color:#6B7280; text-decoration:none; }
.back-link:hover { color: var(--color-primary, #2563eb); }
.auth-success { background:#ECFDF5; color:#065F46; border:1px solid #A7F3D0; padding:12px 16px; border-radius:6px; font-size:.9rem; margin-bottom:16px; display:none; }
.auth-success.visible { display:block; }
.auth-helper { font-size:.85rem; color:#6B7280; margin-bottom:18px; line-height:1.5; }
</style>
</head>
<body>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/>
</svg>
</div>
<span class="auth-logo-text">NIS2 <span>Agile</span></span>
</div>
<p class="auth-subtitle">Reimposta la tua password</p>
</div>
<div class="auth-body">
<div class="auth-error" id="err"></div>
<div class="auth-success" id="ok"></div>
<p class="auth-helper">Inserisci l'indirizzo email associato al tuo account. Ti invieremo un link valido 30 minuti per impostare una nuova password.</p>
<form id="forgot-form" novalidate>
<div class="form-group">
<label class="form-label" for="email">Indirizzo Email</label>
<input type="email" id="email" name="email" class="form-input"
placeholder="nome@azienda.it" autocomplete="email" required>
</div>
<button type="submit" class="btn btn-primary btn-lg w-full" id="submit-btn">
Invia link
</button>
</form>
</div>
<div class="auth-footer">
<a href="login.html" class="back-link"><i class="fas fa-arrow-left"></i> Torna al login</a>
</div>
</div>
</div>
<script src="js/api.js"></script>
<script>
const form = document.getElementById('forgot-form');
const err = document.getElementById('err');
const ok = document.getElementById('ok');
const btn = document.getElementById('submit-btn');
form.addEventListener('submit', async function(e) {
e.preventDefault();
err.classList.remove('visible');
ok.classList.remove('visible');
const email = document.getElementById('email').value.trim();
if (!email) {
err.textContent = 'Inserisci l\'indirizzo email.';
err.classList.add('visible');
return;
}
btn.disabled = true;
btn.textContent = 'Invio in corso...';
try {
const res = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
});
const data = await res.json();
if (res.status === 429) {
err.textContent = data.message || 'Troppe richieste. Riprova più tardi.';
err.classList.add('visible');
} else if (data.success) {
ok.textContent = data.message;
ok.classList.add('visible');
form.style.display = 'none';
} else {
err.textContent = data.message || 'Errore. Riprova.';
err.classList.add('visible');
}
} catch (e) {
err.textContent = 'Errore di connessione al server.';
err.classList.add('visible');
} finally {
btn.disabled = false;
btn.textContent = 'Invia link';
}
});
</script>
</body>
</html>

View File

@ -107,6 +107,8 @@ $controllerMap = [
'contact' => 'ContactController', // legacy 'contact' => 'ContactController', // legacy
'mktg-lead' => 'MktgLeadController', // standard condiviso TRPG/NIS2 'mktg-lead' => 'MktgLeadController', // standard condiviso TRPG/NIS2
'feedback' => 'FeedbackController', // segnalazioni & risoluzione AI '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])) { if (!isset($controllerMap[$controllerName])) {
@ -154,6 +156,20 @@ $actionMap = [
'PUT:profile' => 'updateProfile', 'PUT:profile' => 'updateProfile',
'POST:changePassword' => 'changePassword', 'POST:changePassword' => 'changePassword',
'POST:validateInvite' => 'validateInvite', // valida invite_token (no auth) '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 ────────────────────── // ── OrganizationController ──────────────────────
@ -399,6 +415,21 @@ $actionMap = [
'PUT:{id}' => 'update', 'PUT:{id}' => 'update',
'POST:{id}/resolve' => 'resolve', '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',
],
]; ];
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════

37
public/js/auth-gate.js Normal file
View File

@ -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 <head> PRIMA di qualsiasi altro script o CSS.
*
* Password di default: Nis2Agile2026!@
* Override per-pagina: aggiungere data-pw="..." allo script tag, es:
* <script src="/js/auth-gate.js" data-pw="Angelo@2026!"></script>
* 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 = '<head><meta charset="UTF-8"><title>Accesso riservato</title></head>'
+ '<body style="background:#0F172A;color:#fff;font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;">'
+ '<div style="text-align:center;padding:40px;max-width:420px;">'
+ '<div style="font-size:3rem;margin-bottom:20px;">&#128274;</div>'
+ '<h1 style="font-size:1.5rem;margin:0 0 12px;">Accesso riservato</h1>'
+ '<p style="color:#94A3B8;font-size:.95rem;line-height:1.6;margin:0 0 24px;">Questo documento richiede autenticazione. Contatta il team Agile Software per ottenere la password.</p>'
+ '<button onclick="location.reload()" style="padding:10px 24px;background:#3B82F6;color:#fff;border:none;border-radius:6px;font-size:.9rem;cursor:pointer;">Riprova</button>'
+ '</div></body>';
})();

View File

@ -200,6 +200,7 @@ function loadSidebar() {
{ name: 'Segnalazioni', href: 'whistleblowing.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg>` }, { name: 'Segnalazioni', href: 'whistleblowing.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg>` },
{ name: 'Normative', href: 'normative.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>` }, { name: 'Normative', href: 'normative.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>` },
{ name: 'AI Cross-Analysis', href: 'cross-analysis.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"/></svg>` }, { name: 'AI Cross-Analysis', href: 'cross-analysis.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"/></svg>` },
{ name: 'Knowledge Base', href: 'kb.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z"/></svg>` },
] ]
}, },
{ {
@ -269,6 +270,11 @@ function loadSidebar() {
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 001 1h6a1 1 0 100-2H4V5h5a1 1 0 100-2H3zm11.707 3.293a1 1 0 010 1.414L12.414 10l2.293 2.293a1 1 0 01-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0z" clip-rule="evenodd"/><path fill-rule="evenodd" d="M16 10a1 1 0 00-1-1H8a1 1 0 100 2h7a1 1 0 001-1z" clip-rule="evenodd"/></svg> <svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 001 1h6a1 1 0 100-2H4V5h5a1 1 0 100-2H3zm11.707 3.293a1 1 0 010 1.414L12.414 10l2.293 2.293a1 1 0 01-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0z" clip-rule="evenodd"/><path fill-rule="evenodd" d="M16 10a1 1 0 00-1-1H8a1 1 0 100 2h7a1 1 0 001-1z" clip-rule="evenodd"/></svg>
</button> </button>
</div> </div>
<div class="sidebar-version" id="sidebar-version" title="Versione applicazione"
style="text-align:center; padding:6px 12px; font-size:.68rem; color:var(--gray-400); cursor:pointer; border-top:1px solid var(--gray-100); margin-top:4px;"
onclick="_showVersionChangelog()">
v
</div>
</div> </div>
`; `;
@ -279,6 +285,12 @@ function loadSidebar() {
// Mobile toggle // Mobile toggle
_setupMobileToggle(); _setupMobileToggle();
// Version footer (Fase 4 / G13)
_loadVersionFooter();
// Firm branding white-label (Fase 5 / G16) — non bloccante
_loadFirmBranding();
} }
const _roleLabels = { const _roleLabels = {
@ -314,10 +326,9 @@ async function _loadUserInfo() {
// Save role to localStorage (ensures isConsultant() works across pages) // Save role to localStorage (ensures isConsultant() works across pages)
if (user.role) api.setUserRole(user.role); if (user.role) api.setUserRole(user.role);
// For consultants: render org-switcher // Tenant switcher: visibile per consulenti + per chiunque abbia ≥2 org
if (user.role === 'consultant') { // (Fase 3 / G10 — esposizione globale del context switch)
_loadConsultantOrgSwitcher(); _loadConsultantOrgSwitcher();
}
} }
} catch (e) { } catch (e) {
// Silenzioso // Silenzioso
@ -327,9 +338,12 @@ async function _loadUserInfo() {
async function _loadConsultantOrgSwitcher() { async function _loadConsultantOrgSwitcher() {
try { try {
const result = await api.listOrganizations(); 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; 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 currentOrgId = parseInt(api.orgId);
const currentOrg = orgs.find(o => (o.id || o.organization_id) === currentOrgId); 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); api.setOrganization(orgId);
window.location.reload(); 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() { function _setupMobileToggle() {
// Crea pulsante toggle se non esiste // Crea pulsante toggle se non esiste
if (!document.querySelector('.sidebar-toggle')) { if (!document.querySelector('.sidebar-toggle')) {
@ -799,3 +874,182 @@ function switchLang(lang) {
s.src = 'js/feedback.js'; s.src = 'js/feedback.js';
document.body.appendChild(s); 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 = '<i class="fa-solid fa-wand-magic-sparkles"></i>';
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 ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c];
});
}
function render() {
var list = history.map(function (m) {
var isUser = m.role === 'user';
return '<div style="margin-bottom:10px;display:flex;justify-content:'
+ (isUser ? 'flex-end' : 'flex-start') + '">'
+ '<div style="max-width:78%;padding:8px 12px;border-radius:14px;'
+ 'font-size:13px;line-height:1.45;background:'
+ (isUser ? 'linear-gradient(135deg,#7C3AED,#3B82F6)' : '#f3f4f6')
+ ';color:' + (isUser ? '#fff' : '#111827')
+ ';white-space:pre-wrap">' + escHtml(m.content) + '</div></div>';
}).join('');
panel.innerHTML =
'<div style="padding:14px 16px;background:linear-gradient(135deg,#7C3AED,#3B82F6);'
+ 'color:#fff;display:flex;justify-content:space-between;align-items:center">'
+ '<div style="display:flex;align-items:center;gap:8px;font-weight:600">'
+ '<i class="fa-solid fa-wand-magic-sparkles"></i> ARIA — Assistente NIS2'
+ '</div>'
+ '<button id="ai-chat-close" aria-label="Chiudi" style="background:none;'
+ 'border:none;color:#fff;font-size:18px;cursor:pointer">&times;</button>'
+ '</div>'
+ '<div id="ai-chat-msgs" style="flex:1;overflow-y:auto;padding:14px 16px;'
+ 'background:#fafafa">'
+ (list || '<div style="color:#9ca3af;font-size:13px;text-align:center;'
+ 'margin-top:24px">Ciao! Chiedimi qualcosa su NIS2: misure di '
+ 'sicurezza, audit, fornitori, incident response…</div>')
+ '</div>'
+ '<form id="ai-chat-form" style="display:flex;gap:6px;padding:10px;'
+ 'border-top:1px solid #e5e7eb;background:#fff">'
+ '<input id="ai-chat-input" type="text" placeholder="Scrivi…" '
+ 'autocomplete="off" style="flex:1;padding:10px 12px;border:1px solid '
+ '#d1d5db;border-radius:10px;font-size:14px;font-family:inherit">'
+ '<button type="submit" style="padding:10px 14px;border:none;'
+ 'border-radius:10px;background:linear-gradient(135deg,#7C3AED,#3B82F6);'
+ 'color:#fff;font-weight:600;cursor:pointer">'
+ '<i class="fa-solid fa-paper-plane"></i>'
+ '</button>'
+ '</form>';
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();
}
})();

View File

@ -69,7 +69,7 @@
<button type="submit" class="btn btn-primary btn-lg w-full" id="login-btn"> <button type="submit" class="btn btn-primary btn-lg w-full" id="login-btn">
Accedi Accedi
</button> </button>
<a href="#" class="forgot-link" onclick="alert('Contatta presidenza@agile.software per il reset password.');return false;"> <a href="forgot-password.html" class="forgot-link">
Password dimenticata? Password dimenticata?
</a> </a>
</form> </form>

171
public/reset-password.html Normal file
View File

@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Imposta nuova password - NIS2 Agile</title>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
.pw-wrap { position: relative; }
.pw-wrap .form-input { padding-right: 42px; }
.pw-toggle {
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
background: none; border: none; cursor: pointer;
color: #9CA3AF; font-size: 15px; padding: 0;
}
.strength-bar { display:flex; gap:3px; margin-top:6px; }
.strength-bar span { flex:1; height:4px; border-radius:2px; background:#E0E4E8; transition:background .2s; }
.strength-text { font-size:.78rem; color:#6B7280; margin-top:6px; min-height:16px; }
.auth-success { background:#ECFDF5; color:#065F46; border:1px solid #A7F3D0; padding:12px 16px; border-radius:6px; font-size:.9rem; margin-bottom:16px; display:none; }
.auth-success.visible { display:block; }
.pw-rules { font-size:.78rem; color:#6B7280; margin-top:8px; line-height:1.6; }
.back-link { display:block; text-align:center; margin-top:14px; font-size:.85rem; color:#6B7280; text-decoration:none; }
.back-link:hover { color: var(--color-primary, #2563eb); }
</style>
</head>
<body>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/>
</svg>
</div>
<span class="auth-logo-text">NIS2 <span>Agile</span></span>
</div>
<p class="auth-subtitle">Imposta una nuova password</p>
</div>
<div class="auth-body">
<div class="auth-error" id="err"></div>
<div class="auth-success" id="ok"></div>
<form id="reset-form" novalidate>
<div class="form-group">
<label class="form-label" for="pw">Nuova password</label>
<div class="pw-wrap">
<input type="password" id="pw" class="form-input"
placeholder="Almeno 8 caratteri" autocomplete="new-password" required>
<button type="button" class="pw-toggle" onclick="togglePw('pw',this)" aria-label="Mostra/nascondi password">
<i class="fas fa-eye"></i>
</button>
</div>
<div class="strength-bar"><span></span><span></span><span></span><span></span></div>
<div class="strength-text" id="strength-text"></div>
</div>
<div class="form-group">
<label class="form-label" for="pw2">Conferma password</label>
<div class="pw-wrap">
<input type="password" id="pw2" class="form-input"
placeholder="Ripeti la password" autocomplete="new-password" required>
<button type="button" class="pw-toggle" onclick="togglePw('pw2',this)" aria-label="Mostra/nascondi password">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<p class="pw-rules">Minimo 8 caratteri, con almeno una maiuscola, una minuscola e un numero.</p>
<button type="submit" class="btn btn-primary btn-lg w-full" id="submit-btn" style="margin-top:12px;">
Imposta password
</button>
</form>
</div>
<div class="auth-footer">
<a href="login.html" class="back-link"><i class="fas fa-arrow-left"></i> Torna al login</a>
</div>
</div>
</div>
<script>
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token') || '';
const err = document.getElementById('err');
const ok = document.getElementById('ok');
const form = document.getElementById('reset-form');
const btn = document.getElementById('submit-btn');
const pwInput = document.getElementById('pw');
const pwBars = document.querySelectorAll('.strength-bar span');
const pwText = document.getElementById('strength-text');
if (!token) {
err.textContent = 'Token mancante. Richiedi un nuovo link di reset.';
err.classList.add('visible');
form.style.display = 'none';
}
function togglePw(id, button) {
const i = document.getElementById(id);
if (i.type === 'password') { i.type = 'text'; button.innerHTML = '<i class="fas fa-eye-slash"></i>'; }
else { i.type = 'password'; button.innerHTML = '<i class="fas fa-eye"></i>'; }
}
function computeStrength(pw) {
let s = 0;
if (pw.length >= 8) s++;
if (pw.length >= 12) s++;
if (/[A-Z]/.test(pw) && /[a-z]/.test(pw)) s++;
if (/\d/.test(pw) && /[^A-Za-z0-9]/.test(pw)) s++;
return s;
}
pwInput.addEventListener('input', function() {
const s = computeStrength(pwInput.value);
const colors = ['#E0E4E8', '#EF4444', '#F59E0B', '#3B82F6', '#10B981'];
const labels = ['', 'Debole', 'Media', 'Buona', 'Ottima'];
pwBars.forEach(function(bar, i) { bar.style.background = (i < s) ? colors[s] : '#E0E4E8'; });
pwText.textContent = pwInput.value.length > 0 ? 'Sicurezza: ' + labels[s] : '';
});
form.addEventListener('submit', async function(e) {
e.preventDefault();
err.classList.remove('visible');
ok.classList.remove('visible');
const pw = document.getElementById('pw').value;
const pw2 = document.getElementById('pw2').value;
if (pw !== pw2) {
err.textContent = 'Le due password non coincidono.';
err.classList.add('visible');
return;
}
if (pw.length < 8) {
err.textContent = 'La password deve essere di almeno 8 caratteri.';
err.classList.add('visible');
return;
}
btn.disabled = true;
btn.textContent = 'Salvataggio...';
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token, new_password: pw })
});
const data = await res.json();
if (data.success) {
ok.textContent = data.message + ' Sarai reindirizzato al login.';
ok.classList.add('visible');
form.style.display = 'none';
setTimeout(function() { window.location.href = 'login.html'; }, 2500);
} else {
err.textContent = data.message || 'Errore. Token non valido o scaduto.';
err.classList.add('visible');
}
} catch (e) {
err.textContent = 'Errore di connessione al server.';
err.classList.add('visible');
} finally {
btn.disabled = false;
btn.textContent = 'Imposta password';
}
});
</script>
</body>
</html>

View File

@ -444,14 +444,6 @@
<h3>Sicurezza Account</h3> <h3>Sicurezza Account</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="security-item">
<div class="security-item-header">
<span class="security-item-title">Sessione Corrente</span>
<span class="badge badge-success">Attiva</span>
</div>
<p class="security-item-desc" id="session-info">Sessione autenticata tramite token JWT. L'accesso e' protetto da crittografia.</p>
</div>
<div class="security-item"> <div class="security-item">
<div class="security-item-header"> <div class="security-item-header">
<span class="security-item-title">Autenticazione a Due Fattori (2FA)</span> <span class="security-item-title">Autenticazione a Due Fattori (2FA)</span>
@ -465,7 +457,22 @@
<span class="security-item-title">Accesso API</span> <span class="security-item-title">Accesso API</span>
<span class="badge badge-info">JWT</span> <span class="badge badge-info">JWT</span>
</div> </div>
<p class="security-item-desc">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.</p> <p class="security-item-desc">L'accesso alle API avviene tramite token JWT (JSON Web Token) con scadenza automatica e meccanismo di refresh.</p>
</div>
</div>
</div>
<div class="card mb-24" id="card-sessions">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<div>
<h3>Sessioni Attive</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">Dispositivi attualmente loggati al tuo account. Puoi disconnetterli singolarmente o tutti tranne questo.</p>
</div>
<button class="btn btn-outline btn-danger" onclick="revokeAllOtherSessions()" id="revoke-all-btn" style="display:none;">Disconnetti gli altri</button>
</div>
<div class="card-body" style="padding:0;">
<div id="sessions-container">
<div class="spinner" style="margin:40px auto;"></div>
</div> </div>
</div> </div>
</div> </div>
@ -576,11 +583,96 @@
document.getElementById(panelMap[tab]).classList.add('active'); document.getElementById(panelMap[tab]).classList.add('active');
if (tab === 'members') loadMembers(); if (tab === 'members') loadMembers();
if (tab === 'security') loadAuditLog(); if (tab === 'security') { loadSessions(); loadAuditLog(); }
if (tab === 'apikeys') loadApiKeys(); if (tab === 'apikeys') loadApiKeys();
if (tab === 'webhooks') { loadWebhooks(); loadDeliveries(); } if (tab === 'webhooks') { loadWebhooks(); loadDeliveries(); }
} }
// ── Sessioni Multi-Device (Fase 2 / G07) ─────────────────
async function loadSessions() {
const container = document.getElementById('sessions-container');
container.innerHTML = '<div class="spinner" style="margin:40px auto;"></div>';
try {
const res = await api.get('/auth/sessions');
const sessions = res.data.sessions || [];
renderSessions(sessions);
} catch (e) {
container.innerHTML = '<div style="padding:20px; color:var(--gray-500);">Impossibile caricare le sessioni.</div>';
}
}
function renderSessions(sessions) {
const container = document.getElementById('sessions-container');
const revokeAllBtn = document.getElementById('revoke-all-btn');
if (!sessions.length) {
container.innerHTML = '<div style="padding:20px; color:var(--gray-500);">Nessuna sessione attiva.</div>';
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
? '<span class="badge badge-success" style="margin-left:8px;">Questo dispositivo</span>'
: '';
const actionBtn = isCurrent
? ''
: '<button class="btn btn-sm btn-outline btn-danger" onclick="revokeSession(\'' + s.id + '\')">Disconnetti</button>';
return '<div style="display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--gray-100);">'
+ '<div>'
+ '<div style="font-weight:600;">' + escapeHtml(s.device_label) + badge + '</div>'
+ '<div style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">'
+ 'IP ' + escapeHtml(s.ip_address) + ' &middot; Ultimo accesso ' + lastActivity + ' &middot; Login ' + created
+ '</div>'
+ '</div>'
+ actionBtn
+ '</div>';
}).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 { '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c];
});
}
// ── Settori NIS2 ───────────────────────────────────────── // ── Settori NIS2 ─────────────────────────────────────────
const sectorLabels = { const sectorLabels = {
energy: 'Energia', transport: 'Trasporti', banking: 'Banche', energy: 'Energia', transport: 'Trasporti', banking: 'Banche',

1
public/version.json Normal file
View File

@ -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"}