nis2-agile/docs/sql/016_active_sessions.sql
DevEnv nis2-agile e4f9e9179e [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>
2026-05-29 13:18:35 +02:00

82 lines
3.9 KiB
SQL

-- 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';