[INTEG] Token exchange + SSO federato + Audit trail chiamate esterne
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 <noreply@anthropic.com>
This commit is contained in:
parent
5ecdce7d47
commit
1f534db33a
@ -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.
|
||||
|
||||
@ -298,6 +298,8 @@ $actionMap = [
|
||||
|
||||
// ── ServicesController (API pubblica) ──────────
|
||||
'services' => [
|
||||
'POST:token' => 'token',
|
||||
'POST:sso' => 'sso',
|
||||
'GET:status' => 'status',
|
||||
'GET:complianceSummary' => 'complianceSummary',
|
||||
'GET:risksFeed' => 'risksFeed',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user