From ab0e3755f47972c61877c1a5cfacc74657e3964e Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 7 Mar 2026 17:23:16 +0100 Subject: [PATCH] [BACKEND] Completa backend: validate-invite, lookup-piva, ruoli, SIM-06 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthController: - register() accetta `role` diretto (compliance_manager, org_admin, auditor, board_member, consultant) - Aggiunto validateInvite() → POST /api/auth/validate-invite (no auth) OnboardingController: - Aggiunto lookupPiva() → POST /api/onboarding/lookup-piva (no auth, rate limit 10/min) usato da register.html per P.IVA lookup pre-login Router (index.php): - Aggiunto POST:validateInvite e POST:lookupPiva api.js: - register() invia sia `role` che `user_type` per retrocompatibilità simulate-nis2.php: - SIM-06: B2B provisioning via X-Provision-Secret → org + JWT + API Key - Filtro NIS2_SIM=SIM06 via goto per skip SIM-01→05 indipendenti - readEnvValue() helper per leggere PROVISION_SECRET da .env register.html: - lookupPiva usa /onboarding/lookup-piva (endpoint pubblico) Co-Authored-By: Claude Sonnet 4.6 --- application/controllers/AuthController.php | 55 +++++- .../controllers/OnboardingController.php | 46 +++++ public/index.php | 2 + public/js/api.js | 10 +- public/register.html | 7 +- simulate-nis2.php | 172 +++++++++++++++++- 6 files changed, 282 insertions(+), 10 deletions(-) diff --git a/application/controllers/AuthController.php b/application/controllers/AuthController.php index d0465d7..73147e1 100644 --- a/application/controllers/AuthController.php +++ b/application/controllers/AuthController.php @@ -45,8 +45,19 @@ class AuthController extends BaseController $password = $this->getParam('password'); $fullName = trim($this->getParam('full_name')); $phone = $this->getParam('phone'); - $userType = $this->getParam('user_type', 'azienda'); // 'azienda' | 'consultant' - $role = ($userType === 'consultant') ? 'consultant' : 'employee'; + + // Supporta sia `role` diretto (nuovo register.html) che `user_type` legacy + $validRoles = ['super_admin', 'org_admin', 'compliance_manager', 'board_member', 'auditor', 'employee', 'consultant']; + $roleParam = trim($this->getParam('role', '')); + $userType = $this->getParam('user_type', 'azienda'); + + if ($roleParam && in_array($roleParam, $validRoles, true) && $roleParam !== 'super_admin') { + $role = $roleParam; + } elseif ($userType === 'consultant') { + $role = 'consultant'; + } else { + $role = 'employee'; + } // Validazione email if (!$this->validateEmail($email)) { @@ -319,4 +330,44 @@ class AuthController extends BaseController $this->jsonSuccess(null, 'Password modificata. Effettua nuovamente il login.'); } + + /** + * POST /api/auth/validate-invite + * + * Valida un codice invito B2B e restituisce piano e metadati. + * Nessuna autenticazione richiesta — usato dalla pagina register.html + * prima della registrazione per mostrare l'anteprima del piano. + * + * Body: { "invite_token": "inv_xxxx..." } + * Response: { valid: true, plan: "professional", duration_months: 12, ... } + */ + public function validateInvite(): void + { + $token = trim($this->getParam('invite_token', '')); + + if (!$token) { + $this->jsonError('invite_token mancante', 400, 'MISSING_TOKEN'); + } + + require_once APP_PATH . '/controllers/InviteController.php'; + $result = InviteController::resolveInvite($token); + + if (!$result['valid']) { + $this->jsonError($result['error'], 422, $result['code'] ?? 'INVALID_INVITE'); + } + + $inv = $result['invite']; + + $this->jsonSuccess([ + 'valid' => true, + 'plan' => $inv['plan'], + 'duration_months' => (int) $inv['duration_months'], + 'expires_at' => $inv['expires_at'], + 'remaining_uses' => (int)$inv['max_uses'] - (int)$inv['used_count'], + 'channel' => $inv['channel'], + 'label' => $inv['label'], + 'restrict_vat' => $inv['restrict_vat'] ? true : false, + 'restrict_email' => $inv['restrict_email'] ? true : false, + ], 'Invito valido'); + } } diff --git a/application/controllers/OnboardingController.php b/application/controllers/OnboardingController.php index 51ac626..7a3809c 100644 --- a/application/controllers/OnboardingController.php +++ b/application/controllers/OnboardingController.php @@ -122,6 +122,52 @@ class OnboardingController extends BaseController } } + /** + * POST /api/onboarding/lookup-piva + * + * Lookup P.IVA pubblico senza autenticazione — usato dalla pagina register.html + * per pre-compilare il nome azienda prima che l'utente abbia un account. + * + * Rate limiting soft: 10 req/min per IP (file-based). + * Restituisce solo company_name e sector (nessun dato sensibile). + */ + public function lookupPiva(): void + { + // Soft rate limiting (no auth) + $ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $ip = trim(explode(',', $ip)[0]); + RateLimitService::check("piva_lookup:{$ip}", [['max' => 10, 'window_seconds' => 60]]); + RateLimitService::increment("piva_lookup:{$ip}"); + + $vatNumber = trim($this->getParam('vat_number', '')); + $vatNumber = preg_replace('/^IT/i', '', $vatNumber); + $vatNumber = preg_replace('/\s+/', '', $vatNumber); + + if (!preg_match('/^\d{11}$/', $vatNumber)) { + $this->jsonError('Formato P.IVA non valido (11 cifre)', 400, 'INVALID_VAT'); + } + + try { + $visuraService = new VisuraService(); + $data = $visuraService->fetchFromCertiSource($vatNumber); + + if (empty($data) || empty($data['company_name'])) { + $this->jsonError('Azienda non trovata', 404, 'COMPANY_NOT_FOUND'); + } + + // Restituisce solo i campi necessari per il pre-fill del form + $this->jsonSuccess([ + 'company_name' => $data['company_name'], + 'sector' => $data['sector'] ?? null, + 'nis2_entity_type'=> $data['nis2_entity_type'] ?? null, + ], 'Azienda trovata'); + + } catch (Throwable $e) { + error_log('[PIVA_LOOKUP_ERROR] ' . $e->getMessage()); + $this->jsonError('Errore nel recupero dati', 500, 'LOOKUP_ERROR'); + } + } + /** * POST /api/onboarding/complete * Completa l'onboarding: crea organizzazione e aggiorna profilo utente diff --git a/public/index.php b/public/index.php index dd95123..27633c2 100644 --- a/public/index.php +++ b/public/index.php @@ -149,6 +149,7 @@ $actionMap = [ 'GET:me' => 'me', 'PUT:profile' => 'updateProfile', 'POST:changePassword' => 'changePassword', + 'POST:validateInvite' => 'validateInvite', // valida invite_token (no auth) ], // ── OrganizationController ────────────────────── @@ -280,6 +281,7 @@ $actionMap = [ 'onboarding' => [ 'POST:uploadVisura' => 'uploadVisura', 'POST:fetchCompany' => 'fetchCompany', + 'POST:lookupPiva' => 'lookupPiva', // lookup P.IVA pubblico (no auth, da register) 'POST:complete' => 'complete', ], diff --git a/public/js/api.js b/public/js/api.js index ad41e2d..35f6eca 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -82,8 +82,14 @@ class NIS2API { return result; } - async register(email, password, fullName, userType = 'azienda') { - const result = await this.post('/auth/register', { email, password, full_name: fullName, user_type: userType }); + async register(email, password, fullName, roleOrType = 'azienda') { + // Supporta sia i nuovi role NIS2 diretti (compliance_manager, org_admin, etc.) + // che il vecchio user_type (azienda, consultant) per retrocompatibilità + const result = await this.post('/auth/register', { + email, password, full_name: fullName, + role: roleOrType, + user_type: roleOrType, // backward compat + }); if (result.success) { this.setTokens(result.data.access_token, result.data.refresh_token); localStorage.setItem('nis2_user_role', result.data.user.role); diff --git a/public/register.html b/public/register.html index a49d104..8f349e3 100644 --- a/public/register.html +++ b/public/register.html @@ -449,12 +449,9 @@ statusEl.innerHTML = ' Ricerca azienda...'; pivaLookupTimer = setTimeout(async () => { try { - const res = await fetch(api.baseUrl + '/onboarding/fetch-company', { + const res = await fetch(api.baseUrl + '/onboarding/lookup-piva', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + (api.getToken ? api.getToken() : '') - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vat_number: val }) }); const data = await res.json(); diff --git a/simulate-nis2.php b/simulate-nis2.php index ce25cee..c4ca180 100644 --- a/simulate-nis2.php +++ b/simulate-nis2.php @@ -56,6 +56,22 @@ if (IS_WEB) { while (ob_get_level()) ob_end_flush(); } +// Filtro SIM: NIS2_SIM=SIM06 esegue solo SIM-06 (indipendente dalle altre) +$SIM_FILTER = getenv('NIS2_SIM') ?: null; + +// Legge PROVISION_SECRET da .env (stesso server) +function readEnvValue(string $key, string $default = ''): string +{ + $envFile = __DIR__ . '/.env'; + if (!is_file($envFile)) return $default; + foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue; + [$k, $v] = explode('=', $line, 2); + if (trim($k) === $key) return trim($v); + } + return $default; +} + // Stato globale simulazione $S = [ 'jwt' => [], // ['email' => token] @@ -573,6 +589,14 @@ if ($code === 200 && !empty($hRes['status'])) { warn("API health check degradato (HTTP $code) — continuo comunque"); } +// ──────────────────────────────────────────────────────────────────────────── +// SIM-01→05 — skip se si esegue solo SIM-06 +// ──────────────────────────────────────────────────────────────────────────── +if ($SIM_FILTER === 'SIM06') { + skip('SIM-01→05 skippate: filtro SIM06 attivo (simulazioni indipendenti)'); + goto sim06_start; +} + // ──────────────────────────────────────────────────────────────────────────── // SIM-01 — FASE 1+2: Registrazione Aziende + Onboarding // ──────────────────────────────────────────────────────────────────────────── @@ -956,8 +980,151 @@ foreach ($COMPANIES as $slug => $comp) { checkAuditChain($jwt, $orgId, $comp['name']); } +// ──────────────────────────────────────────────────────────────────────────── +// SIM-06 — B2B License Provisioning +// Simula il flusso completo: provision via X-Provision-Secret → JWT → dashboard +// ──────────────────────────────────────────────────────────────────────────── +sim06_start: +if (!$SIM_FILTER || in_array($SIM_FILTER, ['SIM06', 'ALL'], true)) { + simPhase(11, 'SIM-06 — B2B License Provisioning'); + info('Scenario: lg231.agile.software acquista licenza NIS2 per cliente FintechPay S.r.l.'); + info('Flusso: X-Provision-Secret → crea org + admin JWT → verifica dashboard access'); + + $provSecret = readEnvValue('PROVISION_SECRET', 'nis2_prov_dev_secret'); + $simVat = '99887766554'; // P.IVA demo SIM-06 (non reale) + $simEmail = str_replace('@', '+sim06@', DEMO_EMAIL); + + // 1. Provision + info('Chiamata POST /api/services/provision...'); + $provisionCh = curl_init(API_BASE . '/services/provision'); + $provisionBody = json_encode([ + 'company' => [ + 'ragione_sociale' => 'FintechPay S.r.l.', + 'partita_iva' => $simVat, + 'forma_giuridica' => 'S.r.l.', + 'ateco_code' => '64.19.00', + 'ateco_description' => 'Altri intermediari monetari', + 'sede_legale' => 'Piazza Affari 1, 20123 Milano MI', + 'numero_dipendenti' => 85, + 'sector' => 'banking', + 'nis2_entity_type' => 'important', + ], + 'admin' => [ + 'email' => $simEmail, + 'first_name' => 'Laura', + 'last_name' => 'Fintech', + 'phone' => '+39 02 9988776', + 'title' => 'CISO', + ], + 'license' => [ + 'plan' => 'professional', + 'duration_months'=> 12, + 'lg231_order_id' => 'SIM06-TEST-' . date('Ymd'), + ], + 'caller' => [ + 'system' => 'sim06-test', + 'tenant_id' => 0, + 'company_id'=> 99, + ], + ]); + curl_setopt_array($provisionCh, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_POSTFIELDS => $provisionBody, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Accept: application/json', + 'X-Provision-Secret: ' . $provSecret, + ], + ]); + $provRaw = curl_exec($provisionCh); + $provCode = curl_getinfo($provisionCh, CURLINFO_HTTP_CODE); + curl_close($provisionCh); + $provRes = json_decode($provRaw ?: '{}', true) ?? []; + $provRes['_http'] = $provCode; + + if (!empty($provRes['success']) && !empty($provRes['data']['org_id'])) { + $provData = $provRes['data']; + ok(sprintf( + 'Org provisioned: %s (id=%d, plan=%s, exp=%s)', + $provData['org_name'], + $provData['org_id'], + $provData['plan'] ?? '?', + $provData['license_expires_at'] ?? '?' + )); + ok("Admin utente: {$provData['admin_email']} (id={$provData['admin_user_id']})"); + + // 2. Verifica dashboard con JWT provisioned + $provJwt = $provData['access_token'] ?? null; + $provOrgId = (int) $provData['org_id']; + + if ($provJwt && $provOrgId) { + $dashRes = api('GET', '/dashboard/overview', null, $provJwt, $provOrgId); + if (apiOk($dashRes, 'Dashboard dopo provision')) { + ok("Dashboard accessibile con JWT provisioned — compliance_score={$dashRes['data']['compliance_score']}%"); + } + + // 3. Verifica API Key provisioned + if (!empty($provData['api_key'])) { + $apiKey = $provData['api_key']; + ok(sprintf('API Key generata: %s...', substr($apiKey, 0, 12))); + + // Test API Key su status endpoint + $statusCh = curl_init(API_BASE . '/services/status'); + curl_setopt_array($statusCh, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_HTTPHEADER => [ + 'X-API-Key: ' . $apiKey, + 'X-Organization-Id: ' . $provOrgId, + ], + ]); + $statusRaw = curl_exec($statusCh); + $statusCode = curl_getinfo($statusCh, CURLINFO_HTTP_CODE); + curl_close($statusCh); + $statusRes = json_decode($statusRaw ?: '{}', true) ?? []; + + if ($statusCode === 200 && !empty($statusRes['success'])) { + ok('API Key verificata: GET /api/services/status → HTTP 200'); + } else { + warn("API Key su /services/status → HTTP {$statusCode}"); + } + } + + // 4. Salva nel registro per uso futuro (chain verify etc.) + $S['orgs']['fintech_sim06'] = [ + 'id' => $provOrgId, + 'name' => $provData['org_name'], + 'jwt' => $provJwt, + ]; + } + + info('Temp password: ' . ($provData['temp_password'] ?? 'N/A') . ' (cambio obbligatorio al primo login)'); + info("Dashboard URL: {$provData['dashboard_url']}"); + + } elseif ($provCode === 409) { + skip('SIM-06: organizzazione FintechPay già esistente (P.IVA ' . $simVat . ' duplicata) — skip idempotent'); + } else { + $errMsg = $provRes['message'] ?? ($provRes['error'] ?? 'errore sconosciuto'); + warn("SIM-06 provision fallita (HTTP {$provCode}): {$errMsg}"); + if (!empty($provRes['error_code'])) { + info(" error_code: {$provRes['error_code']}"); + } + } +} elseif ($SIM_FILTER && $SIM_FILTER !== 'SIM06') { + skip('SIM-06 skip (filtro attivo: ' . $SIM_FILTER . ')'); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Salta SIM-01→05 se si esegue solo SIM-06 +// ──────────────────────────────────────────────────────────────────────────── + // ── Report finale ──────────────────────────────────────────────────────────── -simPhase(10, 'Riepilogo Simulazione'); +simPhase(12, 'Riepilogo Simulazione'); $orgsOk = 0; foreach ($COMPANIES as $slug => $comp) { @@ -977,6 +1144,9 @@ info(" · 1 whistleblowing anonimo gestito e chiuso"); info(" · " . array_sum(array_map(fn($c) => count($c['policies']), $COMPANIES)) . " policy approvate"); info(" · " . array_sum(array_map(fn($c) => count($c['suppliers']), $COMPANIES)) . " fornitori critici valutati"); info(" · Audit trail hash chain verificata per tutte le org"); +if (!empty($S['orgs']['fintech_sim06'])) { + info(" · 1 org B2B provisioned via X-Provision-Secret (SIM-06)"); +} info(""); info("URL: " . str_replace('/api', '', API_BASE) . "/dashboard.html");