[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
+66
View File
@@ -0,0 +1,66 @@
-- Migration 015: SSO Federation columns
-- Progetto allineamento NIS2 ↔ TRPG — Fase 1 / G01
-- Data: 2026-05-29
--
-- Aggiunge le colonne necessarie a collegare gli utenti NIS2 alle identità SSO
-- centralizzate in `nexus_tenant_db.sso_identities` (gestito da agile-services).
--
-- - sso_identity_id: FK logica verso nexus_tenant_db.sso_identities.id.
-- NULL = utente non ancora linkato (sarà popolato lazy al primo
-- login post-SSO se l'email matcha — decisione utente 2026-05-29).
-- - password_version: contatore monotono incrementato a ogni cambio password SSO;
-- usato dal cron sync `sso-password-sync.sh` per decidere quando
-- riallineare password_hash locale.
--
-- Comportamento atteso post-migration:
-- * Utenti esistenti: sso_identity_id=NULL, password_version=1
-- * Login locale continua a funzionare senza modifiche (SSO_MODE=local di default)
-- * Nessun controller esistente si rompe (campi additivi)
--
-- Rollback:
-- ALTER TABLE users
-- DROP INDEX idx_sso_identity,
-- DROP COLUMN sso_identity_id,
-- DROP COLUMN password_version;
--
-- Note MySQL 8.x:
-- * `ADD COLUMN IF NOT EXISTS` non standard → controllo via information_schema.
-- * Eseguire come utente con privilegio ALTER su nis2_agile_db.
SET @col_sso := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'sso_identity_id'
);
SET @sql_sso := IF(@col_sso = 0,
'ALTER TABLE users ADD COLUMN sso_identity_id INT NULL COMMENT ''FK logica verso nexus_tenant_db.sso_identities.id'' AFTER email_verified_at',
'SELECT ''sso_identity_id già presente — skip'' AS info'
);
PREPARE stmt FROM @sql_sso; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @col_ver := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'password_version'
);
SET @sql_ver := IF(@col_ver = 0,
'ALTER TABLE users ADD COLUMN password_version INT NOT NULL DEFAULT 1 COMMENT ''Contatore versione password SSO — bumpato a ogni change-password'' AFTER sso_identity_id',
'SELECT ''password_version già presente — skip'' AS info'
);
PREPARE stmt FROM @sql_ver; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_sso := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND INDEX_NAME = 'idx_sso_identity'
);
SET @sql_idx := IF(@idx_sso = 0,
'CREATE INDEX idx_sso_identity ON users (sso_identity_id)',
'SELECT ''idx_sso_identity già presente — skip'' AS info'
);
PREPARE stmt FROM @sql_idx; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Verifica finale
SELECT
COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME IN ('sso_identity_id', 'password_version');
+81
View File
@@ -0,0 +1,81 @@
-- Migration 016: Multi-device session tracking
-- Progetto allineamento NIS2 ↔ TRPG — Fase 2 / G05
-- Data: 2026-05-29
-- Mutuata da TRPG /var/www/trpg-agile/sql/078_active_sessions.sql
--
-- Crea tabella `active_sessions` per tracciare ogni sessione JWT come riga
-- distinta (chiave = jti del token), con device label + IP + last activity +
-- revoke audit. Permette:
-- - lista sessioni attive per utente (Settings → Sicurezza)
-- - revoca selettiva ("esci da questo dispositivo")
-- - revoca cascade su password change
-- - context switch tra organization (Fase 3) senza rotazione manuale
--
-- Differenze rispetto a TRPG:
-- - aggiunta colonna `organization_id` (NIS2 ha tenant esplicito X-Organization-Id)
-- - ENUM revoked_reason include già 'context_switch' (TRPG l'ha aggiunta in
-- migration 093; noi nasciamo con essa per evitare doppia migration)
--
-- Anche aggiungiamo `session_jti` a `refresh_tokens` per legare
-- ogni refresh alla sua sessione (rotazione safe).
--
-- Rollback:
-- ALTER TABLE refresh_tokens DROP INDEX idx_refresh_jti, DROP COLUMN session_jti;
-- DROP TABLE IF EXISTS active_sessions;
SET @tbl := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'active_sessions'
);
SET @sql_tbl := IF(@tbl = 0,
'CREATE TABLE active_sessions (
id CHAR(32) NOT NULL PRIMARY KEY COMMENT ''jti del JWT (bin2hex(random_bytes(16)))'',
user_id INT NOT NULL,
organization_id INT NULL COMMENT ''org attiva al momento del login'',
ip_address VARCHAR(45) NOT NULL,
user_agent VARCHAR(512) NULL,
device_label VARCHAR(120) NULL COMMENT ''Parsing UA-friendly (es. Chrome/Win11)'',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''login time'',
last_activity_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL COMMENT ''Hard cap = login + refresh TTL'',
revoked_at TIMESTAMP NULL DEFAULT NULL,
revoked_reason ENUM(''logout'',''force'',''password_change'',''admin'',''expired_idle'',''context_switch'') NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_active (user_id, revoked_at, last_activity_at),
INDEX idx_last_activity (last_activity_at),
INDEX idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT=''JWT session tracking — solo utenti umani (API key B2B escluse)''',
'SELECT ''active_sessions già presente — skip'' AS info'
);
PREPARE stmt FROM @sql_tbl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Aggiungi session_jti a refresh_tokens
SET @col_jti := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'refresh_tokens' AND COLUMN_NAME = 'session_jti'
);
SET @sql_col := IF(@col_jti = 0,
'ALTER TABLE refresh_tokens ADD COLUMN session_jti CHAR(32) NULL COMMENT ''FK logica → active_sessions.id'' AFTER user_id',
'SELECT ''refresh_tokens.session_jti già presente — skip'' AS info'
);
PREPARE stmt FROM @sql_col; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_jti := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'refresh_tokens' AND INDEX_NAME = 'idx_refresh_jti'
);
SET @sql_idx := IF(@idx_jti = 0,
'CREATE INDEX idx_refresh_jti ON refresh_tokens (session_jti)',
'SELECT ''idx_refresh_jti già presente — skip'' AS info'
);
PREPARE stmt FROM @sql_idx; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Verifica finale
SELECT TABLE_NAME, ENGINE, TABLE_COMMENT
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'active_sessions';
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'refresh_tokens' AND COLUMN_NAME = 'session_jti';
+39
View File
@@ -0,0 +1,39 @@
-- Migration 017: Password reset tokens
-- Progetto allineamento NIS2 ↔ TRPG — Fase 3 / G08
-- Data: 2026-05-29
--
-- Tabella per supportare il flusso "Password dimenticata":
-- 1. POST /auth/forgot-password { email } → genera token, salva hash, invia mail
-- 2. POST /auth/reset-password { token, new_password } → verifica + setta nuova pwd
--
-- TTL: 30 min (decisione utente §10.4)
-- Single-use: `used_at` viene settato al consumo
-- Rate limit: 3 richieste/h per IP+email (applicato in controller)
--
-- Rollback: DROP TABLE password_reset_tokens;
SET @tbl := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'password_reset_tokens'
);
SET @sql := IF(@tbl = 0,
'CREATE TABLE password_reset_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE COMMENT ''SHA-256 hex del token in chiaro inviato via mail'',
expires_at TIMESTAMP NOT NULL COMMENT ''Default 30 min dopo created_at'',
used_at TIMESTAMP NULL COMMENT ''Settato al primo consumo — token diventa single-use'',
ip_address VARCHAR(45) NULL COMMENT ''IP del richiedente forgot-password'',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token (token_hash),
INDEX idx_expires (expires_at),
INDEX idx_user_unused (user_id, used_at, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT=''Token reset password — TTL 30min single-use''',
'SELECT ''password_reset_tokens già presente — skip'' AS info'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT TABLE_NAME, ENGINE, TABLE_COMMENT FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'password_reset_tokens';
+46
View File
@@ -0,0 +1,46 @@
-- Migration 018: User preferences (Fase 4 / G12)
-- Data: 2026-05-29
--
-- Aggiunge a `users` colonne per preferenze:
-- - theme (light|dark|auto)
-- - timezone (default Europe/Rome — vedi CLAUDE.md sez. timezone)
-- - notif_email (notifiche via mail on/off)
-- - notif_inapp (notifiche in-app on/off)
--
-- Rollback:
-- ALTER TABLE users
-- DROP COLUMN notif_inapp, DROP COLUMN notif_email,
-- DROP COLUMN timezone, DROP COLUMN theme;
SET @c1 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='theme');
SET @s1 := IF(@c1 = 0,
'ALTER TABLE users ADD COLUMN theme ENUM(''light'',''dark'',''auto'') DEFAULT ''auto'' AFTER preferred_language',
'SELECT ''theme già presente — skip'' AS info'
);
PREPARE stmt FROM @s1; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c2 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='timezone');
SET @s2 := IF(@c2 = 0,
'ALTER TABLE users ADD COLUMN timezone VARCHAR(64) DEFAULT ''Europe/Rome'' AFTER theme',
'SELECT ''timezone già presente — skip'' AS info'
);
PREPARE stmt FROM @s2; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c3 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='notif_email');
SET @s3 := IF(@c3 = 0,
'ALTER TABLE users ADD COLUMN notif_email TINYINT(1) DEFAULT 1 AFTER timezone',
'SELECT ''notif_email già presente — skip'' AS info'
);
PREPARE stmt FROM @s3; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c4 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='notif_inapp');
SET @s4 := IF(@c4 = 0,
'ALTER TABLE users ADD COLUMN notif_inapp TINYINT(1) DEFAULT 1 AFTER notif_email',
'SELECT ''notif_inapp già presente — skip'' AS info'
);
PREPARE stmt FROM @s4; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users'
AND COLUMN_NAME IN ('theme','timezone','notif_email','notif_inapp');
+33
View File
@@ -0,0 +1,33 @@
-- Migration 019: Firm branding (Fase 5 / G16)
-- Data: 2026-05-29
--
-- Tabella di branding white-label per studi di consulenza.
-- Permette al consulente di personalizzare logo/colori che vedranno i suoi clienti.
--
-- Lookup: per ogni utente loggato → si guarda users.consulting_firm_id →
-- firm_branding.firm_id matching → si applica
--
-- Rollback: DROP TABLE firm_branding;
SET @tbl := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'firm_branding'
);
SET @sql := IF(@tbl = 0,
'CREATE TABLE firm_branding (
firm_id INT NOT NULL PRIMARY KEY,
logo_url VARCHAR(512) NULL COMMENT ''URL assoluto o relativo al logo (es. /uploads/firms/123/logo.svg)'',
primary_color CHAR(7) NULL COMMENT ''Hex #RRGGBB del colore primario UI'',
secondary_color CHAR(7) NULL COMMENT ''Hex #RRGGBB del colore secondario UI'',
custom_brand_name VARCHAR(120) NULL COMMENT ''Override del nome prodotto in UI (es. "Lo Studio X NIS2 Suite")'',
custom_domain VARCHAR(255) NULL COMMENT ''Sottodominio dedicato (futuro)'',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (firm_id) REFERENCES consulting_firms(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT=''White-label branding per consulting firm''',
'SELECT ''firm_branding già presente — skip'' AS info'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT TABLE_NAME, ENGINE, TABLE_COMMENT FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'firm_branding';