[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
co-authored by Claude Opus 4.7
parent c37423f900
commit e4f9e9179e
21 changed files with 2636 additions and 152 deletions
+102 -10
View File
@@ -444,14 +444,6 @@
<h3>Sicurezza Account</h3>
</div>
<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-header">
<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="badge badge-info">JWT</span>
</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>
@@ -576,11 +583,96 @@
document.getElementById(panelMap[tab]).classList.add('active');
if (tab === 'members') loadMembers();
if (tab === 'security') loadAuditLog();
if (tab === 'security') { loadSessions(); loadAuditLog(); }
if (tab === 'apikeys') loadApiKeys();
if (tab === 'webhooks') { loadWebhooks(); loadDeliveries(); }
}
// ── Sessioni Multi-Device (Fase 2 / G07) ─────────────────
async function loadSessions() {
const container = document.getElementById('sessions-container');
container.innerHTML = '<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 ─────────────────────────────────────────
const sectorLabels = {
energy: 'Energia', transport: 'Trasporti', banking: 'Banche',