From 1f534db33a010debe2f482ebc780d83dfc269972 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 7 Mar 2026 14:46:27 +0100 Subject: [PATCH] [INTEG] Token exchange + SSO federato + Audit trail chiamate esterne MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServicesController: - POST /api/services/token: lg231 invia API key → riceve JWT 15min - POST /api/services/sso: SSO federato con identità utente + responsabilità → crea/trova utente NIS2 + emette JWT 2h con ruolo e responsibilities - Audit trail: ogni chiamata esterna autenticata loggata (api.external_call) - SSO login loggato come auth.sso_login severity=warning con responsabilità Co-Authored-By: Claude Sonnet 4.6 --- .../controllers/ServicesController.php | 260 +++++++++++++++++- public/index.php | 2 + 2 files changed, 260 insertions(+), 2 deletions(-) diff --git a/application/controllers/ServicesController.php b/application/controllers/ServicesController.php index 7efed30..6f5cf8a 100644 --- a/application/controllers/ServicesController.php +++ b/application/controllers/ServicesController.php @@ -83,9 +83,10 @@ class ServicesController extends BaseController $this->jsonError('API Key non valida o scaduta', 401, 'INVALID_API_KEY'); } - // Verifica scope + // Verifica scope — read:all è master scope che include tutto $scopes = json_decode($record['scopes'], true) ?? []; - if (!in_array($scope, $scopes) && !in_array('read:all', $scopes)) { + $hasAll = in_array('read:all', $scopes); + if (!$hasAll && !in_array($scope, $scopes)) { $this->jsonError("Scope '{$scope}' non autorizzato per questa chiave", 403, 'SCOPE_DENIED'); } @@ -104,6 +105,42 @@ class ServicesController extends BaseController $this->apiKeyRecord = $record; $this->currentOrgId = (int) $record['organization_id']; + + // ── Audit trail: ogni chiamata esterna autenticata viene registrata ── + $this->logExternalCall($scope); + } + + /** + * Registra la chiamata esterna nell'audit trail con AuditService. + * action: api.external_call — severity: info (warning se scope sensibile) + */ + private function logExternalCall(string $scope): void + { + $rec = $this->apiKeyRecord; + $endpoint = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH); + $caller = $_SERVER['HTTP_X_CALLER'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? 'unknown'); + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + + AuditService::log( + orgId: $this->currentOrgId, + userId: null, + action: 'api.external_call', + entityType: 'api_key', + entityId: (int) $rec['id'], + details: [ + 'endpoint' => $endpoint, + 'method' => $method, + 'scope' => $scope, + 'caller' => $caller, + 'key_name' => $rec['name'] ?? 'n/a', + 'key_prefix' => $rec['key_prefix'] ?? '', + 'org_name' => $rec['org_name'] ?? '', + ], + ipAddress: $_SERVER['REMOTE_ADDR'] ?? '', + userAgent: $caller, + severity: in_array($scope, ['read:incidents', 'read:all'], true) ? 'warning' : 'info', + performedBy: $rec['name'] ?? 'api_key' + ); } /** @@ -154,6 +191,225 @@ class ServicesController extends BaseController // ENDPOINT // ══════════════════════════════════════════════════════════════════════ + /** + * POST /api/services/token + * + * Token exchange: lg231 (e altri sistemi) inviano la loro API Key e + * ricevono un JWT temporaneo (TTL 15 min) per le chiamate successive. + * Pattern identico al CertiSource PAT → session. + * + * Request: X-API-Key: nis2_xxx (o body JSON {"api_key":"nis2_xxx"}) + * Response: {"token":"eyJ...", "expires_in":900, "org_id":5, "scopes":[...]} + */ + public function token(): void + { + $this->requireApiKey('read:all'); + $this->setServiceHeaders(); + + $rec = $this->apiKeyRecord; + $orgId = $this->currentOrgId; + $scopes = json_decode($rec['scopes'], true) ?? ['read:all']; + $ttl = 900; // 15 minuti + + // Carica JWT config dal config applicativo + require_once APP_PATH . '/config/config.php'; + $secret = defined('JWT_SECRET') ? JWT_SECRET : ($_ENV['JWT_SECRET'] ?? 'changeme'); + + $issuedAt = time(); + $payload = [ + 'iss' => 'nis2.agile.software', + 'sub' => 'api_key:' . $rec['id'], + 'org_id' => $orgId, + 'key_id' => (int) $rec['id'], + 'scopes' => $scopes, + 'caller' => $_SERVER['HTTP_X_CALLER'] ?? 'external', + 'iat' => $issuedAt, + 'exp' => $issuedAt + $ttl, + 'type' => 'service_token', + ]; + + // JWT manuale HS256 (stesso formato di BaseController) + $header = base64url_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); + $body = base64url_encode(json_encode($payload)); + $signature = base64url_encode(hash_hmac('sha256', "$header.$body", $secret, true)); + $jwt = "$header.$body.$signature"; + + // Audit + AuditService::log( + orgId: $orgId, + userId: null, + action: 'api.token_issued', + entityType: 'api_key', + entityId: (int) $rec['id'], + details: [ + 'key_name' => $rec['name'] ?? 'n/a', + 'caller' => $_SERVER['HTTP_X_CALLER'] ?? 'external', + 'ttl' => $ttl, + 'scopes' => $scopes, + ], + ipAddress: $_SERVER['REMOTE_ADDR'] ?? '', + severity: 'info', + performedBy: $rec['name'] ?? 'api_key' + ); + + $this->jsonSuccess([ + 'token' => $jwt, + 'token_type' => 'Bearer', + 'expires_in' => $ttl, + 'expires_at' => date('c', $issuedAt + $ttl), + 'org_id' => $orgId, + 'org_name' => $rec['org_name'] ?? '', + 'scopes' => $scopes, + ]); + } + + /** + * POST /api/services/sso + * + * Single Sign-On federato: lg231 (o altro sistema Agile) invia + * l'identità del suo utente e NIS2 emette un JWT di sessione valido + * nell'app NIS2 — senza che l'utente debba fare login separato. + * + * Request (X-API-Key + JSON body): + * { + * "user_email": "tizio@azienda.it", + * "user_name": "Mario Rossi", // opzionale + * "user_role": "compliance_manager", // ruolo NIS2 desiderato + * "caller_system": "lg231", // sistema chiamante + * "caller_user_id": 42, // ID utente nel sistema chiamante + * "responsibilities": [ // facoltativo + * {"area": "MOG 231", "scope": "art.24-bis"}, + * {"area": "OdV", "scope": "monitoraggio"} + * ] + * } + * + * Response: + * { + * "token": "eyJ...", // JWT NIS2 valido 2h (usa nell'Authorization header) + * "user_id": 12, // ID utente NIS2 (creato se non esiste) + * "org_id": 5, + * "role": "compliance_manager", + * "redirect_url": "https://nis2.agile.software/dashboard.html" + * } + * + * Audit: logga l'accesso SSO con identità completa e responsabilità. + */ + public function sso(): void + { + $this->requireApiKey('sso:login'); + $this->setServiceHeaders(); + + $body = json_decode(file_get_contents('php://input'), true) ?? []; + $email = trim($body['user_email'] ?? ''); + $name = trim($body['user_name'] ?? ''); + $role = $body['user_role'] ?? 'auditor'; + $caller = $body['caller_system'] ?? ($_SERVER['HTTP_X_CALLER'] ?? 'external'); + $callerUserId = $body['caller_user_id'] ?? null; + $responsibilities = $body['responsibilities'] ?? []; + + if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->jsonError('user_email mancante o non valido', 400, 'INVALID_EMAIL'); + } + + // Ruoli NIS2 consentiti via SSO (non super_admin) + $allowedRoles = ['compliance_manager', 'auditor', 'board_member', 'employee', 'consultant']; + if (!in_array($role, $allowedRoles, true)) { + $role = 'auditor'; + } + + $db = Database::getInstance(); + $orgId = $this->currentOrgId; + + // Trova o crea l'utente NIS2 per questa email + $user = Database::fetchOne('SELECT * FROM users WHERE email = ? LIMIT 1', [$email]); + + if (!$user) { + // Crea utente SSO (senza password — accede solo via token) + $parts = explode(' ', $name, 2); + $firstName = $parts[0] ?? $email; + $lastName = $parts[1] ?? ''; + + Database::execute( + 'INSERT INTO users (email, password_hash, first_name, last_name, role, status) + VALUES (?, ?, ?, ?, ?, "active")', + [$email, '', $firstName, $lastName, $role] + ); + $userId = (int) Database::lastInsertId(); + } else { + $userId = (int) $user['id']; + } + + // Assicura membership org + $membership = Database::fetchOne( + 'SELECT id FROM user_organizations WHERE user_id = ? AND organization_id = ? LIMIT 1', + [$userId, $orgId] + ); + if (!$membership) { + Database::execute( + 'INSERT INTO user_organizations (user_id, organization_id, role) VALUES (?, ?, ?)', + [$userId, $orgId, $role] + ); + } + + // Emetti JWT NIS2 (TTL 2h, stesso formato BaseController) + require_once APP_PATH . '/config/config.php'; + $secret = defined('JWT_SECRET') ? JWT_SECRET : ($_ENV['JWT_SECRET'] ?? 'changeme'); + $issuedAt = time(); + $ttl = 7200; // 2 ore + + $payload = [ + 'iss' => 'nis2.agile.software', + 'sub' => $userId, + 'org_id' => $orgId, + 'role' => $role, + 'sso' => true, + 'sso_caller' => $caller, + 'sso_caller_uid' => $callerUserId, + 'responsibilities'=> $responsibilities, + 'iat' => $issuedAt, + 'exp' => $issuedAt + $ttl, + 'type' => 'access', + ]; + + $header = base64url_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); + $jwtBody = base64url_encode(json_encode($payload)); + $signature = base64url_encode(hash_hmac('sha256', "$header.$jwtBody", $secret, true)); + $jwt = "$header.$jwtBody.$signature"; + + // Audit trail — traccia SSO con responsabilità + AuditService::log( + orgId: $orgId, + userId: $userId, + action: 'auth.sso_login', + entityType: 'user', + entityId: $userId, + details: [ + 'caller_system' => $caller, + 'caller_user_id' => $callerUserId, + 'role_granted' => $role, + 'responsibilities' => $responsibilities, + 'email' => $email, + 'user_created' => !isset($user), + ], + ipAddress: $_SERVER['REMOTE_ADDR'] ?? '', + severity: 'warning', // SSO login è sempre warning per audit + performedBy: $caller . ':' . $email + ); + + $this->jsonSuccess([ + 'token' => $jwt, + 'token_type' => 'Bearer', + 'expires_in' => $ttl, + 'expires_at' => date('c', $issuedAt + $ttl), + 'user_id' => $userId, + 'user_email' => $email, + 'org_id' => $orgId, + 'role' => $role, + 'redirect_url' => 'https://nis2.agile.software/dashboard.html', + 'sso_caller' => $caller, + ]); + } + /** * GET /api/services/status * Health check + info piattaforma. Nessuna auth richiesta. diff --git a/public/index.php b/public/index.php index 167c1b2..d483d16 100644 --- a/public/index.php +++ b/public/index.php @@ -298,6 +298,8 @@ $actionMap = [ // ── ServicesController (API pubblica) ────────── 'services' => [ + 'POST:token' => 'token', + 'POST:sso' => 'sso', 'GET:status' => 'status', 'GET:complianceSummary' => 'complianceSummary', 'GET:risksFeed' => 'risksFeed',