[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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user