Files
nis2-agile/application/controllers/DemoController.php
T
DevEnv nis2-agile 460d435a47 [DEMO] Auto-auth SPA demo: demo_jwt nel fragment spa_iframe_url + bootstrap token sincrono in common-bi.js
Chiude il gap auth: la SPA demo si auto-autentica col demo_jwt (token API nis2_access_token + org dal
payload) PRIMA di checkAuth, cross-origin (niente sessionStorage same-origin). spa_iframe_url ora atterra
su /dashboard.html?demo=..#djwt=... DemoController.DEMO_BASE override via env DEMO_BASE_URL (switch a
sottodominio senza codice). USR2 richiesto per DemoController.
2026-06-13 10:00:28 +02:00

267 lines
12 KiB
PHP

<?php
/**
* NIS2 Agile - Demo Controller (Avatar di prodotto, product-demo-protocol v1.0.1)
*
* 5 endpoint per la modalità demo guidata dall'avatar (Modalità A, prodotto live).
* Doc: docs/DEMO_AVATAR_NIS2.md | Standard: agile-services/docs/STANDARD_PRODUCT_DEMO_PROTOCOL.md
*
* NB: questi endpoint NON usano requireAuth() — auth dedicata demo (JWT scope=demo:read-only
* / X-Internal-Key). Il riconoscimento del demo_jwt sulle API app è in BaseController (step a parte).
*/
require_once __DIR__ . '/BaseController.php';
require_once __DIR__ . '/../services/DemoSeedService.php';
class DemoController extends BaseController
{
/** Range org riservato demo NIS2 (standard §7.1) */
private const DEMO_ORG_ID = 996001;
private const DEMO_RANGE = [996000, 996999];
private const JWT_TTL_SEC = 1800; // 30 min
private const DEMO_BASE = 'https://dimostrazione.agile.software';
private const RL_DIR = '/tmp/nis2_demo_rl';
// ── 1) POST /api/demo/session-start ──────────────────────────────────────
public function sessionStart(): void
{
$ip = $this->clientIp();
if (!$this->rateLimit($ip)) {
header('Retry-After: 60');
$this->jsonError('Troppe richieste demo, riprova tra poco', 429, 'RATE_LIMIT_EXCEEDED');
}
$language = in_array($this->getParam('language'), ['it', 'en'], true) ? $this->getParam('language') : 'it';
$variant = in_array($this->getParam('tour_variant'), ['full', 'express'], true) ? $this->getParam('tour_variant') : 'full';
$sessionId = 'demo-' . $this->uuid4();
$hmacKeySeed = base64_encode(random_bytes(32)); // 44 char base64 standard
$orgId = self::DEMO_ORG_ID;
$expiresTs = time() + self::JWT_TTL_SEC;
$demoJwt = $this->generateJWT(0, [
'scope' => 'demo:read-only',
'org_id' => $orgId,
'demo_session_id' => $sessionId,
'exp' => $expiresTs,
]);
// ip_hash server-side (anti-spoof): sha256(ip + daily salt)
$ipHash = hash('sha256', $ip . '|' . date('Y-m-d') . '|' . JWT_SECRET);
Database::insert('demo_sessions', [
'session_id' => $sessionId,
'organization_id' => $orgId,
'hmac_key_seed' => $hmacKeySeed,
'language' => $language,
'tour_variant' => $variant,
'presentation_mode' => 'realtime',
'referrer_url' => mb_substr((string) $this->getParam('referrer_url', ''), 0, 512) ?: null,
'user_agent' => mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 512) ?: null,
'ip_hash' => $ipHash,
'expires_at' => date('Y-m-d H:i:s', $expiresTs),
]);
$this->jsonSuccess([
'session_id' => $sessionId,
'demo_jwt' => $demoJwt,
'company_id' => $orgId, // alias di compatibilità standard
'organization_id' => $orgId,
// spa_iframe_url atterra su dashboard e porta il demo_jwt nel FRAGMENT (#djwt):
// common-bi.js lo imposta come token API in modo SINCRONO (prima di checkAuth),
// così la SPA si auto-autentica anche cross-origin (niente sessionStorage same-origin).
'spa_iframe_url' => $this->demoBase() . '/dashboard.html?demo=' . $sessionId . '#djwt=' . $demoJwt,
'manifest_url' => $this->demoBase() . '/api/demo/manifest?session_id=' . $sessionId,
'credentials_url' => $this->demoBase() . '/api/demo/credentials?session_id=' . $sessionId,
'expires_at' => gmdate('Y-m-d\TH:i:s\Z', $expiresTs),
'hmac_key_seed' => $hmacKeySeed,
'presentation_mode' => 'realtime',
], 'Sessione demo creata', 201);
}
// ── 2) GET /api/demo/manifest?session_id= ────────────────────────────────
public function manifest(): void
{
$session = $this->resolveSession();
if (!$session) {
$this->jsonError('Sessione demo non valida o scaduta', 401, 'DEMO_SESSION_INVALID');
}
$lang = in_array($this->getParam('language'), ['it', 'en'], true)
? $this->getParam('language') : ($session['language'] ?? 'it');
$file = PUBLIC_PATH . '/demo/nis2-tour-2026.json';
if (is_file($file)) {
$manifest = json_decode((string) file_get_contents($file), true) ?: [];
$manifest['session_id'] = $session['session_id'];
$manifest['language'] = $lang;
$this->jsonSuccess($manifest);
}
// Placeholder finché il manifest reale non è pubblicato (Fase 1, step successivo)
$this->jsonSuccess([
'tour_id' => 'demo:nis2-tour-2026',
'tour_version' => '0.1',
'session_id' => $session['session_id'],
'language' => $lang,
'tour_variant' => $session['tour_variant'] ?? 'full',
'presentation_mode' => 'realtime',
'persona_key' => 'ARIA_SUPPORT_NIS2',
'control_protocol' => 'agilehub.product-demo.v1',
'iframe_origin' => self::DEMO_BASE,
'steps' => [],
'fallback_static' => ['image_url' => null, 'audio_url_it' => null, 'audio_url_en' => null],
'_note' => 'manifest placeholder — steps in arrivo (Fase 1)',
]);
}
// ── 3) POST /api/demo/event ──────────────────────────────────────────────
public function event(): void
{
$session = $this->resolveSession();
if (!$session) {
$this->jsonError('Sessione demo non valida o scaduta', 401, 'DEMO_SESSION_INVALID');
}
$eventType = (string) $this->getParam('event_type', '');
$allowed = ['step_completed','step_skipped','tour_paused','tour_aborted','free_question_asked','cta_clicked','presentation_mode_chosen','error_encountered'];
if (!in_array($eventType, $allowed, true)) {
$this->jsonError('event_type non valido', 400, 'INVALID_EVENT_TYPE');
}
$stepId = $this->getParam('step_id');
$tsIso = mb_substr((string) $this->getParam('client_timestamp_iso', gmdate('Y-m-d\TH:i:s\Z')), 0, 40);
$meta = $this->getParam('metadata');
// Idempotente: UNIQUE (session_id, event_type, step_id, client_timestamp_iso)
try {
Database::insert('demo_events', [
'session_id' => $session['session_id'],
'event_type' => $eventType,
'step_id' => $stepId !== null ? (int) $stepId : null,
'metadata' => $meta !== null ? json_encode($meta, JSON_UNESCAPED_UNICODE) : null,
'client_timestamp_iso' => $tsIso,
]);
} catch (Throwable $e) {
// duplicato (replay idempotente) → ignora silenziosamente
}
http_response_code(204);
exit;
}
// ── 4) GET /api/demo/credentials?session_id= ─────────────────────────────
public function credentials(): void
{
$sessionId = (string) $this->getParam('session_id', '');
$session = $sessionId !== '' ? $this->loadSession($sessionId) : null;
if (!$session) {
$this->jsonError('Sessione demo non trovata o scaduta', 404, 'DEMO_SESSION_NOT_FOUND');
}
$expiresTs = strtotime($session['expires_at']);
$demoJwt = $this->generateJWT(0, [
'scope' => 'demo:read-only',
'org_id' => (int) $session['organization_id'],
'demo_session_id' => $session['session_id'],
'exp' => $expiresTs,
]);
$this->jsonSuccess([
'demo_jwt' => $demoJwt,
'company_id' => (int) $session['organization_id'],
'organization_id' => (int) $session['organization_id'],
'session_id' => $session['session_id'],
'expires_at' => gmdate('Y-m-d\TH:i:s\Z', $expiresTs),
]);
}
// ── 5) POST /api/demo/reset-dataset ──────────────────────────────────────
public function resetDataset(): void
{
$key = $_SERVER['HTTP_X_INTERNAL_KEY'] ?? '';
$expected = getenv('INTERNAL_DEMO_KEY') ?: (getenv('INTERNAL_EMAIL_KEY') ?: '');
if ($expected === '' || !hash_equals($expected, $key)) {
$this->jsonError('Non autorizzato', 401, 'UNAUTHORIZED');
}
// Reset idempotente del dataset demo (clona la golden #151 in 996001/996002).
// ?scope=demo → solo 996001 (read-only) | ?scope=sandbox → solo 996002 | default: entrambe.
$scope = (string) $this->getParam('scope', '');
$targets = $scope === 'demo' ? [996001 => DemoSeedService::TARGETS[996001]]
: ($scope === 'sandbox' ? [996002 => DemoSeedService::TARGETS[996002]]
: DemoSeedService::TARGETS);
$result = DemoSeedService::seed($targets);
$failed = array_filter($result, fn($v) => str_starts_with($v, 'errore'));
$this->jsonSuccess([
'reset' => empty($failed) ? 'done' : 'partial',
'company_ids' => array_keys($result),
'detail' => $result,
], empty($failed) ? 'Dataset demo reimpostato' : 'Reset demo parziale (vedi detail)', empty($failed) ? 200 : 207);
}
// ═══ Helpers ═════════════════════════════════════════════════════════════
/** Base URL della SPA demo (override via env DEMO_BASE_URL per switch a sottodominio). */
private function demoBase(): string
{
return getenv('DEMO_BASE_URL')
?: ($_SERVER['DEMO_BASE_URL'] ?? '')
?: ($_ENV['DEMO_BASE_URL'] ?? '')
?: self::DEMO_BASE;
}
/** Risolve la sessione da Bearer demo_jwt (preferito) o ?session_id=. */
private function resolveSession(): ?array
{
$token = $this->getBearerToken();
if ($token) {
$payload = $this->verifyJWT($token);
if ($payload && ($payload['scope'] ?? '') === 'demo:read-only' && !empty($payload['demo_session_id'])) {
return $this->loadSession((string) $payload['demo_session_id']);
}
}
$sid = (string) $this->getParam('session_id', '');
return $sid !== '' ? $this->loadSession($sid) : null;
}
private function loadSession(string $sessionId): ?array
{
$s = Database::fetchOne('SELECT * FROM demo_sessions WHERE session_id = ?', [$sessionId]);
if (!$s) return null;
if (strtotime($s['expires_at']) < time()) return null; // scaduta
return $s;
}
/** Rate-limit file-based: 5/min + 50/h per IP. true = consentito. */
private function rateLimit(string $ip): bool
{
if (!is_dir(self::RL_DIR)) @mkdir(self::RL_DIR, 0700, true);
$f = self::RL_DIR . '/' . md5($ip) . '.json';
$now = time();
$hits = is_file($f) ? (json_decode((string) file_get_contents($f), true) ?: []) : [];
$hits = array_values(array_filter($hits, fn($t) => $t > $now - 3600));
$lastMin = count(array_filter($hits, fn($t) => $t > $now - 60));
if ($lastMin >= 5 || count($hits) >= 50) return false;
$hits[] = $now;
@file_put_contents($f, json_encode($hits), LOCK_EX);
return true;
}
private function uuid4(): string
{
$b = random_bytes(16);
$b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
$b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
}
private function clientIp(): string
{
$xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
if ($xff !== '') return trim(explode(',', $xff)[0]);
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
}