Increment backend formazione-first (product-demo-protocol v1.0.1): - BaseController::applyDemoGuard() — SAFE-BY-CONSTRUCTION (flusso auth normale invariato): riconosce demo_jwt scope=demo:read-only (blocca scritture 403 DEMO_READ_ONLY) e training:sandbox (scritture solo su org sandbox); contesto sintetico user=0 ruolo compliance_manager su org range 996xxx; mai super_admin. Short-circuit in requireAuth/ requireOrgAccess/requireOrgRole. php -l OK. DA DEPLOYARE (USR2) + testare quando host disponibile. - scripts/seed-demo-dataset.php — clona golden DataCore #151 in 996001 (demo RO) + 996002 (sandbox scrivibile), idempotente, richiamabile da resetDataset. php -l OK. DA ESEGUIRE su host. NB: chiave ssh host revocata a meta-sessione → seed/USR2/push in attesa di ri-provisioning. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
729 lines
25 KiB
PHP
729 lines
25 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Base Controller
|
|
*
|
|
* Classe base per tutti i controller.
|
|
* Gestisce autenticazione, multi-tenancy, risposte JSON, validazione.
|
|
*/
|
|
|
|
require_once APP_PATH . '/config/database.php';
|
|
require_once APP_PATH . '/services/AuditService.php';
|
|
|
|
class BaseController
|
|
{
|
|
protected ?array $currentUser = null;
|
|
protected ?array $currentSession = null;
|
|
protected ?int $currentOrgId = null;
|
|
protected ?string $currentOrgRole = null;
|
|
|
|
// ── Contesto Avatar di prodotto (demo/sandbox) — vedi applyDemoGuard() ──
|
|
protected bool $isDemo = false; // sessione demo attiva (read-only o sandbox)
|
|
protected bool $isSandbox = false; // scope training:sandbox (scritture su org sandbox)
|
|
protected ?int $demoOrgId = null; // org del range riservato demo (996000-996999)
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// RISPOSTE JSON
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Invia risposta JSON di successo
|
|
*/
|
|
protected function jsonSuccess($data = null, string $message = 'OK', int $statusCode = 200): void
|
|
{
|
|
http_response_code($statusCode);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'message' => $message,
|
|
'data' => $data,
|
|
], JSON_UNESCAPED_UNICODE);
|
|
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Invia risposta JSON di errore
|
|
*/
|
|
protected function jsonError(string $message, int $statusCode = 400, ?string $errorCode = null, ?array $data = null): void
|
|
{
|
|
http_response_code($statusCode);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
|
|
$response = [
|
|
'success' => false,
|
|
'message' => $message,
|
|
];
|
|
|
|
if ($errorCode) {
|
|
$response['error_code'] = $errorCode;
|
|
}
|
|
|
|
if ($data) {
|
|
$response['data'] = $data;
|
|
}
|
|
|
|
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Invia risposta paginata
|
|
*/
|
|
protected function jsonPaginated(array $items, int $total, int $page, int $perPage): void
|
|
{
|
|
$this->jsonSuccess([
|
|
'items' => $items,
|
|
'total' => $total,
|
|
'page' => $page,
|
|
'per_page' => $perPage,
|
|
'pages' => ceil($total / $perPage),
|
|
]);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// PARAMETRI RICHIESTA
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Ottiene parametro dalla richiesta (GET, POST o JSON body)
|
|
*/
|
|
protected function getParam(string $key, $default = null)
|
|
{
|
|
if (isset($_REQUEST[$key])) {
|
|
return $_REQUEST[$key];
|
|
}
|
|
|
|
$jsonBody = $this->getJsonBody();
|
|
if (isset($jsonBody[$key])) {
|
|
return $jsonBody[$key];
|
|
}
|
|
|
|
return $default;
|
|
}
|
|
|
|
/**
|
|
* Verifica se un parametro esiste
|
|
*/
|
|
protected function hasParam(string $key): bool
|
|
{
|
|
if (isset($_REQUEST[$key])) {
|
|
return true;
|
|
}
|
|
|
|
$jsonBody = $this->getJsonBody();
|
|
return isset($jsonBody[$key]);
|
|
}
|
|
|
|
/**
|
|
* Ottiene tutti i parametri dalla richiesta
|
|
*/
|
|
protected function getAllParams(): array
|
|
{
|
|
$params = $_REQUEST;
|
|
$jsonBody = $this->getJsonBody();
|
|
return array_merge($params, $jsonBody);
|
|
}
|
|
|
|
/**
|
|
* Ottiene il body JSON della richiesta
|
|
*/
|
|
protected function getJsonBody(): array
|
|
{
|
|
static $jsonBody = null;
|
|
|
|
if ($jsonBody === null) {
|
|
$input = file_get_contents('php://input');
|
|
$jsonBody = json_decode($input, true) ?? [];
|
|
}
|
|
|
|
return $jsonBody;
|
|
}
|
|
|
|
/**
|
|
* Ottiene parametri di paginazione
|
|
*/
|
|
protected function getPagination(int $defaultPerPage = 20): array
|
|
{
|
|
$page = max(1, (int) $this->getParam('page', 1));
|
|
$perPage = min(100, max(1, (int) $this->getParam('per_page', $defaultPerPage)));
|
|
$offset = ($page - 1) * $perPage;
|
|
|
|
return ['page' => $page, 'per_page' => $perPage, 'offset' => $offset];
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// VALIDAZIONE
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Valida parametri obbligatori
|
|
*/
|
|
protected function validateRequired(array $required): void
|
|
{
|
|
$missing = [];
|
|
|
|
foreach ($required as $field) {
|
|
$value = $this->getParam($field);
|
|
if ($value === null || $value === '') {
|
|
$missing[] = $field;
|
|
}
|
|
}
|
|
|
|
if (!empty($missing)) {
|
|
$this->jsonError(
|
|
'Campi obbligatori mancanti: ' . implode(', ', $missing),
|
|
400,
|
|
'MISSING_REQUIRED_FIELDS'
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Valida formato email
|
|
*/
|
|
protected function validateEmail(string $email): bool
|
|
{
|
|
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
|
}
|
|
|
|
/**
|
|
* Valida Partita IVA italiana
|
|
*/
|
|
protected function validateVAT(string $vat): bool
|
|
{
|
|
$vat = preg_replace('/\s+/', '', $vat);
|
|
$vat = preg_replace('/^IT/i', '', $vat);
|
|
|
|
if (!preg_match('/^\d{11}$/', $vat)) {
|
|
return false;
|
|
}
|
|
|
|
$sum = 0;
|
|
for ($i = 0; $i < 11; $i++) {
|
|
$digit = (int) $vat[$i];
|
|
if ($i % 2 === 0) {
|
|
$sum += $digit;
|
|
} else {
|
|
$double = $digit * 2;
|
|
$sum += ($double > 9) ? $double - 9 : $double;
|
|
}
|
|
}
|
|
|
|
return ($sum % 10) === 0;
|
|
}
|
|
|
|
/**
|
|
* Valida Codice Fiscale italiano
|
|
*/
|
|
protected function validateFiscalCode(string $cf): bool
|
|
{
|
|
$cf = strtoupper(trim($cf));
|
|
return (bool) preg_match('/^[A-Z0-9]{16}$/', $cf);
|
|
}
|
|
|
|
/**
|
|
* Valida password secondo policy
|
|
*/
|
|
protected function validatePassword(string $password): array
|
|
{
|
|
$errors = [];
|
|
|
|
if (strlen($password) < PASSWORD_MIN_LENGTH) {
|
|
$errors[] = 'La password deve essere di almeno ' . PASSWORD_MIN_LENGTH . ' caratteri';
|
|
}
|
|
|
|
if (PASSWORD_REQUIRE_UPPERCASE && !preg_match('/[A-Z]/', $password)) {
|
|
$errors[] = 'La password deve contenere almeno una lettera maiuscola';
|
|
}
|
|
|
|
if (PASSWORD_REQUIRE_NUMBER && !preg_match('/[0-9]/', $password)) {
|
|
$errors[] = 'La password deve contenere almeno un numero';
|
|
}
|
|
|
|
if (PASSWORD_REQUIRE_SPECIAL && !preg_match('/[!@#$%^&*(),.?":{}|<>]/', $password)) {
|
|
$errors[] = 'La password deve contenere almeno un carattere speciale';
|
|
}
|
|
|
|
return $errors;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// AUTENTICAZIONE JWT
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Richiede autenticazione JWT
|
|
*/
|
|
/**
|
|
* Read-only guard Avatar di prodotto (product-demo-protocol v1.0.1).
|
|
*
|
|
* SAFE-BY-CONSTRUCTION: ritorna false (e NON tocca nulla) per qualsiasi JWT
|
|
* applicativo normale. Si attiva SOLO con un demo_jwt firmato che porta
|
|
* scope=demo:read-only oppure training:sandbox, validato contro demo_sessions.
|
|
*
|
|
* - demo:read-only → blocca OGNI scrittura (POST/PUT/PATCH/DELETE) con 403.
|
|
* - training:sandbox → consente scritture, ma SOLO sull'org sandbox della sessione.
|
|
* - Contesto sintetico: user id=0, ruolo compliance_manager, org = org del range
|
|
* demo riservato (996000-996999). Mai super_admin → admin resta irraggiungibile.
|
|
*
|
|
* @return bool true se ha gestito un contesto demo (il chiamante deve return).
|
|
*/
|
|
protected function applyDemoGuard(): bool
|
|
{
|
|
$token = $this->getBearerToken();
|
|
if (!$token) return false;
|
|
|
|
$payload = $this->verifyJWT($token);
|
|
if (!$payload) return false;
|
|
|
|
$scope = $payload['scope'] ?? '';
|
|
if ($scope !== 'demo:read-only' && $scope !== 'training:sandbox') {
|
|
return false; // JWT applicativo normale → flusso invariato
|
|
}
|
|
|
|
$orgId = (int) ($payload['org_id'] ?? 0);
|
|
if ($orgId < 996000 || $orgId > 996999) {
|
|
$this->jsonError('Contesto demo non valido', 401, 'DEMO_CTX_INVALID');
|
|
}
|
|
|
|
$sid = (string) ($payload['demo_session_id'] ?? '');
|
|
$sess = $sid !== '' ? Database::fetchOne('SELECT * FROM demo_sessions WHERE session_id = ?', [$sid]) : null;
|
|
if (!$sess || strtotime($sess['expires_at']) < time()) {
|
|
$this->jsonError('Sessione demo non valida o scaduta', 401, 'DEMO_SESSION_INVALID');
|
|
}
|
|
|
|
$isWrite = in_array($this->getMethod(), ['POST', 'PUT', 'PATCH', 'DELETE'], true);
|
|
if ($scope === 'demo:read-only' && $isWrite) {
|
|
$this->jsonError('Modalità demo in sola lettura: azione non disponibile', 403, 'DEMO_READ_ONLY');
|
|
}
|
|
if ($scope === 'training:sandbox' && $isWrite) {
|
|
// Scritture consentite SOLO sull'org sandbox della sessione (anti-spoof X-Organization-Id).
|
|
$reqOrgRaw = $_SERVER['HTTP_X_ORGANIZATION_ID'] ?? $this->getParam('org_id');
|
|
$reqOrg = ($reqOrgRaw !== null && $reqOrgRaw !== '') ? (int) $reqOrgRaw : null;
|
|
if ($reqOrg !== null && $reqOrg !== $orgId) {
|
|
$this->jsonError('Sandbox: scritture consentite solo sull\'organizzazione sandbox', 403, 'SANDBOX_ORG_LOCKED');
|
|
}
|
|
}
|
|
|
|
// Contesto sintetico read (o sandbox). Nessun accesso al DB utenti reali.
|
|
$this->isDemo = true;
|
|
$this->isSandbox = ($scope === 'training:sandbox');
|
|
$this->demoOrgId = $orgId;
|
|
$this->currentUser = [
|
|
'id' => 0, 'email' => 'demo@nis2-demo.local',
|
|
'full_name' => 'Avatar Demo', 'role' => 'compliance_manager',
|
|
'consulting_firm_id' => null,
|
|
];
|
|
$this->currentOrgId = $orgId;
|
|
$this->currentOrgRole = 'compliance_manager';
|
|
return true;
|
|
}
|
|
|
|
protected function requireAuth(): void
|
|
{
|
|
// Avatar di prodotto: se è un demo_jwt valido, applica il guard e termina qui.
|
|
if ($this->applyDemoGuard()) {
|
|
return;
|
|
}
|
|
|
|
$token = $this->getBearerToken();
|
|
|
|
if (!$token) {
|
|
$this->jsonError('Token di autenticazione mancante', 401, 'MISSING_TOKEN');
|
|
}
|
|
|
|
$payload = $this->verifyJWT($token);
|
|
|
|
if (!$payload) {
|
|
$this->jsonError('Token non valido o scaduto', 401, 'INVALID_TOKEN');
|
|
}
|
|
|
|
$user = Database::fetchOne(
|
|
'SELECT * FROM users WHERE id = ? AND is_active = 1',
|
|
[$payload['user_id']]
|
|
);
|
|
|
|
if (!$user) {
|
|
$this->jsonError('Utente non trovato o disabilitato', 401, 'USER_NOT_FOUND');
|
|
}
|
|
|
|
// --- Multi-device session verification (Fase 2 / G06) ---
|
|
// JWT senza jti = legacy (pre Fase 2) → rifiutato per forzare nuovo login con sessione tracciata.
|
|
$jti = $payload['jti'] ?? null;
|
|
if (!$jti) {
|
|
$this->jsonError('Token senza session id — effettua nuovamente il login', 401, 'JWT_NO_JTI');
|
|
}
|
|
$session = Database::fetchOne(
|
|
'SELECT * FROM active_sessions
|
|
WHERE id = ? AND user_id = ? AND revoked_at IS NULL AND expires_at > NOW()',
|
|
[$jti, (int) $user['id']]
|
|
);
|
|
if (!$session) {
|
|
$this->jsonError('Sessione non valida o revocata', 401, 'SESSION_REVOKED');
|
|
}
|
|
// Throttle last_activity update: max 1 update/min per evitare write storm
|
|
$lastTs = strtotime($session['last_activity_at']);
|
|
if ($lastTs && (time() - $lastTs) > 60) {
|
|
Database::update('active_sessions',
|
|
['last_activity_at' => date('Y-m-d H:i:s')],
|
|
'id = ?',
|
|
[$jti]
|
|
);
|
|
}
|
|
$user['session_jti'] = $jti;
|
|
$this->currentSession = $session;
|
|
|
|
$this->currentUser = $user;
|
|
}
|
|
|
|
/**
|
|
* Parse User-Agent in label friendly: "Chrome 134 su Windows", "Safari su macOS", ecc.
|
|
* Fallback "Browser sconosciuto" se UA assente/malformato.
|
|
*/
|
|
protected function parseDeviceLabel(string $ua): string
|
|
{
|
|
if ($ua === '') return 'Browser sconosciuto';
|
|
$os = 'OS sconosciuto';
|
|
if (preg_match('/Windows NT 10/i', $ua)) $os = 'Windows';
|
|
elseif (preg_match('/Mac OS X|Macintosh/i', $ua)) $os = 'macOS';
|
|
elseif (preg_match('/Android/i', $ua)) $os = 'Android';
|
|
elseif (preg_match('/iPhone|iPad|iOS/i', $ua)) $os = 'iOS';
|
|
elseif (preg_match('/Linux/i', $ua)) $os = 'Linux';
|
|
|
|
$browser = 'Browser';
|
|
if (preg_match('/Edg\/([0-9]+)/', $ua, $m)) $browser = 'Edge ' . $m[1];
|
|
elseif (preg_match('/OPR\/([0-9]+)/', $ua, $m)) $browser = 'Opera ' . $m[1];
|
|
elseif (preg_match('/Chrome\/([0-9]+)/', $ua, $m)) $browser = 'Chrome ' . $m[1];
|
|
elseif (preg_match('/Firefox\/([0-9]+)/', $ua, $m)) $browser = 'Firefox ' . $m[1];
|
|
elseif (preg_match('/Safari\/([0-9]+)/', $ua, $m) && !preg_match('/Chrome/', $ua)) {
|
|
if (preg_match('/Version\/([0-9]+)/', $ua, $m2)) $browser = 'Safari ' . $m2[1];
|
|
else $browser = 'Safari';
|
|
}
|
|
return substr($browser . ' su ' . $os, 0, 120);
|
|
}
|
|
|
|
/**
|
|
* Richiede ruolo super_admin
|
|
*/
|
|
protected function requireSuperAdmin(): void
|
|
{
|
|
$this->requireAuth();
|
|
|
|
if ($this->currentUser['role'] !== 'super_admin') {
|
|
$this->jsonError('Accesso riservato ai super amministratori', 403, 'SUPER_ADMIN_REQUIRED');
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// MULTI-TENANCY
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Richiede accesso all'organizzazione corrente
|
|
*/
|
|
protected function requireOrgAccess(): void
|
|
{
|
|
$this->requireAuth();
|
|
|
|
// Avatar di prodotto: contesto org già fissato dal guard sull'org demo/sandbox.
|
|
if ($this->isDemo) {
|
|
$this->currentOrgId = $this->demoOrgId;
|
|
$this->currentOrgRole = 'compliance_manager';
|
|
return;
|
|
}
|
|
|
|
$orgId = $this->resolveOrgId();
|
|
|
|
if (!$orgId) {
|
|
$this->jsonError('Organizzazione non selezionata', 403, 'NO_ORG');
|
|
}
|
|
|
|
// Super admin ha accesso a tutto
|
|
if ($this->currentUser['role'] === 'super_admin') {
|
|
$this->currentOrgId = $orgId;
|
|
$this->currentOrgRole = 'super_admin';
|
|
return;
|
|
}
|
|
|
|
// Verifica membership
|
|
$membership = Database::fetchOne(
|
|
'SELECT role FROM user_organizations WHERE user_id = ? AND organization_id = ?',
|
|
[$this->getCurrentUserId(), $orgId]
|
|
);
|
|
|
|
if (!$membership) {
|
|
$this->jsonError('Accesso non autorizzato a questa organizzazione', 403, 'ORG_ACCESS_DENIED');
|
|
}
|
|
|
|
$this->currentOrgId = $orgId;
|
|
$this->currentOrgRole = $membership['role'];
|
|
}
|
|
|
|
/**
|
|
* Richiede ruolo minimo nell'organizzazione
|
|
*/
|
|
protected function requireOrgRole(array $allowedRoles): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
|
|
// Avatar di prodotto: le scritture sono già filtrate dal guard (demo=block,
|
|
// sandbox=solo org sandbox). Le letture passano i gate di ruolo.
|
|
if ($this->isDemo) {
|
|
return;
|
|
}
|
|
|
|
if ($this->currentOrgRole === 'super_admin') {
|
|
return;
|
|
}
|
|
|
|
if (!in_array($this->currentOrgRole, $allowedRoles)) {
|
|
$this->jsonError(
|
|
'Ruolo insufficiente. Richiesto: ' . implode(' o ', $allowedRoles),
|
|
403,
|
|
'INSUFFICIENT_ROLE'
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Risolve l'ID organizzazione dalla richiesta
|
|
*/
|
|
protected function resolveOrgId(): ?int
|
|
{
|
|
// 1. Header X-Organization-Id
|
|
$orgId = $_SERVER['HTTP_X_ORGANIZATION_ID'] ?? null;
|
|
if ($orgId) {
|
|
return (int) $orgId;
|
|
}
|
|
|
|
// 2. Query parameter org_id
|
|
$orgId = $this->getParam('org_id');
|
|
if ($orgId) {
|
|
return (int) $orgId;
|
|
}
|
|
|
|
// 3. Organizzazione primaria dell'utente
|
|
$primary = Database::fetchOne(
|
|
'SELECT organization_id FROM user_organizations WHERE user_id = ? AND is_primary = 1',
|
|
[$this->getCurrentUserId()]
|
|
);
|
|
|
|
return $primary ? (int) $primary['organization_id'] : null;
|
|
}
|
|
|
|
/**
|
|
* Ottiene utente corrente
|
|
*/
|
|
protected function getCurrentUser(): ?array
|
|
{
|
|
return $this->currentUser;
|
|
}
|
|
|
|
/**
|
|
* Ottiene ID utente corrente
|
|
*/
|
|
protected function getCurrentUserId(): ?int
|
|
{
|
|
return $this->currentUser ? (int) $this->currentUser['id'] : null;
|
|
}
|
|
|
|
/**
|
|
* Ottiene ID organizzazione corrente
|
|
*/
|
|
protected function getCurrentOrgId(): ?int
|
|
{
|
|
return $this->currentOrgId;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// JWT TOKEN MANAGEMENT
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Estrae Bearer token dall'header Authorization
|
|
*/
|
|
protected function getBearerToken(): ?string
|
|
{
|
|
$headers = $this->getAuthorizationHeader();
|
|
|
|
if ($headers && preg_match('/Bearer\s(\S+)/', $headers, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
|
|
if (isset($_GET['token']) && !empty($_GET['token'])) {
|
|
return $_GET['token'];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Ottiene header Authorization
|
|
*/
|
|
private function getAuthorizationHeader(): ?string
|
|
{
|
|
if (isset($_SERVER['Authorization'])) {
|
|
return $_SERVER['Authorization'];
|
|
}
|
|
|
|
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
|
return $_SERVER['HTTP_AUTHORIZATION'];
|
|
}
|
|
|
|
if (function_exists('apache_request_headers')) {
|
|
$headers = apache_request_headers();
|
|
if (isset($headers['Authorization'])) {
|
|
return $headers['Authorization'];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Genera JWT token
|
|
*/
|
|
protected function generateJWT(int $userId, array $extraData = []): string
|
|
{
|
|
$header = json_encode([
|
|
'typ' => 'JWT',
|
|
'alg' => JWT_ALGORITHM,
|
|
]);
|
|
|
|
$payload = json_encode(array_merge([
|
|
'user_id' => $userId,
|
|
'iat' => time(),
|
|
'exp' => time() + JWT_EXPIRES_IN,
|
|
], $extraData));
|
|
|
|
$base64Header = $this->base64UrlEncode($header);
|
|
$base64Payload = $this->base64UrlEncode($payload);
|
|
|
|
$signature = hash_hmac('sha256', "$base64Header.$base64Payload", JWT_SECRET, true);
|
|
$base64Signature = $this->base64UrlEncode($signature);
|
|
|
|
return "$base64Header.$base64Payload.$base64Signature";
|
|
}
|
|
|
|
/**
|
|
* Verifica JWT token
|
|
*/
|
|
protected function verifyJWT(string $token): ?array
|
|
{
|
|
$parts = explode('.', $token);
|
|
|
|
if (count($parts) !== 3) {
|
|
return null;
|
|
}
|
|
|
|
[$base64Header, $base64Payload, $base64Signature] = $parts;
|
|
|
|
$signature = $this->base64UrlDecode($base64Signature);
|
|
$expectedSignature = hash_hmac('sha256', "$base64Header.$base64Payload", JWT_SECRET, true);
|
|
|
|
if (!hash_equals($signature, $expectedSignature)) {
|
|
return null;
|
|
}
|
|
|
|
$payload = json_decode($this->base64UrlDecode($base64Payload), true);
|
|
|
|
if (!$payload) {
|
|
return null;
|
|
}
|
|
|
|
if (isset($payload['exp']) && $payload['exp'] < time()) {
|
|
return null;
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
/**
|
|
* Genera refresh token.
|
|
* Da Fase 2 (G05) supporta linking esplicito alla `active_sessions.id` via $sessionJti
|
|
* per permettere rotazione safe e revoca cascade.
|
|
*/
|
|
protected function generateRefreshToken(int $userId, ?string $sessionJti = null): string
|
|
{
|
|
$token = bin2hex(random_bytes(32));
|
|
$expiresAt = date('Y-m-d H:i:s', time() + JWT_REFRESH_EXPIRES_IN);
|
|
|
|
Database::insert('refresh_tokens', [
|
|
'user_id' => $userId,
|
|
'token' => hash('sha256', $token),
|
|
'expires_at' => $expiresAt,
|
|
'session_jti' => $sessionJti,
|
|
]);
|
|
|
|
return $token;
|
|
}
|
|
|
|
protected function base64UrlEncode(string $data): string
|
|
{
|
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
|
}
|
|
|
|
protected function base64UrlDecode(string $data): string
|
|
{
|
|
return base64_decode(strtr($data, '-_', '+/'));
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// AUDIT LOGGING
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Registra azione nell'audit log
|
|
*/
|
|
protected function logAudit(string $action, ?string $entityType = null, ?int $entityId = null, ?array $details = null): void
|
|
{
|
|
$orgId = $this->currentOrgId;
|
|
$userId = $this->getCurrentUserId();
|
|
$severity = AuditService::resolveSeverity($action, $details);
|
|
$email = $this->currentUser['email'] ?? null;
|
|
|
|
AuditService::log(
|
|
$orgId ?? 0,
|
|
$userId,
|
|
$action,
|
|
$entityType,
|
|
$entityId,
|
|
$details,
|
|
$_SERVER['REMOTE_ADDR'] ?? '',
|
|
$_SERVER['HTTP_USER_AGENT'] ?? null,
|
|
$severity,
|
|
$email
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// UTILITY
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Sanitizza stringa per output
|
|
*/
|
|
protected function sanitize(string $value): string
|
|
{
|
|
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
/**
|
|
* Ottiene metodo HTTP della richiesta
|
|
*/
|
|
protected function getMethod(): string
|
|
{
|
|
return strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
|
}
|
|
|
|
/**
|
|
* Genera codice univoco
|
|
*/
|
|
protected function generateCode(string $prefix, int $length = 6): string
|
|
{
|
|
$number = str_pad(mt_rand(0, pow(10, $length) - 1), $length, '0', STR_PAD_LEFT);
|
|
return $prefix . '-' . $number;
|
|
}
|
|
}
|