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