[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:
co-authored by
Claude Opus 4.7
parent
c37423f900
commit
e4f9e9179e
+102
-10
@@ -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) + ' · Ultimo accesso ' + lastActivity + ' · 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 { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
// ── Settori NIS2 ─────────────────────────────────────────
|
||||
const sectorLabels = {
|
||||
energy: 'Energia', transport: 'Trasporti', banking: 'Banche',
|
||||
|
||||
Reference in New Issue
Block a user