[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:
parent
e4e7d94043
commit
ab0e3755f4
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
],
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -449,12 +449,9 @@
|
||||
statusEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 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();
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user