[BACKEND] Completa backend: validate-invite, lookup-piva, ruoli, SIM-06

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 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-03-07 17:23:16 +01:00
parent e4e7d94043
commit ab0e3755f4
6 changed files with 282 additions and 10 deletions

View File

@ -45,8 +45,19 @@ class AuthController extends BaseController
$password = $this->getParam('password'); $password = $this->getParam('password');
$fullName = trim($this->getParam('full_name')); $fullName = trim($this->getParam('full_name'));
$phone = $this->getParam('phone'); $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 // Validazione email
if (!$this->validateEmail($email)) { if (!$this->validateEmail($email)) {
@ -319,4 +330,44 @@ class AuthController extends BaseController
$this->jsonSuccess(null, 'Password modificata. Effettua nuovamente il login.'); $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');
}
} }

View File

@ -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 * POST /api/onboarding/complete
* Completa l'onboarding: crea organizzazione e aggiorna profilo utente * Completa l'onboarding: crea organizzazione e aggiorna profilo utente

View File

@ -149,6 +149,7 @@ $actionMap = [
'GET:me' => 'me', 'GET:me' => 'me',
'PUT:profile' => 'updateProfile', 'PUT:profile' => 'updateProfile',
'POST:changePassword' => 'changePassword', 'POST:changePassword' => 'changePassword',
'POST:validateInvite' => 'validateInvite', // valida invite_token (no auth)
], ],
// ── OrganizationController ────────────────────── // ── OrganizationController ──────────────────────
@ -280,6 +281,7 @@ $actionMap = [
'onboarding' => [ 'onboarding' => [
'POST:uploadVisura' => 'uploadVisura', 'POST:uploadVisura' => 'uploadVisura',
'POST:fetchCompany' => 'fetchCompany', 'POST:fetchCompany' => 'fetchCompany',
'POST:lookupPiva' => 'lookupPiva', // lookup P.IVA pubblico (no auth, da register)
'POST:complete' => 'complete', 'POST:complete' => 'complete',
], ],

View File

@ -82,8 +82,14 @@ class NIS2API {
return result; return result;
} }
async register(email, password, fullName, userType = 'azienda') { async register(email, password, fullName, roleOrType = 'azienda') {
const result = await this.post('/auth/register', { email, password, full_name: fullName, user_type: userType }); // 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) { if (result.success) {
this.setTokens(result.data.access_token, result.data.refresh_token); this.setTokens(result.data.access_token, result.data.refresh_token);
localStorage.setItem('nis2_user_role', result.data.user.role); localStorage.setItem('nis2_user_role', result.data.user.role);

View File

@ -449,12 +449,9 @@
statusEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Ricerca azienda...'; statusEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Ricerca azienda...';
pivaLookupTimer = setTimeout(async () => { pivaLookupTimer = setTimeout(async () => {
try { try {
const res = await fetch(api.baseUrl + '/onboarding/fetch-company', { const res = await fetch(api.baseUrl + '/onboarding/lookup-piva', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (api.getToken ? api.getToken() : '')
},
body: JSON.stringify({ vat_number: val }) body: JSON.stringify({ vat_number: val })
}); });
const data = await res.json(); const data = await res.json();

View File

@ -56,6 +56,22 @@ if (IS_WEB) {
while (ob_get_level()) ob_end_flush(); 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 // Stato globale simulazione
$S = [ $S = [
'jwt' => [], // ['email' => token] 'jwt' => [], // ['email' => token]
@ -573,6 +589,14 @@ if ($code === 200 && !empty($hRes['status'])) {
warn("API health check degradato (HTTP $code) — continuo comunque"); 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 // SIM-01 — FASE 1+2: Registrazione Aziende + Onboarding
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
@ -956,8 +980,151 @@ foreach ($COMPANIES as $slug => $comp) {
checkAuditChain($jwt, $orgId, $comp['name']); 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 ──────────────────────────────────────────────────────────── // ── Report finale ────────────────────────────────────────────────────────────
simPhase(10, 'Riepilogo Simulazione'); simPhase(12, 'Riepilogo Simulazione');
$orgsOk = 0; $orgsOk = 0;
foreach ($COMPANIES as $slug => $comp) { 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['policies']), $COMPANIES)) . " policy approvate");
info(" · " . array_sum(array_map(fn($c) => count($c['suppliers']), $COMPANIES)) . " fornitori critici valutati"); info(" · " . array_sum(array_map(fn($c) => count($c['suppliers']), $COMPANIES)) . " fornitori critici valutati");
info(" · Audit trail hash chain verificata per tutte le org"); 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("");
info("URL: " . str_replace('/api', '', API_BASE) . "/dashboard.html"); info("URL: " . str_replace('/api', '', API_BASE) . "/dashboard.html");