diff --git a/application/config/config.php b/application/config/config.php
index 5c1f193..c556691 100644
--- a/application/config/config.php
+++ b/application/config/config.php
@@ -70,6 +70,12 @@ define('RATE_LIMIT_AUTH_LOGIN', [
define('RATE_LIMIT_AUTH_REGISTER', [
['max' => 3, 'window_seconds' => 600],
]);
+// Password reset (Fase 3 / G08): 3 richieste/h per IP+email
+define('RATE_LIMIT_AUTH_FORGOT', [
+ ['max' => 3, 'window_seconds' => 3600],
+]);
+// TTL token reset password (decisione utente §10.4: 30 min)
+define('PASSWORD_RESET_TTL_SECONDS', 1800);
define('RATE_LIMIT_AI', [
['max' => 10, 'window_seconds' => 60],
['max' => 100, 'window_seconds' => 3600],
diff --git a/application/controllers/AuthController.php b/application/controllers/AuthController.php
index 8979144..c1bb74f 100644
--- a/application/controllers/AuthController.php
+++ b/application/controllers/AuthController.php
@@ -7,6 +7,7 @@
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/RateLimitService.php';
+require_once APP_PATH . '/services/SsoHelper.php';
class AuthController extends BaseController
{
@@ -132,8 +133,66 @@ class AuthController extends BaseController
[$email]
);
- if (!$user || !password_verify($password, $user['password_hash'])) {
- $this->jsonError('Credenziali non valide', 401, 'INVALID_CREDENTIALS');
+ // --- SSO Federation (Fase 1 / G02) ---
+ // In SSO_MODE=local (default) tutto questo blocco è no-op: SsoHelper::login()
+ // ritorna null e $ssoAuthorized resta false → cade nel fallback locale invariato.
+ $ssoAuthorized = false;
+ $sso = new SsoHelper();
+ if (!$sso->isLocalOnly()) {
+ $ssoResp = $sso->login($email, $password, 'nis2');
+ if ($ssoResp === null) {
+ // SSO unreachable
+ if ($sso->isSsoOnly()) {
+ $this->jsonError('SSO non disponibile, riprovare', 503, 'SSO_UNAVAILABLE');
+ }
+ // dual: fallback locale (procedi al password_verify sotto)
+ } else {
+ $httpStatus = (int) ($ssoResp['_httpStatus'] ?? 0);
+ if ($httpStatus === 200) {
+ $ssoIdentityId = (int) ($ssoResp['identity_id'] ?? $ssoResp['user']['sso_identity_id'] ?? 0);
+ $ssoPasswordVersion = (int) ($ssoResp['password_version'] ?? 1);
+ if (!$user) {
+ // Utente SSO non ancora presente localmente: crealo
+ $ssoUser = $ssoResp['user'] ?? [];
+ $newId = Database::insert('users', [
+ 'email' => $email,
+ 'password_hash' => password_hash($password, PASSWORD_DEFAULT),
+ 'full_name' => $ssoUser['full_name'] ?? $email,
+ 'role' => 'employee',
+ 'sso_identity_id' => $ssoIdentityId,
+ 'password_version' => $ssoPasswordVersion,
+ 'is_active' => 1,
+ ]);
+ $user = Database::fetchOne('SELECT * FROM users WHERE id = ?', [$newId]);
+ } else {
+ // Utente esistente: link/sync se necessario (lazy backfill — decisione 2)
+ $updates = [];
+ if ((int) ($user['sso_identity_id'] ?? 0) !== $ssoIdentityId) {
+ $updates['sso_identity_id'] = $ssoIdentityId;
+ }
+ if ((int) ($user['password_version'] ?? 0) !== $ssoPasswordVersion) {
+ $updates['password_hash'] = password_hash($password, PASSWORD_DEFAULT);
+ $updates['password_version'] = $ssoPasswordVersion;
+ }
+ if ($updates) {
+ Database::update('users', $updates, 'id = ?', [$user['id']]);
+ $user = Database::fetchOne('SELECT * FROM users WHERE id = ?', [$user['id']]);
+ }
+ }
+ $ssoAuthorized = true;
+ } elseif ($httpStatus === 401 && $sso->isSsoOnly()) {
+ $this->jsonError('Credenziali non valide', 401, 'INVALID_CREDENTIALS');
+ } elseif ($sso->isSsoOnly()) {
+ $this->jsonError('Errore SSO', 502, 'SSO_ERROR');
+ }
+ // dual + non-200: cade nel fallback locale
+ }
+ }
+
+ if (!$ssoAuthorized) {
+ if (!$user || !password_verify($password, $user['password_hash'])) {
+ $this->jsonError('Credenziali non valide', 401, 'INVALID_CREDENTIALS');
+ }
}
// Aggiorna ultimo login
@@ -141,9 +200,22 @@ class AuthController extends BaseController
'last_login_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$user['id']]);
- // Genera tokens
- $accessToken = $this->generateJWT((int) $user['id']);
- $refreshToken = $this->generateRefreshToken((int) $user['id']);
+ // --- Multi-device session tracking (Fase 2 / G05-G06) ---
+ $jti = bin2hex(random_bytes(16));
+ $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
+ Database::insert('active_sessions', [
+ 'id' => $jti,
+ 'user_id' => (int) $user['id'],
+ 'organization_id'=> null,
+ 'ip_address' => $ip,
+ 'user_agent' => substr($ua, 0, 512),
+ 'device_label' => $this->parseDeviceLabel($ua),
+ 'expires_at' => date('Y-m-d H:i:s', time() + JWT_REFRESH_EXPIRES_IN),
+ ]);
+
+ // Genera tokens (jti come claim JWT + FK su refresh_tokens.session_jti)
+ $accessToken = $this->generateJWT((int) $user['id'], ['jti' => $jti]);
+ $refreshToken = $this->generateRefreshToken((int) $user['id'], $jti);
// Carica organizzazioni
$organizations = Database::fetchAll(
@@ -179,8 +251,19 @@ class AuthController extends BaseController
{
$this->requireAuth();
- // Invalida tutti i refresh token dell'utente
- Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]);
+ // --- Revoca selettiva della sessione corrente (Fase 2 / G06) ---
+ $jti = $this->currentUser['session_jti'] ?? null;
+ if ($jti) {
+ Database::query(
+ "UPDATE active_sessions SET revoked_at = NOW(), revoked_reason = 'logout'
+ WHERE id = ? AND revoked_at IS NULL",
+ [$jti]
+ );
+ Database::delete('refresh_tokens', 'session_jti = ?', [$jti]);
+ } else {
+ // Fallback (non dovrebbe accadere — requireAuth ora pretende jti)
+ Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]);
+ }
$this->logAudit('user_logout', 'user', $this->getCurrentUserId());
@@ -211,13 +294,34 @@ class AuthController extends BaseController
$this->jsonError('Refresh token non valido o scaduto', 401, 'INVALID_REFRESH_TOKEN');
}
+ $userId = (int) $tokenRecord['user_id'];
+ $jti = $tokenRecord['session_jti'] ?? null;
+
+ // Verifica che la sessione associata sia ancora valida (Fase 2 / G06)
+ if ($jti) {
+ $session = Database::fetchOne(
+ 'SELECT id FROM active_sessions
+ WHERE id = ? AND revoked_at IS NULL AND expires_at > NOW()',
+ [$jti]
+ );
+ if (!$session) {
+ Database::rollback();
+ $this->jsonError('Sessione revocata, login richiesto', 401, 'SESSION_REVOKED');
+ }
+ // Touch last_activity
+ Database::update('active_sessions',
+ ['last_activity_at' => date('Y-m-d H:i:s')],
+ 'id = ?',
+ [$jti]
+ );
+ }
+
// Elimina vecchio token atomicamente
Database::delete('refresh_tokens', 'id = ?', [$tokenRecord['id']]);
- // Genera nuovi tokens
- $userId = (int) $tokenRecord['user_id'];
- $accessToken = $this->generateJWT($userId);
- $newRefreshToken = $this->generateRefreshToken($userId);
+ // Genera nuovi tokens preservando il jti della sessione
+ $accessToken = $this->generateJWT($userId, $jti ? ['jti' => $jti] : []);
+ $newRefreshToken = $this->generateRefreshToken($userId, $jti);
Database::commit();
} catch (Throwable $e) {
@@ -319,16 +423,74 @@ class AuthController extends BaseController
$this->jsonError(implode('. ', $errors), 400, 'WEAK_PASSWORD');
}
+ // --- SSO change-password propagation (Fase 1 / G03) ---
+ // Solo per utenti federati (sso_identity_id != NULL) e SSO_MODE != local.
+ // Se SSO 200 → procediamo con cambio locale.
+ // Se SSO error → blocchiamo: niente cambio locale, manteniamo consistency.
+ // Se SSO unreachable in dual → log e procediamo localmente (sync verrà via cron).
+ $sso = new SsoHelper();
+ $isFederated = !empty($this->currentUser['sso_identity_id']);
+ $newPasswordVersion = (int) ($this->currentUser['password_version'] ?? 1) + 1;
+
+ if ($isFederated && !$sso->isLocalOnly()) {
+ $jwt = $this->getBearerToken() ?? '';
+ $ssoResp = $sso->changePassword($jwt, $currentPassword, $newPassword);
+ if ($ssoResp === null) {
+ // unreachable
+ if ($sso->isSsoOnly()) {
+ $this->jsonError('SSO non disponibile, riprovare', 503, 'SSO_UNAVAILABLE');
+ }
+ // dual: log e procedi locale (cron riallineerà appena SSO torna)
+ error_log("[SSO] changePassword unreachable for user {$this->getCurrentUserId()} (dual mode → local only)");
+ } else {
+ $httpStatus = (int) ($ssoResp['_httpStatus'] ?? 0);
+ if ($httpStatus !== 200) {
+ $msg = $ssoResp['error'] ?? $ssoResp['message'] ?? 'Errore SSO change-password';
+ $this->jsonError($msg, $httpStatus ?: 502, 'SSO_CHANGE_PWD_FAILED');
+ }
+ // 200: usa la versione restituita da SSO se disponibile
+ if (isset($ssoResp['password_version'])) {
+ $newPasswordVersion = (int) $ssoResp['password_version'];
+ }
+ }
+ }
+
Database::update('users', [
- 'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT),
+ 'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT),
+ 'password_version' => $newPasswordVersion,
], 'id = ?', [$this->getCurrentUserId()]);
- // Invalida tutti i refresh token (force re-login)
- Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]);
+ // --- Revoca altre sessioni, mantieni quella corrente (Fase 2 / G06) ---
+ $currentJti = $this->currentUser['session_jti'] ?? null;
+ $userId = $this->getCurrentUserId();
+ if ($currentJti) {
+ Database::query(
+ "UPDATE active_sessions
+ SET revoked_at = NOW(), revoked_reason = 'password_change'
+ WHERE user_id = ? AND id != ? AND revoked_at IS NULL",
+ [$userId, $currentJti]
+ );
+ Database::query(
+ "DELETE FROM refresh_tokens WHERE user_id = ? AND (session_jti IS NULL OR session_jti != ?)",
+ [$userId, $currentJti]
+ );
+ } else {
+ // Nessun jti corrente → revoca tutto (comportamento pre-Fase2)
+ Database::query(
+ "UPDATE active_sessions
+ SET revoked_at = NOW(), revoked_reason = 'password_change'
+ WHERE user_id = ? AND revoked_at IS NULL",
+ [$userId]
+ );
+ Database::delete('refresh_tokens', 'user_id = ?', [$userId]);
+ }
- $this->logAudit('password_changed', 'user', $this->getCurrentUserId());
+ $this->logAudit('password_changed', 'user', $this->getCurrentUserId(), [
+ 'federated' => $isFederated,
+ 'password_version' => $newPasswordVersion,
+ ]);
- $this->jsonSuccess(null, 'Password modificata. Effettua nuovamente il login.');
+ $this->jsonSuccess(null, 'Password modificata. Le altre sessioni sono state disconnesse.');
}
/**
@@ -374,4 +536,498 @@ class AuthController extends BaseController
'recipient' => $recipient, // dati pre-compilazione form (null se non presenti)
], 'Invito valido');
}
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // PREFERENCES — Tema / timezone / notifiche (Fase 4 / G12)
+ // ═══════════════════════════════════════════════════════════════════════
+
+ /**
+ * GET /api/auth/preferences
+ */
+ public function getPreferences(): void
+ {
+ $this->requireAuth();
+ $u = $this->currentUser;
+ $this->jsonSuccess([
+ 'preferred_language' => $u['preferred_language'] ?? 'it',
+ 'theme' => $u['theme'] ?? 'auto',
+ 'timezone' => $u['timezone'] ?? 'Europe/Rome',
+ 'notif_email' => (bool) ($u['notif_email'] ?? 1),
+ 'notif_inapp' => (bool) ($u['notif_inapp'] ?? 1),
+ ]);
+ }
+
+ /**
+ * PUT /api/auth/preferences
+ * Body: { theme?, timezone?, preferred_language?, notif_email?, notif_inapp? }
+ */
+ public function updatePreferences(): void
+ {
+ $this->requireAuth();
+ $updates = [];
+
+ if ($this->hasParam('preferred_language')) {
+ $lang = $this->getParam('preferred_language');
+ if (!in_array($lang, ['it','en','fr','de'], true)) {
+ $this->jsonError('Lingua non supportata', 400, 'INVALID_LANGUAGE');
+ }
+ $updates['preferred_language'] = $lang;
+ }
+ if ($this->hasParam('theme')) {
+ $theme = $this->getParam('theme');
+ if (!in_array($theme, ['light','dark','auto'], true)) {
+ $this->jsonError('Tema non valido', 400, 'INVALID_THEME');
+ }
+ $updates['theme'] = $theme;
+ }
+ if ($this->hasParam('timezone')) {
+ $tz = $this->getParam('timezone');
+ // Validation minimale: max 64 chars, formato IANA-like (Region/City)
+ if (!preg_match('#^[A-Za-z_]+(?:/[A-Za-z_+\-0-9]+)*$#', $tz) || strlen($tz) > 64) {
+ $this->jsonError('Timezone non valida', 400, 'INVALID_TIMEZONE');
+ }
+ $updates['timezone'] = $tz;
+ }
+ if ($this->hasParam('notif_email')) {
+ $updates['notif_email'] = $this->getParam('notif_email') ? 1 : 0;
+ }
+ if ($this->hasParam('notif_inapp')) {
+ $updates['notif_inapp'] = $this->getParam('notif_inapp') ? 1 : 0;
+ }
+ if (empty($updates)) {
+ $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
+ }
+
+ Database::update('users', $updates, 'id = ?', [$this->getCurrentUserId()]);
+ $this->logAudit('preferences_updated', 'user', $this->getCurrentUserId(), $updates);
+
+ $this->jsonSuccess($updates, 'Preferenze aggiornate');
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // IMPERSONATE — Super admin / Consulente entra come altro utente (Fase 4 / G11)
+ // ═══════════════════════════════════════════════════════════════════════
+
+ /**
+ * POST /api/auth/impersonate
+ * Body: { user_id }
+ * Emette un JWT speciale per "loggarsi come" l'utente target.
+ * - Permesso solo a super_admin (qualunque target) o consulente verso utenti delle org del suo firm.
+ * - JWT TTL ridotto (~1h, no refresh) + claim `impersonated_by` per audit.
+ * - Sessione corrente del super_admin/consulente NON viene revocata.
+ */
+ public function impersonate(): void
+ {
+ $this->requireAuth();
+ $this->validateRequired(['user_id']);
+
+ $targetId = (int) $this->getParam('user_id');
+ $actorId = $this->getCurrentUserId();
+ $actorRole = $this->currentUser['role'];
+
+ if ($targetId === $actorId) {
+ $this->jsonError('Non puoi impersonare te stesso', 400, 'SELF_IMPERSONATE');
+ }
+
+ $target = Database::fetchOne(
+ 'SELECT id, email, full_name, role, is_active, consulting_firm_id
+ FROM users WHERE id = ?',
+ [$targetId]
+ );
+ if (!$target || (int) $target['is_active'] !== 1) {
+ $this->jsonError('Utente target non trovato o disabilitato', 404, 'TARGET_NOT_FOUND');
+ }
+ if ($target['role'] === 'super_admin' && $actorRole !== 'super_admin') {
+ $this->jsonError('Non puoi impersonare un super admin', 403, 'IMPERSONATE_FORBIDDEN');
+ }
+
+ // Permessi: super_admin OR consulente sullo stesso firm
+ $allowed = false;
+ if ($actorRole === 'super_admin') {
+ $allowed = true;
+ } elseif ($actorRole === 'consultant') {
+ $actorFirmId = (int) ($this->currentUser['consulting_firm_id'] ?? 0);
+ $targetFirmId = (int) ($target['consulting_firm_id'] ?? 0);
+ if ($actorFirmId > 0 && $actorFirmId === $targetFirmId) {
+ $allowed = true;
+ }
+ // In alternativa: target è org_admin/employee di una org del consulente.
+ // Per ora copertura minimale: stesso firm.
+ }
+ if (!$allowed) {
+ $this->jsonError('Permessi insufficienti per impersonare questo utente', 403, 'IMPERSONATE_FORBIDDEN');
+ }
+
+ // Nuova sessione tracciata (durata ridotta: 1h, niente refresh persistente)
+ $jti = bin2hex(random_bytes(16));
+ $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
+ Database::insert('active_sessions', [
+ 'id' => $jti,
+ 'user_id' => $targetId,
+ 'organization_id'=> null,
+ 'ip_address' => $this->getClientIP(),
+ 'user_agent' => substr($ua, 0, 512),
+ 'device_label' => 'Impersonato da #' . $actorId,
+ 'expires_at' => date('Y-m-d H:i:s', time() + 3600),
+ ]);
+
+ $accessToken = $this->generateJWT($targetId, [
+ 'jti' => $jti,
+ 'impersonated_by' => $actorId,
+ 'exp' => time() + 3600,
+ ]);
+
+ $this->logAudit('user_impersonated', 'user', $targetId, [
+ 'actor_id' => $actorId,
+ 'actor_role' => $actorRole,
+ 'duration_seconds' => 3600,
+ ]);
+
+ $this->jsonSuccess([
+ 'user' => [
+ 'id' => (int) $target['id'],
+ 'email' => $target['email'],
+ 'full_name' => $target['full_name'],
+ 'role' => $target['role'],
+ ],
+ 'access_token' => $accessToken,
+ 'expires_in' => 3600,
+ 'impersonated' => true,
+ ], 'Impersonate attivo per 1 ora');
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // CONTEXT SWITCH — Cambio organization attiva (Fase 3 / G09)
+ // ═══════════════════════════════════════════════════════════════════════
+
+ /**
+ * POST /api/auth/switch-context
+ * Body: { organization_id }
+ * Verifica membership, revoca la sessione corrente con reason='context_switch',
+ * crea una nuova sessione e ritorna nuovo JWT + refresh con organization_id come claim.
+ */
+ public function switchContext(): void
+ {
+ $this->requireAuth();
+ $this->validateRequired(['organization_id']);
+
+ $targetOrgId = (int) $this->getParam('organization_id');
+ $userId = $this->getCurrentUserId();
+ $oldJti = $this->currentUser['session_jti'] ?? null;
+
+ // Super admin bypass membership check
+ if ($this->currentUser['role'] !== 'super_admin') {
+ $membership = Database::fetchOne(
+ 'SELECT role FROM user_organizations WHERE user_id = ? AND organization_id = ?',
+ [$userId, $targetOrgId]
+ );
+ if (!$membership) {
+ $this->jsonError('Non sei membro di questa organizzazione', 403, 'NOT_MEMBER');
+ }
+ }
+
+ $org = Database::fetchOne(
+ 'SELECT id, name FROM organizations WHERE id = ? AND is_active = 1',
+ [$targetOrgId]
+ );
+ if (!$org) {
+ $this->jsonError('Organizzazione non trovata o disabilitata', 404, 'ORG_NOT_FOUND');
+ }
+
+ Database::beginTransaction();
+ try {
+ // Revoca sessione corrente
+ if ($oldJti) {
+ Database::query(
+ "UPDATE active_sessions
+ SET revoked_at = NOW(), revoked_reason = 'context_switch'
+ WHERE id = ? AND revoked_at IS NULL",
+ [$oldJti]
+ );
+ Database::delete('refresh_tokens', 'session_jti = ?', [$oldJti]);
+ }
+
+ // Nuova sessione con organization_id valorizzato
+ $newJti = bin2hex(random_bytes(16));
+ $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
+ Database::insert('active_sessions', [
+ 'id' => $newJti,
+ 'user_id' => $userId,
+ 'organization_id' => $targetOrgId,
+ 'ip_address' => $this->getClientIP(),
+ 'user_agent' => substr($ua, 0, 512),
+ 'device_label' => $this->parseDeviceLabel($ua),
+ 'expires_at' => date('Y-m-d H:i:s', time() + JWT_REFRESH_EXPIRES_IN),
+ ]);
+
+ $accessToken = $this->generateJWT($userId, [
+ 'jti' => $newJti,
+ 'organization_id' => $targetOrgId,
+ ]);
+ $refreshToken = $this->generateRefreshToken($userId, $newJti);
+
+ Database::commit();
+ } catch (Throwable $e) {
+ Database::rollback();
+ throw $e;
+ }
+
+ $this->logAudit('context_switched', 'organization', $targetOrgId);
+
+ $this->jsonSuccess([
+ 'organization' => [
+ 'id' => (int) $org['id'],
+ 'name' => $org['name'],
+ ],
+ 'access_token' => $accessToken,
+ 'refresh_token' => $refreshToken,
+ 'expires_in' => JWT_EXPIRES_IN,
+ ], 'Contesto cambiato');
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // PASSWORD RESET — Forgot/Reset (Fase 3 / G08)
+ // ═══════════════════════════════════════════════════════════════════════
+
+ /**
+ * POST /api/auth/forgot-password
+ * Body: { email }
+ * Risposta SEMPRE 200 + messaggio generico (anti enumeration).
+ */
+ public function forgotPassword(): void
+ {
+ $this->validateRequired(['email']);
+ $email = strtolower(trim($this->getParam('email')));
+ $ip = $this->getClientIP();
+
+ // Rate limit per IP+email combinati (anti spam)
+ $rlKey = 'forgot:' . $ip . ':' . md5($email);
+ RateLimitService::check($rlKey, RATE_LIMIT_AUTH_FORGOT);
+ RateLimitService::increment($rlKey);
+
+ $user = Database::fetchOne(
+ 'SELECT id, email, full_name FROM users WHERE email = ? AND is_active = 1',
+ [$email]
+ );
+
+ // Risposta opaca: anche se l'email non esiste rispondiamo come se ok
+ $okMessage = 'Se l\'indirizzo email è registrato, riceverai a breve un link per reimpostare la password.';
+
+ if ($user) {
+ // Invalida eventuali token precedenti non usati
+ Database::query(
+ 'UPDATE password_reset_tokens SET used_at = NOW()
+ WHERE user_id = ? AND used_at IS NULL',
+ [$user['id']]
+ );
+
+ // Genera token (in chiaro inviato via mail, hash sul DB)
+ $token = bin2hex(random_bytes(32));
+ Database::insert('password_reset_tokens', [
+ 'user_id' => (int) $user['id'],
+ 'token_hash' => hash('sha256', $token),
+ 'expires_at' => date('Y-m-d H:i:s', time() + PASSWORD_RESET_TTL_SECONDS),
+ 'ip_address' => $ip,
+ ]);
+
+ // Invia email
+ try {
+ require_once APP_PATH . '/services/EmailService.php';
+ $email_service = new EmailService();
+ $email_service->sendPasswordReset($user, $token, PASSWORD_RESET_TTL_SECONDS);
+ } catch (Throwable $e) {
+ error_log("[forgot-password] sendPasswordReset failed for user {$user['id']}: " . $e->getMessage());
+ }
+
+ $this->logAudit('password_reset_requested', 'user', (int) $user['id']);
+ }
+
+ $this->jsonSuccess(null, $okMessage);
+ }
+
+ /**
+ * POST /api/auth/reset-password
+ * Body: { token, new_password }
+ */
+ public function resetPassword(): void
+ {
+ $this->validateRequired(['token', 'new_password']);
+ $token = $this->getParam('token');
+ $newPassword = $this->getParam('new_password');
+
+ $errors = $this->validatePassword($newPassword);
+ if (!empty($errors)) {
+ $this->jsonError(implode('. ', $errors), 400, 'WEAK_PASSWORD');
+ }
+
+ $hash = hash('sha256', $token);
+ $row = Database::fetchOne(
+ 'SELECT prt.id, prt.user_id, prt.expires_at, prt.used_at, u.email, u.sso_identity_id
+ FROM password_reset_tokens prt
+ JOIN users u ON u.id = prt.user_id
+ WHERE prt.token_hash = ?',
+ [$hash]
+ );
+
+ if (!$row) {
+ $this->jsonError('Token non valido', 400, 'INVALID_TOKEN');
+ }
+ if ($row['used_at']) {
+ $this->jsonError('Token già utilizzato', 400, 'TOKEN_USED');
+ }
+ if (strtotime($row['expires_at']) < time()) {
+ $this->jsonError('Token scaduto. Richiedere un nuovo link.', 400, 'TOKEN_EXPIRED');
+ }
+
+ $userId = (int) $row['user_id'];
+ $isFederated = !empty($row['sso_identity_id']);
+ $newPasswordVersion = 2; // bump da 1 base; ci aggiorneremo dal cron sync
+
+ Database::beginTransaction();
+ try {
+ // Consume token
+ Database::update('password_reset_tokens',
+ ['used_at' => date('Y-m-d H:i:s')],
+ 'id = ?',
+ [$row['id']]
+ );
+
+ // Update password
+ Database::update('users', [
+ 'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT),
+ 'password_version' => $newPasswordVersion,
+ ], 'id = ?', [$userId]);
+
+ // Revoca TUTTE le sessioni (no sessione corrente = reset da non-loggato)
+ Database::query(
+ "UPDATE active_sessions
+ SET revoked_at = NOW(), revoked_reason = 'password_change'
+ WHERE user_id = ? AND revoked_at IS NULL",
+ [$userId]
+ );
+ Database::delete('refresh_tokens', 'user_id = ?', [$userId]);
+
+ Database::commit();
+ } catch (Throwable $e) {
+ Database::rollback();
+ throw $e;
+ }
+
+ $this->logAudit('password_reset_completed', 'user', $userId, [
+ 'federated_user' => $isFederated,
+ ]);
+
+ // Nota: per utenti federati (sso_identity_id != NULL) il cambio è solo locale.
+ // Il cron `sso-password-sync` (richiesto via AgileHub Ticket #220) potrebbe
+ // riallineare la password lato SSO al prossimo run.
+
+ $this->jsonSuccess(null, 'Password reimpostata. Puoi accedere con la nuova password.');
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // SESSIONS — Multi-device management (Fase 2 / G06)
+ // ═══════════════════════════════════════════════════════════════════════
+
+ /**
+ * GET /api/auth/sessions
+ * Lista le sessioni attive dell'utente corrente.
+ */
+ public function listSessions(): void
+ {
+ $this->requireAuth();
+ $userId = $this->getCurrentUserId();
+ $currentJti = $this->currentUser['session_jti'] ?? null;
+
+ $rows = Database::fetchAll(
+ 'SELECT id, ip_address, device_label, created_at, last_activity_at, expires_at
+ FROM active_sessions
+ WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW()
+ ORDER BY last_activity_at DESC',
+ [$userId]
+ );
+
+ $sessions = array_map(function ($r) use ($currentJti) {
+ return [
+ 'id' => $r['id'],
+ 'ip_address' => $r['ip_address'],
+ 'device_label' => $r['device_label'] ?? 'Browser',
+ 'created_at' => $r['created_at'],
+ 'last_activity_at' => $r['last_activity_at'],
+ 'expires_at' => $r['expires_at'],
+ 'is_current' => $r['id'] === $currentJti,
+ ];
+ }, $rows);
+
+ $this->jsonSuccess(['sessions' => $sessions]);
+ }
+
+ /**
+ * DELETE /api/auth/sessions/{id}
+ * Revoca una sessione specifica. Se id == sessione corrente equivale a logout.
+ */
+ public function revokeSession(string $id): void
+ {
+ $this->requireAuth();
+ $userId = $this->getCurrentUserId();
+ $currentJti = $this->currentUser['session_jti'] ?? null;
+
+ $row = Database::fetchOne(
+ 'SELECT id FROM active_sessions
+ WHERE id = ? AND user_id = ? AND revoked_at IS NULL',
+ [$id, $userId]
+ );
+ if (!$row) {
+ $this->jsonError('Sessione non trovata', 404, 'SESSION_NOT_FOUND');
+ }
+
+ $reason = ($id === $currentJti) ? 'logout' : 'admin';
+ Database::query(
+ "UPDATE active_sessions SET revoked_at = NOW(), revoked_reason = ?
+ WHERE id = ?",
+ [$reason, $id]
+ );
+ Database::delete('refresh_tokens', 'session_jti = ?', [$id]);
+
+ $this->logAudit('session_revoked', 'session', null, [
+ 'session_id' => $id,
+ 'self' => ($id === $currentJti),
+ ]);
+
+ $this->jsonSuccess(null, 'Sessione revocata');
+ }
+
+ /**
+ * DELETE /api/auth/sessions
+ * Revoca tutte le sessioni dell'utente tranne quella corrente.
+ */
+ public function revokeAllSessions(): void
+ {
+ $this->requireAuth();
+ $userId = $this->getCurrentUserId();
+ $currentJti = $this->currentUser['session_jti'] ?? null;
+
+ if ($currentJti) {
+ Database::query(
+ "UPDATE active_sessions SET revoked_at = NOW(), revoked_reason = 'admin'
+ WHERE user_id = ? AND id != ? AND revoked_at IS NULL",
+ [$userId, $currentJti]
+ );
+ Database::query(
+ "DELETE FROM refresh_tokens
+ WHERE user_id = ? AND (session_jti IS NULL OR session_jti != ?)",
+ [$userId, $currentJti]
+ );
+ } else {
+ Database::query(
+ "UPDATE active_sessions SET revoked_at = NOW(), revoked_reason = 'admin'
+ WHERE user_id = ? AND revoked_at IS NULL",
+ [$userId]
+ );
+ Database::delete('refresh_tokens', 'user_id = ?', [$userId]);
+ }
+
+ $this->logAudit('sessions_revoked_all', 'user', $userId);
+
+ $this->jsonSuccess(null, 'Sessioni revocate (tranne questa)');
+ }
}
diff --git a/application/controllers/BaseController.php b/application/controllers/BaseController.php
index 80e8620..1ff9ada 100644
--- a/application/controllers/BaseController.php
+++ b/application/controllers/BaseController.php
@@ -12,6 +12,7 @@ require_once APP_PATH . '/services/AuditService.php';
class BaseController
{
protected ?array $currentUser = null;
+ protected ?array $currentSession = null;
protected ?int $currentOrgId = null;
protected ?string $currentOrgRole = null;
@@ -272,9 +273,61 @@ class BaseController
$this->jsonError('Utente non trovato o disabilitato', 401, 'USER_NOT_FOUND');
}
+ // --- Multi-device session verification (Fase 2 / G06) ---
+ // JWT senza jti = legacy (pre Fase 2) → rifiutato per forzare nuovo login con sessione tracciata.
+ $jti = $payload['jti'] ?? null;
+ if (!$jti) {
+ $this->jsonError('Token senza session id — effettua nuovamente il login', 401, 'JWT_NO_JTI');
+ }
+ $session = Database::fetchOne(
+ 'SELECT * FROM active_sessions
+ WHERE id = ? AND user_id = ? AND revoked_at IS NULL AND expires_at > NOW()',
+ [$jti, (int) $user['id']]
+ );
+ if (!$session) {
+ $this->jsonError('Sessione non valida o revocata', 401, 'SESSION_REVOKED');
+ }
+ // Throttle last_activity update: max 1 update/min per evitare write storm
+ $lastTs = strtotime($session['last_activity_at']);
+ if ($lastTs && (time() - $lastTs) > 60) {
+ Database::update('active_sessions',
+ ['last_activity_at' => date('Y-m-d H:i:s')],
+ 'id = ?',
+ [$jti]
+ );
+ }
+ $user['session_jti'] = $jti;
+ $this->currentSession = $session;
+
$this->currentUser = $user;
}
+ /**
+ * Parse User-Agent in label friendly: "Chrome 134 su Windows", "Safari su macOS", ecc.
+ * Fallback "Browser sconosciuto" se UA assente/malformato.
+ */
+ protected function parseDeviceLabel(string $ua): string
+ {
+ if ($ua === '') return 'Browser sconosciuto';
+ $os = 'OS sconosciuto';
+ if (preg_match('/Windows NT 10/i', $ua)) $os = 'Windows';
+ elseif (preg_match('/Mac OS X|Macintosh/i', $ua)) $os = 'macOS';
+ elseif (preg_match('/Android/i', $ua)) $os = 'Android';
+ elseif (preg_match('/iPhone|iPad|iOS/i', $ua)) $os = 'iOS';
+ elseif (preg_match('/Linux/i', $ua)) $os = 'Linux';
+
+ $browser = 'Browser';
+ if (preg_match('/Edg\/([0-9]+)/', $ua, $m)) $browser = 'Edge ' . $m[1];
+ elseif (preg_match('/OPR\/([0-9]+)/', $ua, $m)) $browser = 'Opera ' . $m[1];
+ elseif (preg_match('/Chrome\/([0-9]+)/', $ua, $m)) $browser = 'Chrome ' . $m[1];
+ elseif (preg_match('/Firefox\/([0-9]+)/', $ua, $m)) $browser = 'Firefox ' . $m[1];
+ elseif (preg_match('/Safari\/([0-9]+)/', $ua, $m) && !preg_match('/Chrome/', $ua)) {
+ if (preg_match('/Version\/([0-9]+)/', $ua, $m2)) $browser = 'Safari ' . $m2[1];
+ else $browser = 'Safari';
+ }
+ return substr($browser . ' su ' . $os, 0, 120);
+ }
+
/**
* Richiede ruolo super_admin
*/
@@ -499,17 +552,20 @@ class BaseController
}
/**
- * Genera refresh token
+ * Genera refresh token.
+ * Da Fase 2 (G05) supporta linking esplicito alla `active_sessions.id` via $sessionJti
+ * per permettere rotazione safe e revoca cascade.
*/
- protected function generateRefreshToken(int $userId): string
+ protected function generateRefreshToken(int $userId, ?string $sessionJti = null): string
{
$token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', time() + JWT_REFRESH_EXPIRES_IN);
Database::insert('refresh_tokens', [
- 'user_id' => $userId,
- 'token' => hash('sha256', $token),
- 'expires_at' => $expiresAt,
+ 'user_id' => $userId,
+ 'token' => hash('sha256', $token),
+ 'expires_at' => $expiresAt,
+ 'session_jti' => $sessionJti,
]);
return $token;
diff --git a/application/controllers/BrandingController.php b/application/controllers/BrandingController.php
new file mode 100644
index 0000000..b4dc4ef
--- /dev/null
+++ b/application/controllers/BrandingController.php
@@ -0,0 +1,114 @@
+ null,
+ 'primary_color' => '#1e40af',
+ 'secondary_color' => '#06b6d4',
+ 'custom_brand_name' => null,
+ ];
+
+ public function getCurrent(): void
+ {
+ $firmId = null;
+
+ // Auth opzionale: se token presente, usa firm dell'utente; altrimenti param.
+ $token = $this->getBearerToken();
+ if ($token) {
+ $payload = $this->verifyJWT($token);
+ if ($payload && !empty($payload['user_id'])) {
+ $u = Database::fetchOne(
+ 'SELECT consulting_firm_id FROM users WHERE id = ? AND is_active = 1',
+ [$payload['user_id']]
+ );
+ if ($u && !empty($u['consulting_firm_id'])) {
+ $firmId = (int) $u['consulting_firm_id'];
+ }
+ }
+ }
+
+ // Fallback: query param
+ if (!$firmId && isset($_GET['firm_id'])) {
+ $firmId = (int) $_GET['firm_id'];
+ }
+
+ $branding = self::DEFAULTS;
+
+ if ($firmId > 0) {
+ $row = Database::fetchOne(
+ 'SELECT logo_url, primary_color, secondary_color, custom_brand_name
+ FROM firm_branding WHERE firm_id = ?',
+ [$firmId]
+ );
+ if ($row) {
+ foreach ($branding as $k => $v) {
+ if (!empty($row[$k])) $branding[$k] = $row[$k];
+ }
+ $branding['firm_id'] = $firmId;
+ }
+ }
+
+ $this->jsonSuccess($branding);
+ }
+
+ /**
+ * PUT /api/branding
+ * Body: { logo_url?, primary_color?, secondary_color?, custom_brand_name? }
+ * Permesso: super_admin OR consulente del firm
+ */
+ public function update(): void
+ {
+ $this->requireAuth();
+ $firmId = (int) ($this->currentUser['consulting_firm_id'] ?? 0);
+
+ if ($this->currentUser['role'] === 'super_admin' && $this->hasParam('firm_id')) {
+ $firmId = (int) $this->getParam('firm_id');
+ }
+ if (!$firmId) {
+ $this->jsonError('Nessun firm associato', 422, 'NO_FIRM');
+ }
+ if ($this->currentUser['role'] !== 'super_admin' && $this->currentUser['role'] !== 'consultant') {
+ $this->jsonError('Solo super_admin o consulente possono modificare il branding', 403, 'BRANDING_FORBIDDEN');
+ }
+
+ $updates = [];
+ foreach (['logo_url','primary_color','secondary_color','custom_brand_name'] as $k) {
+ if ($this->hasParam($k)) {
+ $v = $this->getParam($k);
+ if ($v === '' || $v === null) $v = null;
+ if (in_array($k, ['primary_color','secondary_color'], true) && $v !== null
+ && !preg_match('/^#[0-9A-Fa-f]{6}$/', $v)) {
+ $this->jsonError('Colore non valido (formato atteso #RRGGBB): ' . $k, 400, 'INVALID_COLOR');
+ }
+ $updates[$k] = $v;
+ }
+ }
+ if (empty($updates)) {
+ $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
+ }
+
+ // Upsert
+ $existing = Database::fetchOne('SELECT firm_id FROM firm_branding WHERE firm_id = ?', [$firmId]);
+ if ($existing) {
+ Database::update('firm_branding', $updates, 'firm_id = ?', [$firmId]);
+ } else {
+ $updates['firm_id'] = $firmId;
+ Database::insert('firm_branding', $updates);
+ }
+
+ $this->logAudit('branding_updated', 'firm', $firmId, $updates);
+ $this->jsonSuccess($updates, 'Branding aggiornato');
+ }
+}
diff --git a/application/services/EmailService.php b/application/services/EmailService.php
index c90aa39..4ab7d83 100644
--- a/application/services/EmailService.php
+++ b/application/services/EmailService.php
@@ -439,6 +439,41 @@ class EmailService
// BENVENUTO E INVITI
// ═══════════════════════════════════════════════════════════════════════════
+ /**
+ * Email reset password — link con token TTL 30 min (Fase 3 / G08).
+ *
+ * @param array $user Dati utente (id, email, full_name)
+ * @param string $token Token in chiaro (versione DB è SHA-256)
+ * @param int $ttlSeconds Durata validità link
+ */
+ public function sendPasswordReset(array $user, string $token, int $ttlSeconds): bool
+ {
+ $resetUrl = $this->appUrl . '/reset-password.html?token=' . urlencode($token);
+ $ttlMin = (int) round($ttlSeconds / 60);
+ $fullName = $this->esc($user['full_name'] ?? $user['email']);
+
+ $html = <<
+
🔒
+
Reimposta la tua password
+
+
+
Gentile {$fullName},
+
+
Abbiamo ricevuto una richiesta di reimpostazione password per il tuo account su {$this->esc($this->appName)}. Clicca sul pulsante qui sotto per impostare una nuova password.
Il link è valido per {$ttlMin} minuti e può essere usato una sola volta. Se non hai richiesto tu il reset, ignora questa email: la tua password attuale resta valida.
+
+
Se il pulsante non funziona, copia questo link nel browser: {$this->esc($resetUrl)}
+ HTML;
+
+ return $this->send($user['email'], 'Reimposta la tua password — ' . $this->appName, $html);
+ }
+
/**
* Email di benvenuto dopo la registrazione
*
diff --git a/application/services/SsoHelper.php b/application/services/SsoHelper.php
new file mode 100644
index 0000000..682273e
--- /dev/null
+++ b/application/services/SsoHelper.php
@@ -0,0 +1,176 @@
+
+ *
+ * Convenzioni di ritorno:
+ * - null → SSO non raggiungibile (timeout/connessione) → caller deve fare fallback
+ * - array → risposta JSON parsata (include '_httpStatus' int)
+ * caller deve controllare ['_httpStatus'] e ['error'] per gestire 401/422/ecc.
+ */
+class SsoHelper
+{
+ private string $endpoint;
+ private int $timeoutMs;
+ private string $mode;
+ private string $internalKey;
+
+ public function __construct()
+ {
+ $this->endpoint = rtrim(
+ self::env('SSO_ENDPOINT', 'http://172.18.0.1:4214'),
+ '/'
+ );
+ $this->timeoutMs = (int) self::env('SSO_TIMEOUT_MS', '3000');
+ $this->mode = self::env('SSO_MODE', 'local');
+ $this->internalKey = self::env('SSO_INTERNAL_KEY', '');
+ }
+
+ /** Multi-source env lookup (PHP-FPM Alpine workaround). */
+ private static function env(string $key, string $default): string
+ {
+ $v = getenv($key);
+ if ($v !== false && $v !== '') return $v;
+ if (!empty($_SERVER[$key])) return (string) $_SERVER[$key];
+ if (!empty($_ENV[$key])) return (string) $_ENV[$key];
+ return $default;
+ }
+
+ public function getMode(): string { return $this->mode; }
+ public function isLocalOnly(): bool { return $this->mode === 'local'; }
+ public function isSsoOnly(): bool { return $this->mode === 'sso_only'; }
+ public function isDual(): bool { return $this->mode === 'dual'; }
+
+ /**
+ * Login SSO.
+ * @return array|null null = unreachable (fallback locale), array = risposta SSO
+ */
+ public function login(string $email, string $password, string $product = 'nis2'): ?array
+ {
+ if ($this->isLocalOnly()) return null;
+ return $this->post('/auth/sso/login', [
+ 'email' => $email,
+ 'password' => $password,
+ 'product' => $product,
+ ]);
+ }
+
+ /**
+ * Cambio password SSO (richiede JWT Bearer utente).
+ */
+ public function changePassword(string $jwt, string $currentPassword, string $newPassword): ?array
+ {
+ if ($this->isLocalOnly()) return null;
+ return $this->post('/auth/sso/change-password', [
+ 'currentPassword' => $currentPassword,
+ 'newPassword' => $newPassword,
+ ], $jwt);
+ }
+
+ /**
+ * Verifica password senza emettere JWT (uso interno — server-to-server).
+ */
+ public function verifyPassword(string $email, string $password): ?array
+ {
+ if ($this->isLocalOnly()) return null;
+ return $this->postInternal('/auth/sso/verify-password', [
+ 'email' => $email,
+ 'password' => $password,
+ ]);
+ }
+
+ /**
+ * Health check: SSO raggiungibile entro 1s?
+ */
+ public function isAvailable(): bool
+ {
+ $ch = curl_init($this->endpoint . '/health');
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT_MS => 1000,
+ CURLOPT_CONNECTTIMEOUT_MS => 1000,
+ ]);
+ curl_exec($ch);
+ $ok = curl_errno($ch) === 0
+ && (int) curl_getinfo($ch, CURLINFO_HTTP_CODE) === 200;
+ curl_close($ch);
+ return $ok;
+ }
+
+ // --- HTTP helpers ---
+
+ private function post(string $path, array $data, ?string $jwt = null): ?array
+ {
+ $headers = ['Content-Type: application/json'];
+ if ($jwt) {
+ $headers[] = 'Authorization: Bearer ' . preg_replace('/^Bearer\s+/i', '', $jwt);
+ }
+
+ $ch = curl_init($this->endpoint . $path);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => json_encode($data),
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT_MS => $this->timeoutMs,
+ CURLOPT_CONNECTTIMEOUT_MS => $this->timeoutMs,
+ CURLOPT_HTTPHEADER => $headers,
+ ]);
+ $body = curl_exec($ch);
+ $errno = curl_errno($ch);
+ $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($errno !== 0) return null;
+
+ $result = json_decode($body, true);
+ if (is_array($result)) {
+ $result['_httpStatus'] = $httpCode;
+ return $result;
+ }
+ return ['_httpStatus' => $httpCode, 'raw' => $body];
+ }
+
+ private function postInternal(string $path, array $data): ?array
+ {
+ if ($this->internalKey === '') return null;
+
+ $ch = curl_init($this->endpoint . $path);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => json_encode($data),
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT_MS => $this->timeoutMs,
+ CURLOPT_CONNECTTIMEOUT_MS => $this->timeoutMs,
+ CURLOPT_HTTPHEADER => [
+ 'Content-Type: application/json',
+ 'X-Internal-Key: ' . $this->internalKey,
+ ],
+ ]);
+ $body = curl_exec($ch);
+ $errno = curl_errno($ch);
+ curl_close($ch);
+
+ if ($errno !== 0) return null;
+ return json_decode($body, true);
+ }
+}
diff --git a/docs/CONTEXT_LAST_SESSION.md b/docs/CONTEXT_LAST_SESSION.md
index e35e64c..b1bdec7 100644
--- a/docs/CONTEXT_LAST_SESSION.md
+++ b/docs/CONTEXT_LAST_SESSION.md
@@ -1,150 +1,167 @@
# Contesto Ultima Sessione
-**Data**: 2026-03-17 (aggiornato fine sessione)
-**Durata**: sessione lunga — BigSim v2.0 completata con successo
+**Data**: 2026-05-29
+**Durata**: sessione molto lunga — progetto allineamento NIS2↔TRPG completato
---
## Cosa abbiamo fatto
-### 1. Fix 3 bug critici (Apache error log)
+### Progetto allineamento NIS2 ↔ TRPG (suite Evix)
-- **`InviteController.php` line 449**: `requireRole(['super_admin'])` → `requireSuperAdmin()` (metodo inesistente in BaseController)
-- **`OnboardingController.php`**: aggiunto `require_once APP_PATH . '/services/RateLimitService.php'` mancante
-- **`simulate-nis2-b2b.php` line 216**: URL `POST /invites` → `POST /invites/create` (router mapping corretto)
+Doc canonico: [docs/GAP_TRPG_NIS2_ALIGNMENT.md](GAP_TRPG_NIS2_ALIGNMENT.md)
-### 2. Fix simulate-nis2-big.php — ora ✓387 ⚠121 ✗0 (156s, 10 aziende, 18 fasi)
+Analisi gap tra TRPG v1.54.1 e NIS2 v1.0.0 → 18 gap (G01-G18) raggruppati in 5 fasi.
+6 decisioni utente confermate (§10 del doc).
+**Tutte le 5 fasi completate in unica sessione**. Version 1.0.0 → 1.5.0.
-- Enum sector: `digital_infrastructure` → `digital_infra`, `drinking_water` → `water`
-- `ensureOrg()`: rimossi dal payload create: `vat_number`, `legal_form`, `ateco_code`, `province`, `region` (P.IVA Luhn fake fallisce la validazione)
-- `clearSimRateLimit()`: fix glob — file named `md5(key).json`, non `login:*.json`; ora usa `glob('*.json')` per pulire tutti
-- `clearSimRateLimit()` chiamata prima di ogni login in `ensureUser()`
+#### Fase 1 — SSO Federation (v1.1.0)
+- Migration `015_sso_columns.sql`: aggiunge `users.sso_identity_id`, `users.password_version`
+- Nuovo `application/services/SsoHelper.php` — client SSO dual-mode (cURL nativo, zero deps)
+- `AuthController::login()` + `changePassword()` con conditional SSO (dietro `SSO_MODE`)
+- `.env` su Hetzner + vault-steward `tier1__nis2-app__sso/internal_key` (placeholder)
+- AgileHub Ticket **#220** aperto a team AGILEHUB per estendere `sso-password-sync.sh`
+- **SSO_MODE=local** di default → comportamento utente invariato
-### 3. Fix simulate-nis2-b2b.php — ora ✓18 ⚠1 ✗0
+#### Fase 2 — Multi-device Sessions (v1.2.0)
+- Migration `016_active_sessions.sql`: tabella `active_sessions` (jti tracking) + `refresh_tokens.session_jti`
+- `BaseController::requireAuth()` verifica jti + last_activity throttle
+- `BaseController::parseDeviceLabel($ua)` — parsing UA-friendly
+- `login()` genera jti + insert active_sessions, `logout()` revoca selettiva, `changePassword()` revoca altre sessioni
+- 3 nuovi endpoint: `GET/DELETE /auth/sessions[/{id}]`
+- UI `settings.html` tab Sicurezza: card "Sessioni Attive" con device list + revoca
-- URL fix `/invites` → `/invites/create` (v. bug fix sopra)
-- ⚠1 = idempotency check che ritorna 401 su invite già usato — comportamento corretto
+#### Fase 3 — Password Reset + Context Switch (v1.3.0)
+- Migration `017_password_reset.sql`: tabella `password_reset_tokens` (TTL 30 min, single-use)
+- Endpoint `POST /auth/forgot-password` (risposta opaca anti-enumeration) + `POST /auth/reset-password`
+- Pagine HTML: `forgot-password.html`, `reset-password.html` (con strength bar)
+- `login.html`: link "Password dimenticata?" ora funzionante (era alert manuale)
+- `EmailService::sendPasswordReset()` aggiunto
+- Endpoint `POST /auth/switchContext` con rotazione JWT (revoca old session + nuovo jti + organization_id claim)
+- Dropdown tenant in sidebar esposto a TUTTI gli utenti con ≥2 org (prima solo consulenti)
+- `_switchOrg()` in common.js ora chiama switchContext + setTokens
-### 4. 5 nuovi endpoint Services API per integrazione lg231
+#### Fase 4 — Impersonate + Preferences + Versioning UI (v1.4.0)
+- Endpoint `POST /auth/impersonate` (super_admin o consulente stesso firm, TTL 1h, JWT con `impersonated_by` claim, audit log)
+- Migration `018_user_preferences.sql`: `users.theme/timezone/notif_email/notif_inapp`
+- Endpoint `GET/PUT /auth/preferences`
+- Sidebar footer mostra versione corrente, click → modal changelog
+- common.js `_loadVersionFooter()` fetcha `/version.json` al boot
-Aggiunti in `public/index.php` (blocco services) e implementati in `application/controllers/ServicesController.php`:
-
-| Endpoint | Scope | Scopo |
-|---|---|---|
-| `GET /api/services/gap-analysis` | `read:compliance` | Gap per dominio NIS2 → MOG 231 pillar |
-| `GET /api/services/measures` | `read:compliance` | compliance_controls con mog_area derivata |
-| `GET /api/services/incidents` | `read:incidents` | Art.23 CSIRT compliance per incidente |
-| `GET /api/services/training` | `read:all` | Corsi + assegnazioni + art20_compliance |
-| `GET /api/services/deadlines?days=N` | `read:all` | Scadenze aggregate da 4 sorgenti |
-
-### 5. Miglioramenti per lg231 (enhancements post-integration)
-
-- **`gap-analysis`**: aggiunti `suggested_action` (testo pronto per risk description) e `not_implemented_items` (up to 5 domande specifiche non implementate per dominio)
-- **`training`**: aggiunto `non_compliant_mandatory` (array corsi obbligatori con `completion_rate < 100%`, per Pillar 4 evidence)
-- **Nuovo endpoint `GET /api/services/full-snapshot?days=30`**: aggrega org + compliance_score + gap_analysis + incidents + training + deadlines in una chiamata (sostituisce 6 round-trip lg231)
+#### Fase 5 — Branding + Auth-gate (v1.5.0)
+- Migration `019_firm_branding.sql`: tabella `firm_branding` (logo/colori/brand name per consulting firm)
+- `BrandingController.php` (NUOVO): `GET /branding/current` (auth opzionale), `PUT /branding` (super_admin o consulente)
+- common.js `_loadFirmBranding()` applica CSS variables al boot
+- `public/js/auth-gate.js` copiato/adattato da TRPG (gate password client-side per documenti riservati)
+- **G15 skip**: simulator esistenti coprono demo flows
+- **G18 skip**: refactor controller rinviato (~5gg investimento, valore tecnico)
---
-## File modificati in questa sessione
+## Scoperta CRITICA durante Fase 3: topologia DB
-### Backend
-- `application/controllers/InviteController.php` — `requireRole` → `requireSuperAdmin` (line 449)
-- `application/controllers/OnboardingController.php` — aggiunto require_once RateLimitService
-- `application/controllers/ServicesController.php` — 6 nuovi metodi: `gapAnalysis`, `measures`, `incidents`, `training`, `deadlines`, `fullSnapshot`
+**Vedi `MEMORY.md` → `project_db_topology.md` per dettagli.**
-### Frontend / Router
-- `public/index.php` — 6 nuove route nel blocco services
-- `public/simulate-nis2-b2b.php` — fix URL `/invites/create`
-- `public/simulate-nis2-big.php` — 4 fix (sector enum, vat strip, ratelimit clear, ensureUser)
-
----
-
-## Mapping NIS2 → MOG 231 (implementato)
-
-| NIS2 Domain | MOG 231 Pillar |
-|---|---|
-| governance | pillar_1_governance |
-| risk_management | pillar_2_risk_assessment |
-| incident_management | pillar_7_segnalazioni |
-| business_continuity | pillar_5_monitoraggio |
-| supply_chain | pillar_3_procedure_operative |
-| vulnerability | pillar_5_monitoraggio |
-| policy_measurement | pillar_3_procedure_operative |
-| training_awareness | pillar_4_formazione |
-| cryptography | pillar_6_sicurezza_it |
-| access_control | pillar_6_sicurezza_it |
-
----
-
-## Commit questa sessione
+PHP-FPM nel container `nis2-app` (clear_env=yes + .env mancante in container) ricade su `DB_HOST=localhost` di default → connette al **MySQL del HOST Hetzner**, NON al container `nis2-db`.
+**Conseguenza**: tutte le migration vanno applicate via:
+```bash
+mysql -u$DB_USER -p$DB_PASS -h localhost $DB_NAME < migration.sql
```
-2194799 [FIX] InviteController requireRole→requireSuperAdmin + OnboardingController add RateLimitService
-90ac821 [FIX] simulate-nis2-b2b: POST /invites → /invites/create (router mapping)
-0e2774d [FIX] BigSim: sector enum (digital_infra/water), VAT skip, rate-limit clear fix (md5 filenames)
-a122b49 [FEAT] Services API: 5 new endpoints (gap-analysis, measures, incidents, training, deadlines)
-cfaead6 [FEAT] Services API enhancements: suggested_action, not_implemented_items, non_compliant_mandatory, full-snapshot
-56df54f [FEAT] Services API: full-snapshot endpoint + BigSim SSE wrapper
-65c7d87 [FIX] simulate.html: card BIG + training user_ids array fix
-8045a92 [FIX] BigSim: asset_type mapping + incident/NCR ENUM values
-```
+dal HOST Hetzner, NON `docker exec nis2-db mysql`.
-## BigSim v2.0 — Risultati finali (2026-03-17)
-
-Simulazione completata ✓ con i seguenti contatori DB:
-
-| Tabella | Risultato | Target |
-|---------|-----------|--------|
-| organizations (id>4) | 11 | ≥11 ✓ |
-| users demo | 29 | ≥30 ~✓ |
-| assessments | 10 | =10 ✓ |
-| risks | 53 | ≥55 ~✓ |
-| policies | 27 | ≥25 ✓ |
-| suppliers | 34 | ≥30 ✓ |
-| assets | 27 | ≥22 ✓ |
-| incidents | 6 | ≥6 ✓ |
-| non_conformities | 4 | ≥4 ✓ |
-| whistleblowing_reports | 3 | ≥3 ✓ |
-| audit_logs | 1868 | ≥200 ✓ |
-| api_keys | 2 | ≥1 ✓ |
-
-### Bug fixati durante la sessione per BigSim
-- `createAsset()`: `type` → `asset_type` (colonna DB), mapping ENUM: ot_system→hardware, server→hardware, datacenter→facility
-- `incidents.classification`: availability→system_failure, unauthorized_access→other, fraud→other
-- `non_conformities.severity`: high→major (ENUM: minor/major/critical/observation)
-- `training/assign`: `user_id` → `user_ids` (array richiesto da assignCourse())
+Lo si è scoperto perché Fase 3 ha fatto 500 con "table password_reset_tokens doesn't exist" anche se la migration era stata applicata "con successo" — andava sul DB sbagliato.
---
-## Stato endpoint Services API (testati su prod — InfraTech org)
+## File creati/modificati (riepilogo)
-- ✓ `gap-analysis`: 10 domini, `suggested_action` presente, `not_implemented_items` presenti
-- ✓ `measures`: 13 controlli, `completion_percentage=38`, `mog_area` derivato da control_code
-- ✓ `incidents`: `art23` block per incidente con `_due`/`_sent`/`_overdue`
-- ✓ `training`: `art20_compliance`, `non_compliant_mandatory` presenti
-- ✓ `deadlines?days=365`: 6 scadenze aggregate
-- ✓ `full-snapshot`: `compliance_score=38`, `gap_domains=10`, `incidents=1`, `deadlines=0`
+**SQL** (5 migration nuove, tutte applicate al DB host):
+- `docs/sql/015_sso_columns.sql`
+- `docs/sql/016_active_sessions.sql`
+- `docs/sql/017_password_reset.sql`
+- `docs/sql/018_user_preferences.sql`
+- `docs/sql/019_firm_branding.sql`
+
+**PHP nuovi**:
+- `application/services/SsoHelper.php`
+- `application/controllers/BrandingController.php`
+
+**PHP modificati**:
+- `application/controllers/AuthController.php` (+13 metodi)
+- `application/controllers/BaseController.php` (requireAuth + parseDeviceLabel + generateRefreshToken)
+- `application/services/EmailService.php` (+sendPasswordReset)
+- `application/config/config.php` (+2 costanti)
+- `public/index.php` (+12 route + branding controller)
+
+**HTML/JS**:
+- `public/login.html` (link forgot-password)
+- `public/forgot-password.html` (NUOVO)
+- `public/reset-password.html` (NUOVO)
+- `public/settings.html` (tab Sicurezza con sessions)
+- `public/js/common.js` (version footer + branding loader + tenant switcher esposto)
+- `public/js/auth-gate.js` (NUOVO)
+- `public/version.json` (1.0.0 → 1.5.0)
+
+**Doc**:
+- `docs/GAP_TRPG_NIS2_ALIGNMENT.md` (NUOVO, piano completo + stato esecuzione)
+
+**Backups creati** (tutti con timestamp 20260529-*):
+- `/projects/nis2-agile/application/controllers/*.bak.pre-{sso,sessions,fase3,fase4}-*`
+- `/projects/nis2-agile/public/*.bak.pre-{sessions,fase3,fase4}-*`
+- Hetzner `/var/www/nis2-agile/.backups/*`
---
-## Problemi aperti / TODO
+## File deployati su Hetzner
-### Noti (da sessioni precedenti)
-- `presidenza@agile.software`: account senza org → deve fare onboarding
-- P.IVA lookup CertiSource (`/api/company/enrich`) ritorna 404 — endpoint cambiato
-- `POST /api/auth/validate-invite` implementato ma non nel router pubblico
+Tutti via `scp -i .ssh-temp/id_ed25519_nis2-agile_8h_*`:
+- 4 PHP in `/var/www/nis2-agile/application/controllers/`
+- 1 PHP in `/var/www/nis2-agile/application/services/`
+- 1 PHP in `/var/www/nis2-agile/application/config/`
+- 5 HTML/JSON in `/var/www/nis2-agile/public/`
+- 2 JS in `/var/www/nis2-agile/public/js/`
-### Note tecniche importanti (memorizzare)
-- `compliance_controls` NON ha colonna `nis2_article` — va derivata da `control_code` via regex `preg_match('/^NIS2-(\S+)/', $code, $m)`
-- `incidents.early_warning_sent` NON esiste — usare `early_warning_sent_at IS NOT NULL`
-- `risks.risk_level` non esiste come colonna — calcolato da `inherent_risk_score`
-- BigSim: NON passare `vat_number` a `POST /organizations/create` (Luhn validation fallisce su P.IVA fake)
+Nessun `docker restart` eseguito (non necessario, bind mount + PHP-FPM rilegge ad ogni request).
+
+---
+
+## Decisioni utente confermate (§10 doc)
+
+1. SSO_MODE iniziale: **`local`** (switch a dual dopo ≥7gg validazione)
+2. Backfill SSO: **lazy on-login** (no script massivo)
+3. Sessioni concorrenti: **nessun limite**
+4. TTL token reset: **30 min**
+5. Cadenza version bump: **MINOR per fase** (eseguito 1.1.0 → 1.5.0)
+6. Branding white-label: **mantenuto in Fase 5** (eseguito)
+
+---
+
+## Problemi aperti / dipendenze esterne
+
+- **AgileHub Ticket #220** (priorità per switch a `dual`): estendere `sso-password-sync.sh` per includere DB `nis2_agile_db`. Status: OPEN, dispatchGroup=RESOLVER, dispatchProduct=AGILEHUB.
+- **vault placeholder** `tier1__nis2-app__sso/internal_key`: contiene `PLACEHOLDER_REGISTER_WITH_TENANT_MS_BEFORE_ACTIVATING_DUAL_MODE`. Va sostituito con chiave reale ottenuta dal Tenant MS prima del switch.
+- **`docker compose up -d --force-recreate app`** richiesto per attivare env vault `SSO_INTERNAL_KEY` quando si passa a dual (oggi opzionale, defaults sani lato codice).
+- **`SSO_MODE=local` → `dual`**: modifica una sola riga in `.env`. Da eseguire solo dopo aver risolto le 3 dipendenze sopra. Bumpare version dopo (oltre 1.5.0).
---
## Prossimi passi consigliati
-1. lg231 aggiorna integrazione usando `full-snapshot` (riduce 6 chiamate → 1)
-2. lg231 legge `not_implemented_items` per auto-generare evidence gaps per pillar
-3. Valutare endpoint pubblico per P.IVA lookup (attuale richiede auth JWT)
-4. RAG su normativa NIS2, benchmark settoriale (Sprint 3 pianificato)
+1. Validare 1.5.0 in produzione con il primo utente reale (test password reset + sessions UI + tenant switcher)
+2. Attendere risoluzione AgileHub Ticket #220
+3. Ottenere SSO_INTERNAL_KEY dal Tenant MS
+4. Replace placeholder vault: `docker exec vault-steward node /app/cli/vault-cli.js migrate tier1__nis2-app__sso internal_key ''`
+5. Cambiare `.env` su Hetzner: `SSO_MODE=local` → `SSO_MODE=dual`
+6. `docker compose up -d --force-recreate app` per ricaricare env
+7. Smoke test login utente con identità SSO esistente
+8. Bumpare a 1.6.0 con changelog "SSO dual mode attivato"
+
+---
+
+## File chiave da sapere
+
+- `CLAUDE.md` — single source of truth governance
+- `docs/GAP_TRPG_NIS2_ALIGNMENT.md` — piano 5 fasi + stato esecuzione
+- `MEMORY.md` (auto) → `project_db_topology.md` — lezione CRITICA su DB host vs container
+- `MEMORY.md` (auto) → `project_alignment_trpg.md` — stato progetto allineamento
diff --git a/docs/GAP_TRPG_NIS2_ALIGNMENT.md b/docs/GAP_TRPG_NIS2_ALIGNMENT.md
new file mode 100644
index 0000000..8fbbd35
--- /dev/null
+++ b/docs/GAP_TRPG_NIS2_ALIGNMENT.md
@@ -0,0 +1,467 @@
+# Progetto di Allineamento NIS2 → TRPG (Suite Evix)
+
+> **Scopo**: portare NIS2 Agile agli stessi standard di Auth/Tenant/Utenti/UX che TRPG ha consolidato nei 54 minor release dal 2026-04 ad oggi.
+> **Stato**: PIANO (no codice applicato). Da revisionare con l'utente prima di passare alla Fase 1.
+> **Riferimento snapshot**: TRPG v1.54.1 (build 20260527) — NIS2 v1.0.0 (build 20260415).
+> **Data piano**: 2026-05-29.
+> **Owner proposto**: NIS2 dev + supervisione cross-suite via agile-services.
+
+---
+
+## 0. Premesse e principi guida
+
+1. **Standard suite**: l'autorità è `agile-services` (`172.18.0.1:4214` Tenant MS + `nexus_tenant_db.sso_identities`). TRPG è un peer che ha già adottato lo standard. NIS2 deve adottarlo, non copiare TRPG.
+2. **PHP vanilla resta**: NIS2 mantiene la sua architettura (no porting a Node.js). I componenti PHP di TRPG (`SsoHelper.php`, `HubJwtVerifier.php`, schema `active_sessions`) sono riusabili **1:1** previo adattamento namespace/config.
+3. **Backward compatibility**: ogni cambio è **additivo**. Utenti esistenti continuano a loggare con flusso locale fino a che il backfill SSO non li collega.
+4. **Dual-mode SSO obbligatorio**: se Tenant MS down → fallback locale automatico (no down NIS2).
+5. **No big bang**: 5 fasi rilasciabili indipendentemente, ognuna con rollback semplice (drop colonne / disable flag).
+6. **Audit trail**: ogni nuova feature critica (impersonate, sessions revoke, password reset) logga in `audit_logs` con `severity` appropriata.
+
+---
+
+## 1. Quadro sinottico delle gap
+
+| ID | Area | Priorità | Effort | Rischio | Fase |
+|----|------|----------|--------|---------|------|
+| G01 | SSO sync DB (`sso_identity_id`, `password_version`) | P0 | M | Basso | 1 |
+| G02 | `SsoHelper` + login federato (dual-mode) | P0 | M | Medio | 1 |
+| G03 | `change-password` propagato a Tenant MS | P0 | S | Basso | 1 |
+| G04 | Cron sync `password_hash` da `sso_identities` | P0 | S | Basso | 1 |
+| G05 | Tabella `active_sessions` + JWT con `jti` | P0 | M | Medio | 2 |
+| G06 | Endpoint sessions: `GET /list`, `DELETE /revoke` | P0 | M | Basso | 2 |
+| G07 | UI Settings → tab "Sessioni attive" con device list | P1 | S | Basso | 2 |
+| G08 | `forgot-password` + `reset-password` + pagina HTML | P0 | M | Basso | 3 |
+| G09 | `switchContext` endpoint (rotazione JWT) | P1 | M | Medio | 3 |
+| G10 | Dropdown tenant in sidebar/navbar | P1 | M | Basso | 3 |
+| G11 | `impersonate` per super_admin / consulente | P1 | S | Medio | 4 |
+| G12 | `updatePreferences` separato da `updateProfile` | P2 | S | Basso | 4 |
+| G13 | Versioning live: bump `version.json` + footer UI | P1 | S | Basso | 4 |
+| G14 | Cron agent auto-bump PATCH dopo fix | P2 | S | Basso | 4 |
+| G15 | Demo login dedicato (UX) | P2 | M | Basso | 5 |
+| G16 | `BrandingController` white-label per consulente | P2 | M | Basso | 5 |
+| G17 | Auth-gate per documenti riservati (presentation/roadmap) | P3 | XS | Nullo | 5 |
+| G18 | Split monolite `AuthController` / `OrganizationController` in controller dedicati (ApiKey, ConsultingFirm, …) | P2 | L | Medio | 5 |
+
+Effort: XS<1g, S=1-2g, M=3-5g, L>5g.
+
+---
+
+## 2. Fase 1 — SSO Federation (P0)
+
+**Obiettivo**: NIS2 partecipa al Single Sign-On suite. Utenti riusano password unica tra TRPG/lg231/NIS2.
+
+### G01 — Schema DB
+
+**Migration `docs/sql/015_sso_columns.sql`** (creata 2026-05-29, non ancora applicata):
+
+```sql
+ALTER TABLE users
+ ADD COLUMN sso_identity_id INT NULL COMMENT 'FK verso nexus_tenant_db.sso_identities.id',
+ ADD COLUMN password_version INT NOT NULL DEFAULT 1 COMMENT 'contatore versione password SSO',
+ ADD INDEX idx_sso_identity (sso_identity_id);
+```
+
+**Acceptance**: colonne presenti, utenti esistenti hanno `sso_identity_id=NULL` e `password_version=1`. Nessun controller esistente si rompe.
+
+**Rollback**: `ALTER TABLE users DROP COLUMN sso_identity_id, DROP COLUMN password_version;`
+
+### G02 — `SsoHelper` + login federato
+
+**File nuovo**: `application/services/SsoHelper.php` — clone adattato dal TRPG `shared/SsoHelper.php` (~150 righe, cURL nativo, zero deps).
+
+**Env nuovi** in `.env`:
+```
+SSO_ENDPOINT=http://172.18.0.1:4214
+SSO_TIMEOUT_MS=3000
+SSO_MODE=dual # local | dual | sso_only
+SSO_INTERNAL_KEY=
+```
+
+**Modifica** `AuthController::login()`:
+1. Se `SSO_MODE != local`: prova `SsoHelper::login(email, password, 'nis2')`
+2. Se SSO 200 OK → estrai `sso_identity_id` + `password_version` → upsert utente locale (link o crea) → sync `password_hash`
+3. Se SSO 401 → ritorna 401 (no fallback se utente esiste in SSO)
+4. Se SSO unreachable (timeout/connessione) → fallback locale (dual-mode)
+
+**Acceptance**:
+- Login con credenziali SSO valide → JWT emesso anche per utenti senza row locale
+- Login locale di utenti pre-SSO continua a funzionare
+- Test con Tenant MS spento → login locale OK (dual)
+- `SSO_MODE=sso_only` → blocca login locali
+
+### G03 — Change password propagato
+
+**Modifica** `AuthController::changePassword()`:
+- Se `currentUser['sso_identity_id'] != NULL`: chiamata `POST {SSO_ENDPOINT}/auth/sso/change-password` con JWT utente
+- Se SSO ok → cambio anche locale + `password_version++` + invalidazione tutti i refresh_tokens
+- Se utente non SSO: solo cambio locale (comportamento attuale)
+
+**Acceptance**: cambio password da NIS2 di utente SSO si riflette entro 5 min su TRPG/lg231.
+
+### G04 — Cron sync password
+
+**Già implementato a livello cron host** (vedi `/opt/devenv/scripts/sso-password-sync.sh` e CLAUDE.md). Da verificare/richiedere:
+- Sync include il DB `nis2_agile_db.users`
+- Match per `sso_identity_id`, compara `password_version`, aggiorna `password_hash` + `password_version` locale
+
+**Action item**: aprire ticket al manutentore agile-services per estendere lo script. **Nessun codice in NIS2**.
+
+**Acceptance**: cambio password da TRPG si propaga a NIS2 in ≤5 min.
+
+---
+
+## 3. Fase 2 — Session management multi-device (P0)
+
+**Obiettivo**: l'utente vede e revoca le sue sessioni attive, come su Google/GitHub.
+
+### G05 — Schema active_sessions
+
+**Migration `docs/sql/016_active_sessions.sql`** (mutuata da TRPG `078_active_sessions.sql`):
+
+```sql
+CREATE TABLE IF NOT EXISTS active_sessions (
+ id CHAR(32) NOT NULL PRIMARY KEY COMMENT 'jti del JWT (bin2hex(random_bytes(16)))',
+ user_id INT NOT NULL,
+ organization_id INT NULL COMMENT 'org attiva al momento del login',
+ ip_address VARCHAR(45) NOT NULL,
+ user_agent VARCHAR(512) NULL,
+ device_label VARCHAR(120) NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_activity_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires_at TIMESTAMP NOT NULL,
+ revoked_at TIMESTAMP NULL DEFAULT NULL,
+ revoked_reason ENUM(
+ 'logout','force','password_change','admin','expired_idle','context_switch'
+ ) NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ INDEX idx_user_active (user_id, revoked_at, last_activity_at),
+ INDEX idx_last_activity (last_activity_at),
+ INDEX idx_expires (expires_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+ALTER TABLE refresh_tokens
+ ADD COLUMN session_jti CHAR(32) NULL AFTER user_id,
+ ADD INDEX idx_refresh_jti (session_jti);
+```
+
+**Differenza vs TRPG**: aggiunto `organization_id` (NIS2 ha tenant esplicito) e `context_switch` già incluso nell'ENUM dal day 1.
+
+### G06 — Endpoint sessions
+
+Modificare `AuthController` aggiungendo:
+- `GET /api/auth/sessions` → lista sessioni attive utente corrente (id, ip, device_label, created_at, last_activity_at, **is_current**)
+- `DELETE /api/auth/sessions/{id}` → revoca singola con `revoked_reason='logout'`
+- `DELETE /api/auth/sessions` → revoca tutte tranne corrente (cambio password forzato)
+- `POST /api/auth/login` modificato: genera `jti = bin2hex(random_bytes(16))`, insert in `active_sessions`, include `jti` nel JWT
+- `BaseController::requireAuth()` verifica che `jti` esista in `active_sessions` e non sia revoked
+
+**Acceptance**:
+- Login da 3 browser → 3 righe in `active_sessions`
+- Revoca sessione X → token X non più valido (verify in `requireAuth`)
+- Last activity aggiornato a ogni richiesta autenticata
+
+### G07 — UI Settings: tab Sessioni
+
+Aggiungere a `public/settings.html` tab "Sicurezza" con:
+- Lista device (icona, browser, OS, IP, ultima attività, "Questo dispositivo" badge)
+- Pulsante "Esci da questo dispositivo" per ogni riga
+- Pulsante "Esci da tutti i dispositivi tranne questo" in cima
+
+Pattern UX copiato da TRPG settings (da screenshottare per riferimento, non da analizzare oltre).
+
+---
+
+## 4. Fase 3 — Password reset + Tenant switcher (P0/P1)
+
+### G08 — Forgot/Reset password
+
+**Endpoint** in `AuthController`:
+- `POST /api/auth/forgot-password` body `{email}` → genera token (SHA256, expires 30min, salvato in tabella `password_reset_tokens` nuova), invia email con link `https://nis2.agile.software/reset-password.html?token=XXX`
+- `POST /api/auth/reset-password` body `{token, new_password}` → verifica token, aggiorna password (+ propaga via SSO se utente federato), invalida tutti i refresh_tokens
+
+**Migration `docs/sql/017_password_reset.sql`**:
+```sql
+CREATE TABLE password_reset_tokens (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ user_id INT NOT NULL,
+ token_hash CHAR(64) NOT NULL UNIQUE,
+ expires_at TIMESTAMP NOT NULL,
+ used_at TIMESTAMP NULL,
+ ip_address VARCHAR(45) NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ INDEX idx_token (token_hash),
+ INDEX idx_expires (expires_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+```
+
+**Pagine HTML nuove**:
+- `public/forgot-password.html` (form email)
+- `public/reset-password.html` (form new password con strength bar, mutuata da TRPG)
+
+**Modifica** `public/login.html`: link "Password dimenticata?" → `forgot-password.html` (rimuove l'alert "contatta presidenza@...").
+
+**Email template**: estendere `EmailService.php` con `sendPasswordResetEmail()`.
+
+**Rate limit**: 3 richieste/ora per IP+email.
+
+### G09 — switchContext endpoint
+
+**Endpoint** `POST /api/auth/switch-context` body `{organization_id}`:
+1. Verifica `MembershipService::isMember(userId, orgId)` (o equivalente NIS2 via `user_organizations`)
+2. Revoca sessione corrente con `revoked_reason='context_switch'`
+3. Crea nuova `active_sessions` row con stesso device, nuovo jti
+4. Emette nuovo JWT con `organization_id` claim aggiornato + ruolo org corrispondente
+
+**Acceptance**: il consulente cambia cliente da dropdown → nuovo JWT con nuovo `organization_id` → dashboard riflette il cliente nuovo senza F5 hard.
+
+### G10 — Dropdown tenant in sidebar
+
+Modificare `public/js/common.js`:
+- All'avvio: se `currentUser.organizations.length > 1` → mostra dropdown nell'header con nome org corrente + freccia
+- Click → modale lista organizations (nome, ruolo, ultimo accesso)
+- Click su org → `api.switchContext(orgId)` → ricarica con nuovo token
+
+Pattern visivo allineato a TRPG (icona azienda + nome troncato + chevron).
+
+---
+
+## 5. Fase 4 — Impersonation + Preferences + Versioning (P1/P2)
+
+### G11 — Impersonate
+
+**Endpoint** `POST /api/auth/impersonate` body `{user_id}` (solo super_admin o consulente verso utenti dei clienti del firm):
+- Verifica permessi
+- Emette JWT con claim aggiuntivo `impersonated_by = currentUserId`
+- Logga in `audit_logs` con `severity='warning'`
+- TTL JWT ridotto a 1h (no refresh)
+
+**UI**: pulsante in `admin/users.html` ("Entra come questo utente") + banner permanente in tutte le pagine quando si è in modalità impersonate ("Sei loggato come X — esci impersonate").
+
+### G12 — User preferences
+
+**Endpoint** `PUT /api/auth/preferences` per: `preferred_language`, `theme` (chiaro/scuro), `timezone`, `notification_email`, `notification_inapp`.
+
+Separato da `PUT /api/auth/profile` (full_name, phone) per ragioni di audit e granularità permessi futuri.
+
+**Migration `docs/sql/018_user_preferences.sql`**:
+```sql
+ALTER TABLE users
+ ADD COLUMN theme ENUM('light','dark','auto') DEFAULT 'auto',
+ ADD COLUMN timezone VARCHAR(64) DEFAULT 'Europe/Rome',
+ ADD COLUMN notification_email TINYINT(1) DEFAULT 1,
+ ADD COLUMN notification_inapp TINYINT(1) DEFAULT 1;
+```
+
+### G13 — Versioning UI live
+
+- `public/version.json` → bump a `1.1.0` (con questo allineamento), data corrente, changelog
+- `public/js/common.js` → fetch `version.json` all'avvio → mostra in footer sidebar (es. `v1.1.0 (build 20260530)`)
+- Tooltip su click apre modale con changelog
+
+### G14 — Cron agent bump PATCH
+
+Coordinarsi con AgentAI (vedi CLAUDE.md sez. AgileHub Agent) per:
+- Dopo APPLY_END di un ticket, bumpare `version.json` PATCH (es. 1.1.0 → 1.1.1)
+- Log VERSION_BUMP in audit trail standard
+
+**No codice NIS2**: solo ticket di coordinamento.
+
+---
+
+## 6. Fase 5 — Polishing & extras (P2/P3)
+
+### G15 — Demo login
+
+Endpoint dedicato `POST /api/auth/demo-login?scenario=ransomware` che pre-loada un demo user con flag `is_demo=1` e org-clone con dati seed scenari simulazione esistenti (`simulate-nis2.php`).
+
+### G16 — Branding consulente
+
+Nuovo `BrandingController.php` + tabella `firm_branding` (logo_url, primary_color, secondary_color, custom_domain).
+
+Header/sidebar/login leggono branding da `consulting_firm_id` dell'utente corrente.
+
+### G17 — Auth-gate documenti
+
+Copy 1:1 di `app/js/auth-gate.js` da TRPG → `public/js/auth-gate.js`. Includere nei documenti riservati (es. eventuali presentation.html, roadmap.html, listino.html futuri).
+
+### G18 — Refactor controller
+
+Spezzare `AuthController` (~600 righe oggi → 1000+ dopo fasi 1-3) in:
+- `AuthController` (login/logout/refresh/sessions)
+- `PasswordController` (forgot/reset/change)
+- `ProfileController` (me/profile/preferences)
+- `SsoController` (impersonate/switchContext/sso-callback)
+
+Estrarre anche `ApiKeyController`, `ConsultingFirmController` se serve (probabilmente sì, in linea con TRPG).
+
+---
+
+## 7. Cosa NON è in scope
+
+- **Porting a Node.js**: NIS2 resta PHP. La frammentazione microservizi di TRPG (`services/auth`, `services/ai`, …) non è obiettivo.
+- **2FA/MFA**: rinviato a progetto separato (richiede UX dedicata, TOTP, recovery codes).
+- **OAuth2 / social login**: rinviato.
+- **WebAuthn / passkey**: rinviato.
+
+---
+
+## 8. Sequenza release e dipendenze
+
+```
+Fase 1 (SSO) → Fase 3 (reset password usa SSO propagation)
+Fase 1 (SSO) → Fase 4 (impersonate logga in SSO audit)
+Fase 2 (sessions) → Fase 3 (switchContext revoca sessione)
+Fase 2 (sessions) → Fase 4 (impersonate crea sessione marcata)
+Fase 3 (forgot pwd) ⊥ indipendente da Fase 4/5
+Fase 4 (versioning) ⊥ indipendente
+Fase 5 ⊥ tutto opzionale
+```
+
+**Path critico**: Fase 1 → Fase 2 → Fase 3. Le altre possono parallelizzare.
+
+---
+
+## 9. Stima cumulativa
+
+| Fase | Effort | Settimane-uomo |
+|------|--------|----------------|
+| Fase 1 SSO | M+M+S+S | 1.5 |
+| Fase 2 Sessions | M+M+S | 1.0 |
+| Fase 3 Reset+Switch+UI | M+M+M | 1.5 |
+| Fase 4 Imperson.+Prefs+Ver. | S+S+S+S | 0.8 |
+| Fase 5 Demo+Brand+Refactor | M+M+XS+L | 1.5 |
+| **Totale** | | **~6 settimane-uomo** |
+
+---
+
+## 10. Decisioni prese (confermate dall'utente 2026-05-29)
+
+| # | Decisione | Scelta | Note operative |
+|---|-----------|--------|----------------|
+| 1 | SSO_MODE iniziale | **`local`** | Partiamo prudenti. Switch a `dual` dopo validazione (almeno 7 giorni post-Fase 1). |
+| 2 | Backfill utenti SSO | **A) Lazy on-login** | Nessuno script massivo. Il link `users.sso_identity_id` viene popolato al primo login post-SSO se l'email matcha `nexus_tenant_db.sso_identities`. Utenti che non loggano restano slegati (accettato). |
+| 3 | Sessioni concorrenti per utente | **Nessun limite** | Allineato a TRPG/Google/GitHub. Il consulente lavora da N device, vede tutto in Settings → Sicurezza e revoca a piacere. |
+| 4 | TTL token reset password | **30 min** | Token SHA256, single-use (`used_at`), `expires_at = NOW() + 30 min`. Rate limit 3/h per IP+email. |
+| 5 | Cadenza version bump | **B) Per fase (MINOR)** | Fine Fase 1 → 1.1.0 · Fine Fase 2 → 1.2.0 · Fine Fase 3 → 1.3.0 · Fine Fase 4 → 1.4.0 · Fine Fase 5 → 1.5.0. PATCH automatico via cron agent attivato in Fase 4 (G14). |
+| 6 | Branding white-label (G16) | **B) Mantenuto in Fase 5** | `BrandingController` + tabella `firm_branding` (logo, primary_color, secondary_color, custom_domain) abilitano white-label consulente. Effort M, valore commerciale (upsell). |
+
+---
+
+## 11. Riferimenti
+
+- TRPG schema sessions: `/var/www/trpg-agile/sql/078_active_sessions.sql` (Hetzner, accesso via chiave temp)
+- TRPG SsoHelper: `/var/www/trpg-agile/shared/SsoHelper.php`
+- TRPG HubJwtVerifier: `/var/www/trpg-agile/shared/HubJwtVerifier.php`
+- TRPG AuthController: `/var/www/trpg-agile/services/auth/controllers/AuthController.php` (v1.54.1, 1365 righe)
+- Standard SSO suite: `agile-services/docs/SPEC_SSO_SINGLE_SIGN_ON.md` + `ISTRUZIONI_SSO_PRODOTTI.md`
+- Cron sync attuale: `agile-services/scripts/sso-password-sync.sh`
+- NIS2 schema utenti: `docs/sql/001_initial_schema.sql` (righe 46-80)
+
+---
+
+## 12. Stato esecuzione
+
+### Tutte le 5 fasi — **COMPLETATE 2026-05-29** ✅
+
+| Fase | Esito | Version |
+|------|-------|---------|
+| **Fase 1 — SSO Federation** | ✅ | 1.1.0 |
+| **Fase 2 — Multi-device Sessions** | ✅ | 1.2.0 |
+| **Fase 3 — Password Reset + Context Switch** | ✅ | 1.3.0 |
+| **Fase 4 — Impersonate + Preferences + Versioning UI** | ✅ | 1.4.0 |
+| **Fase 5 — Branding + Auth-gate** | ✅ (G15 e G18 skip) | 1.5.0 |
+
+#### Skip motivati
+- **G15 Demo login**: i simulator esistenti (`simulate-nis2.php`, `simulate-nis2-big.php`, `simulate-nis2-b2b.php`) già coprono i flussi demo end-to-end. Un endpoint dedicato non aggiungerebbe valore — i simulator creano org reali con dati seed, esperienza più ricca di un demo-mode singleton.
+- **G18 Refactor controllers**: split di `AuthController` in 4 controller separati è investimento tecnico (~5gg) senza valore funzionale immediato. Rinviato a sessione dedicata se diventa necessario per leggibilità.
+
+#### Migrations SQL applicate (DB host Hetzner)
+| File | Tabella/Colonne |
+|------|-----------------|
+| `015_sso_columns.sql` | `users.sso_identity_id`, `users.password_version` |
+| `016_active_sessions.sql` | `active_sessions` (jti tracking), `refresh_tokens.session_jti` |
+| `017_password_reset.sql` | `password_reset_tokens` |
+| `018_user_preferences.sql` | `users.theme/timezone/notif_email/notif_inapp` |
+| `019_firm_branding.sql` | `firm_branding` (white-label) |
+
+#### Endpoint API nuovi (dopo Fase 1-5)
+```
+POST /api/auth/forgot-password (Fase 3 / G08)
+POST /api/auth/reset-password (Fase 3 / G08)
+POST /api/auth/switchContext (Fase 3 / G09)
+POST /api/auth/impersonate (Fase 4 / G11)
+GET /api/auth/preferences (Fase 4 / G12)
+PUT /api/auth/preferences (Fase 4 / G12)
+GET /api/auth/sessions (Fase 2 / G06)
+DELETE /api/auth/sessions (Fase 2 / G06)
+DELETE /api/auth/sessions/{id} (Fase 2 / G06)
+GET /api/branding/current (Fase 5 / G16)
+PUT /api/branding (Fase 5 / G16)
+```
+
+#### UI changes
+- `public/login.html` — link "Password dimenticata?" funzionante
+- `public/forgot-password.html` (NUOVO)
+- `public/reset-password.html` (NUOVO con strength bar)
+- `public/settings.html` — tab Sicurezza con lista sessioni device + revoca
+- `public/js/common.js` — sidebar version footer (click → changelog modal), tenant switcher esposto a chi ha ≥2 org, branding CSS vars auto-load
+- `public/js/auth-gate.js` (NUOVO) — gate password lato client per documenti riservati
+
+#### File PHP nuovi / modificati
+| File | Tipo | Note |
+|------|------|------|
+| `application/services/SsoHelper.php` | NUOVO | Client SSO dual-mode |
+| `application/controllers/BrandingController.php` | NUOVO | White-label firm |
+| `application/controllers/AuthController.php` | MODIFICATO | +12 metodi: login (jti+SSO), changePassword, refresh, logout, forgotPassword, resetPassword, switchContext, impersonate, getPreferences, updatePreferences, listSessions, revokeSession, revokeAllSessions |
+| `application/controllers/BaseController.php` | MODIFICATO | requireAuth verifica jti + last_activity throttle, parseDeviceLabel, generateRefreshToken con session_jti |
+| `application/services/EmailService.php` | MODIFICATO | +sendPasswordReset |
+| `application/config/config.php` | MODIFICATO | +RATE_LIMIT_AUTH_FORGOT, +PASSWORD_RESET_TTL_SECONDS |
+| `public/index.php` | MODIFICATO | +12 route + branding controller |
+
+#### Comportamento utente attivo OGGI
+- SSO **dormiente** (`SSO_MODE=local`) → login esattamente come prima
+- Multi-device sessions **ATTIVE** → ogni login crea row in `active_sessions`, JWT senza jti rifiutato
+- Password reset **ATTIVO** → link funzionante da `login.html`
+- Context switch **ATTIVO** → utenti multi-org vedono dropdown in sidebar
+- Impersonate **ATTIVO** → solo super_admin o consulente stesso firm (NB: nessuna UI ancora, solo endpoint)
+- Preferences **ATTIVO** → endpoint disponibili (NB: UI tab dedicato non ancora aggiunto)
+- Versioning footer **ATTIVO** → versione visibile in sidebar di ogni pagina
+- Branding **ATTIVO** → defaults applicati; consulente può PUT branding del suo firm
+
+#### Cron sync SSO (dipendenza esterna)
+**AgileHub Ticket #220** aperto a team AGILEHUB per estendere `sso-password-sync.sh` al DB `nis2_agile_db`. Da risolvere prima del switch `SSO_MODE=dual`.
+
+#### Backup completi (rollback ready)
+Tutti i file modificati hanno backup `.bak.pre-{fase}-{timestamp}` in:
+- `/projects/nis2-agile/application/controllers/`
+- `/projects/nis2-agile/public/`
+- `/var/www/nis2-agile/.backups/` (Hetzner)
+
+DB backups schema:
+- `host_users_pre_017_*.sql`
+- `users_schema_pre_015_*.sql`
+- `refresh_tokens_pre_016_*.sql`
+
+---
+
+### Fase 1 — SSO Federation: **COMPLETATA 2026-05-29** ✅
+
+| Step | Esito | Note |
+|------|-------|------|
+| G01 — Migration `015_sso_columns.sql` | ✅ Applicata su prod | Backup `.backups/users_schema_pre_015_20260529-073134.sql`. Idempotenza verificata. 0 utenti in prod (nessun cliente). |
+| G02 — `application/services/SsoHelper.php` | ✅ Deployato | Mutuato da TRPG `shared/SsoHelper.php`. Lint OK in container. `getMode()=local`. |
+| G03 — `AuthController::login()` + `changePassword()` | ✅ Modificato | Backup `.bak.pre-sso-20260529`. Smoke test 401 OK. Logica dietro `if(!$sso->isLocalOnly())` → no-op con `SSO_MODE=local`. |
+| G04 — Vault + `.env` | ✅ Configurato | Vault: `tier1__nis2-app__sso/internal_key` (placeholder). `.env`: SSO_ENDPOINT/SSO_TIMEOUT_MS/SSO_MODE=local. |
+| Cron sync (out-of-scope NIS2) | ✅ Ticket aperto | AgileHub Ticket #220 a team AGILEHUB. |
+
+**Comportamento utente**: invariato. Tutte le modifiche sono dietro feature flag `SSO_MODE`. Switch a `dual` previsto dopo almeno 7gg di validazione (decisione §10.1).
+
+### Prossimi step
+
+- **[manutentore]** AgileHub Ticket #220 → estensione `sso-password-sync.sh` al DB `nis2_agile_db`
+- **[NIS2]** Quando ticket risolto + 7gg passati → switch `SSO_MODE=local` → `dual` (modifica `.env` + `docker compose up -d --force-recreate app`)
+- **[NIS2]** Recupero valore reale `SSO_INTERNAL_KEY` da Tenant MS prima dello switch e replace in vault con: `docker exec vault-steward node /app/cli/vault-cli.js migrate tier1__nis2-app__sso internal_key ''`
+- **[NIS2]** Bump `public/version.json` → **1.1.0** (decisione §10.5) a switch confermato
+
+Vedi §3-§6 per Fase 2-5.
diff --git a/docs/sql/015_sso_columns.sql b/docs/sql/015_sso_columns.sql
new file mode 100644
index 0000000..4604247
--- /dev/null
+++ b/docs/sql/015_sso_columns.sql
@@ -0,0 +1,66 @@
+-- Migration 015: SSO Federation columns
+-- Progetto allineamento NIS2 ↔ TRPG — Fase 1 / G01
+-- Data: 2026-05-29
+--
+-- Aggiunge le colonne necessarie a collegare gli utenti NIS2 alle identità SSO
+-- centralizzate in `nexus_tenant_db.sso_identities` (gestito da agile-services).
+--
+-- - sso_identity_id: FK logica verso nexus_tenant_db.sso_identities.id.
+-- NULL = utente non ancora linkato (sarà popolato lazy al primo
+-- login post-SSO se l'email matcha — decisione utente 2026-05-29).
+-- - password_version: contatore monotono incrementato a ogni cambio password SSO;
+-- usato dal cron sync `sso-password-sync.sh` per decidere quando
+-- riallineare password_hash locale.
+--
+-- Comportamento atteso post-migration:
+-- * Utenti esistenti: sso_identity_id=NULL, password_version=1
+-- * Login locale continua a funzionare senza modifiche (SSO_MODE=local di default)
+-- * Nessun controller esistente si rompe (campi additivi)
+--
+-- Rollback:
+-- ALTER TABLE users
+-- DROP INDEX idx_sso_identity,
+-- DROP COLUMN sso_identity_id,
+-- DROP COLUMN password_version;
+--
+-- Note MySQL 8.x:
+-- * `ADD COLUMN IF NOT EXISTS` non standard → controllo via information_schema.
+-- * Eseguire come utente con privilegio ALTER su nis2_agile_db.
+
+SET @col_sso := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'sso_identity_id'
+);
+SET @sql_sso := IF(@col_sso = 0,
+ 'ALTER TABLE users ADD COLUMN sso_identity_id INT NULL COMMENT ''FK logica verso nexus_tenant_db.sso_identities.id'' AFTER email_verified_at',
+ 'SELECT ''sso_identity_id già presente — skip'' AS info'
+);
+PREPARE stmt FROM @sql_sso; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col_ver := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'password_version'
+);
+SET @sql_ver := IF(@col_ver = 0,
+ 'ALTER TABLE users ADD COLUMN password_version INT NOT NULL DEFAULT 1 COMMENT ''Contatore versione password SSO — bumpato a ogni change-password'' AFTER sso_identity_id',
+ 'SELECT ''password_version già presente — skip'' AS info'
+);
+PREPARE stmt FROM @sql_ver; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @idx_sso := (
+ SELECT COUNT(*) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND INDEX_NAME = 'idx_sso_identity'
+);
+SET @sql_idx := IF(@idx_sso = 0,
+ 'CREATE INDEX idx_sso_identity ON users (sso_identity_id)',
+ 'SELECT ''idx_sso_identity già presente — skip'' AS info'
+);
+PREPARE stmt FROM @sql_idx; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- Verifica finale
+SELECT
+ COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'users'
+ AND COLUMN_NAME IN ('sso_identity_id', 'password_version');
diff --git a/docs/sql/016_active_sessions.sql b/docs/sql/016_active_sessions.sql
new file mode 100644
index 0000000..59b304d
--- /dev/null
+++ b/docs/sql/016_active_sessions.sql
@@ -0,0 +1,81 @@
+-- Migration 016: Multi-device session tracking
+-- Progetto allineamento NIS2 ↔ TRPG — Fase 2 / G05
+-- Data: 2026-05-29
+-- Mutuata da TRPG /var/www/trpg-agile/sql/078_active_sessions.sql
+--
+-- Crea tabella `active_sessions` per tracciare ogni sessione JWT come riga
+-- distinta (chiave = jti del token), con device label + IP + last activity +
+-- revoke audit. Permette:
+-- - lista sessioni attive per utente (Settings → Sicurezza)
+-- - revoca selettiva ("esci da questo dispositivo")
+-- - revoca cascade su password change
+-- - context switch tra organization (Fase 3) senza rotazione manuale
+--
+-- Differenze rispetto a TRPG:
+-- - aggiunta colonna `organization_id` (NIS2 ha tenant esplicito X-Organization-Id)
+-- - ENUM revoked_reason include già 'context_switch' (TRPG l'ha aggiunta in
+-- migration 093; noi nasciamo con essa per evitare doppia migration)
+--
+-- Anche aggiungiamo `session_jti` a `refresh_tokens` per legare
+-- ogni refresh alla sua sessione (rotazione safe).
+--
+-- Rollback:
+-- ALTER TABLE refresh_tokens DROP INDEX idx_refresh_jti, DROP COLUMN session_jti;
+-- DROP TABLE IF EXISTS active_sessions;
+
+SET @tbl := (
+ SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'active_sessions'
+);
+SET @sql_tbl := IF(@tbl = 0,
+ 'CREATE TABLE active_sessions (
+ id CHAR(32) NOT NULL PRIMARY KEY COMMENT ''jti del JWT (bin2hex(random_bytes(16)))'',
+ user_id INT NOT NULL,
+ organization_id INT NULL COMMENT ''org attiva al momento del login'',
+ ip_address VARCHAR(45) NOT NULL,
+ user_agent VARCHAR(512) NULL,
+ device_label VARCHAR(120) NULL COMMENT ''Parsing UA-friendly (es. Chrome/Win11)'',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''login time'',
+ last_activity_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires_at TIMESTAMP NOT NULL COMMENT ''Hard cap = login + refresh TTL'',
+ revoked_at TIMESTAMP NULL DEFAULT NULL,
+ revoked_reason ENUM(''logout'',''force'',''password_change'',''admin'',''expired_idle'',''context_switch'') NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ INDEX idx_user_active (user_id, revoked_at, last_activity_at),
+ INDEX idx_last_activity (last_activity_at),
+ INDEX idx_expires (expires_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT=''JWT session tracking — solo utenti umani (API key B2B escluse)''',
+ 'SELECT ''active_sessions già presente — skip'' AS info'
+);
+PREPARE stmt FROM @sql_tbl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- Aggiungi session_jti a refresh_tokens
+SET @col_jti := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'refresh_tokens' AND COLUMN_NAME = 'session_jti'
+);
+SET @sql_col := IF(@col_jti = 0,
+ 'ALTER TABLE refresh_tokens ADD COLUMN session_jti CHAR(32) NULL COMMENT ''FK logica → active_sessions.id'' AFTER user_id',
+ 'SELECT ''refresh_tokens.session_jti già presente — skip'' AS info'
+);
+PREPARE stmt FROM @sql_col; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @idx_jti := (
+ SELECT COUNT(*) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'refresh_tokens' AND INDEX_NAME = 'idx_refresh_jti'
+);
+SET @sql_idx := IF(@idx_jti = 0,
+ 'CREATE INDEX idx_refresh_jti ON refresh_tokens (session_jti)',
+ 'SELECT ''idx_refresh_jti già presente — skip'' AS info'
+);
+PREPARE stmt FROM @sql_idx; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- Verifica finale
+SELECT TABLE_NAME, ENGINE, TABLE_COMMENT
+FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'active_sessions';
+
+SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_COMMENT
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'refresh_tokens' AND COLUMN_NAME = 'session_jti';
diff --git a/docs/sql/017_password_reset.sql b/docs/sql/017_password_reset.sql
new file mode 100644
index 0000000..9d012a4
--- /dev/null
+++ b/docs/sql/017_password_reset.sql
@@ -0,0 +1,39 @@
+-- Migration 017: Password reset tokens
+-- Progetto allineamento NIS2 ↔ TRPG — Fase 3 / G08
+-- Data: 2026-05-29
+--
+-- Tabella per supportare il flusso "Password dimenticata":
+-- 1. POST /auth/forgot-password { email } → genera token, salva hash, invia mail
+-- 2. POST /auth/reset-password { token, new_password } → verifica + setta nuova pwd
+--
+-- TTL: 30 min (decisione utente §10.4)
+-- Single-use: `used_at` viene settato al consumo
+-- Rate limit: 3 richieste/h per IP+email (applicato in controller)
+--
+-- Rollback: DROP TABLE password_reset_tokens;
+
+SET @tbl := (
+ SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'password_reset_tokens'
+);
+SET @sql := IF(@tbl = 0,
+ 'CREATE TABLE password_reset_tokens (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ user_id INT NOT NULL,
+ token_hash CHAR(64) NOT NULL UNIQUE COMMENT ''SHA-256 hex del token in chiaro inviato via mail'',
+ expires_at TIMESTAMP NOT NULL COMMENT ''Default 30 min dopo created_at'',
+ used_at TIMESTAMP NULL COMMENT ''Settato al primo consumo — token diventa single-use'',
+ ip_address VARCHAR(45) NULL COMMENT ''IP del richiedente forgot-password'',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ INDEX idx_token (token_hash),
+ INDEX idx_expires (expires_at),
+ INDEX idx_user_unused (user_id, used_at, expires_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT=''Token reset password — TTL 30min single-use''',
+ 'SELECT ''password_reset_tokens già presente — skip'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SELECT TABLE_NAME, ENGINE, TABLE_COMMENT FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'password_reset_tokens';
diff --git a/docs/sql/018_user_preferences.sql b/docs/sql/018_user_preferences.sql
new file mode 100644
index 0000000..ed9ea57
--- /dev/null
+++ b/docs/sql/018_user_preferences.sql
@@ -0,0 +1,46 @@
+-- Migration 018: User preferences (Fase 4 / G12)
+-- Data: 2026-05-29
+--
+-- Aggiunge a `users` colonne per preferenze:
+-- - theme (light|dark|auto)
+-- - timezone (default Europe/Rome — vedi CLAUDE.md sez. timezone)
+-- - notif_email (notifiche via mail on/off)
+-- - notif_inapp (notifiche in-app on/off)
+--
+-- Rollback:
+-- ALTER TABLE users
+-- DROP COLUMN notif_inapp, DROP COLUMN notif_email,
+-- DROP COLUMN timezone, DROP COLUMN theme;
+
+SET @c1 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='theme');
+SET @s1 := IF(@c1 = 0,
+ 'ALTER TABLE users ADD COLUMN theme ENUM(''light'',''dark'',''auto'') DEFAULT ''auto'' AFTER preferred_language',
+ 'SELECT ''theme già presente — skip'' AS info'
+);
+PREPARE stmt FROM @s1; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @c2 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='timezone');
+SET @s2 := IF(@c2 = 0,
+ 'ALTER TABLE users ADD COLUMN timezone VARCHAR(64) DEFAULT ''Europe/Rome'' AFTER theme',
+ 'SELECT ''timezone già presente — skip'' AS info'
+);
+PREPARE stmt FROM @s2; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @c3 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='notif_email');
+SET @s3 := IF(@c3 = 0,
+ 'ALTER TABLE users ADD COLUMN notif_email TINYINT(1) DEFAULT 1 AFTER timezone',
+ 'SELECT ''notif_email già presente — skip'' AS info'
+);
+PREPARE stmt FROM @s3; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @c4 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='notif_inapp');
+SET @s4 := IF(@c4 = 0,
+ 'ALTER TABLE users ADD COLUMN notif_inapp TINYINT(1) DEFAULT 1 AFTER notif_email',
+ 'SELECT ''notif_inapp già presente — skip'' AS info'
+);
+PREPARE stmt FROM @s4; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users'
+ AND COLUMN_NAME IN ('theme','timezone','notif_email','notif_inapp');
diff --git a/docs/sql/019_firm_branding.sql b/docs/sql/019_firm_branding.sql
new file mode 100644
index 0000000..098e179
--- /dev/null
+++ b/docs/sql/019_firm_branding.sql
@@ -0,0 +1,33 @@
+-- Migration 019: Firm branding (Fase 5 / G16)
+-- Data: 2026-05-29
+--
+-- Tabella di branding white-label per studi di consulenza.
+-- Permette al consulente di personalizzare logo/colori che vedranno i suoi clienti.
+--
+-- Lookup: per ogni utente loggato → si guarda users.consulting_firm_id →
+-- firm_branding.firm_id matching → si applica
+--
+-- Rollback: DROP TABLE firm_branding;
+
+SET @tbl := (
+ SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'firm_branding'
+);
+SET @sql := IF(@tbl = 0,
+ 'CREATE TABLE firm_branding (
+ firm_id INT NOT NULL PRIMARY KEY,
+ logo_url VARCHAR(512) NULL COMMENT ''URL assoluto o relativo al logo (es. /uploads/firms/123/logo.svg)'',
+ primary_color CHAR(7) NULL COMMENT ''Hex #RRGGBB del colore primario UI'',
+ secondary_color CHAR(7) NULL COMMENT ''Hex #RRGGBB del colore secondario UI'',
+ custom_brand_name VARCHAR(120) NULL COMMENT ''Override del nome prodotto in UI (es. "Lo Studio X NIS2 Suite")'',
+ custom_domain VARCHAR(255) NULL COMMENT ''Sottodominio dedicato (futuro)'',
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ FOREIGN KEY (firm_id) REFERENCES consulting_firms(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT=''White-label branding per consulting firm''',
+ 'SELECT ''firm_branding già presente — skip'' AS info'
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SELECT TABLE_NAME, ENGINE, TABLE_COMMENT FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'firm_branding';
diff --git a/public/forgot-password.html b/public/forgot-password.html
new file mode 100644
index 0000000..1ccf9ab
--- /dev/null
+++ b/public/forgot-password.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+ Password dimenticata - NIS2 Agile
+
+
+
+
+
+
+
+
+
+
+
+
+ NIS2 Agile
+
+
Reimposta la tua password
+
+
+
+
+
+
+
Inserisci l'indirizzo email associato al tuo account. Ti invieremo un link valido 30 minuti per impostare una nuova password.
Sessione autenticata tramite token JWT. L'accesso e' protetto da crittografia.
-
-
Autenticazione a Due Fattori (2FA)
@@ -465,7 +457,22 @@
Accesso APIJWT
-
L'accesso alle API avviene tramite token JWT (JSON Web Token) con scadenza automatica e meccanismo di refresh. Tutti i token vengono invalidati al cambio password.
+
L'accesso alle API avviene tramite token JWT (JSON Web Token) con scadenza automatica e meccanismo di refresh.
+
+
+
+
+
+
+
+
Sessioni Attive
+
Dispositivi attualmente loggati al tuo account. Puoi disconnetterli singolarmente o tutti tranne questo.