From de09af6d7e86f27f9b6350156214db5ddc036223 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sun, 31 May 2026 17:40:20 +0200 Subject: [PATCH] [FEAT] Fase 3 backend: portale fornitore OTP/magic-link (SupplierPortalController) Auth fornitore SEPARATA dagli utenti interni (supplier_users/otp/sessions, mig 034): - SUPPLIER_JWT_SECRET dedicato, aud=supplier-portal, claim sp_uid/supplier_id/org_id (mai user_id); requireSupplierSession() verifica jti in supplier_sessions (revocabile), non tocca users/active_sessions. - OTP 8 cifre SHA-256, 15min, lockout persistente (attempts+locked_until), invalidazione OTP precedenti, hash_equals, rate-limit email+IP. - magic-link 32B hashed single-use (consumo atomico solo su verify). - request-otp risposta opaca anti-enumerazione. - OTP via EmailService::sendViaTemplate (/api/emails/send, fuori da email_log). - Endpoint: requestOtp/verifyOtp (no auth) + me/getQuestionnaire/saveAnswers (PATCH autosave)/submitQuestionnaire. Ownership campaign.supplier_id==session (no IDOR). - Scoring per-vulnerabilita (Art.21.3), snapshot domande immutabile. - config: SUPPLIER_JWT_SECRET + PATCH in CORS_ALLOWED_METHODS. - routes: controllerMap + actionMap supplier-portal. php -l OK su tutti. Tabelle 034 gia' applicate su host. Co-Authored-By: Claude Opus 4.8 --- .../controllers/SupplierPortalController.php | 706 ++++++++++++++++++ public/index.php | 13 + 2 files changed, 719 insertions(+) create mode 100644 application/controllers/SupplierPortalController.php diff --git a/application/controllers/SupplierPortalController.php b/application/controllers/SupplierPortalController.php new file mode 100644 index 0000000..9e380b9 --- /dev/null +++ b/application/controllers/SupplierPortalController.php @@ -0,0 +1,706 @@ + fuori da email_log). + * 6) Ogni endpoint valida campaign.supplier_id == session.supplier_id (no IDOR). + */ + +require_once __DIR__ . '/BaseController.php'; +require_once APP_PATH . '/services/EmailService.php'; +require_once APP_PATH . '/services/RateLimitService.php'; +require_once APP_PATH . '/services/AuditService.php'; + +class SupplierPortalController extends BaseController +{ + private const SESSION_TTL_SECONDS = 4 * 3600; + private const OTP_TTL_SECONDS = 15 * 60; + private const OTP_DIGITS = 8; + private const OTP_MAX_ATTEMPTS = 5; + private const OTP_LOCKOUT_SECONDS = 15 * 60; + private const RL_REQUEST = [['max' => 3, 'window_seconds' => 600], ['max' => 8, 'window_seconds' => 3600]]; + private const RL_VERIFY = [['max' => 10, 'window_seconds' => 60], ['max' => 40, 'window_seconds' => 3600]]; + + private ?array $supplierSession = null; + + // ════════════════ AUTH OTP / MAGIC-LINK (NO AUTH interna) ════════════════ + + /** POST /api/supplier-portal/request-otp Body: {email} — risposta opaca. */ + public function requestOtp(): void + { + $email = strtolower(trim((string) $this->getParam('email', ''))); + $ip = $this->getClientIPLocal(); + $opaqueMsg = 'Se l\'email e\' registrata, riceverai il codice di accesso.'; + + if ($email === '' || !$this->validateEmail($email)) { + $this->jsonSuccess(['delivered' => true], $opaqueMsg); + } + + RateLimitService::check('sp_req_email:' . $email, self::RL_REQUEST); + RateLimitService::check('sp_req_ip:' . $ip, self::RL_REQUEST); + RateLimitService::increment('sp_req_email:' . $email); + RateLimitService::increment('sp_req_ip:' . $ip); + + $su = Database::fetchOne( + 'SELECT su.id, su.supplier_id, su.organization_id, su.email, su.full_name + FROM supplier_users su JOIN suppliers s ON s.id = su.supplier_id + WHERE su.email = ? AND su.is_active = 1 AND s.deleted_at IS NULL + ORDER BY su.last_login_at DESC, su.id DESC LIMIT 1', + [$email] + ); + if (!$su) { + $this->jsonSuccess(['delivered' => true], $opaqueMsg); + } + + // Invalida OTP/magic precedenti del referente. + Database::query('UPDATE supplier_otp SET consumed_at = NOW() WHERE supplier_user_id = ? AND consumed_at IS NULL', + [(int) $su['id']]); + + $otpCode = $this->generateNumericOtp(self::OTP_DIGITS); + $magicToken = 'sp_' . bin2hex(random_bytes(32)); + Database::insert('supplier_otp', [ + 'supplier_user_id' => (int) $su['id'], + 'otp_hash' => hash('sha256', $otpCode), + 'magic_token_hash' => hash('sha256', $magicToken), + 'purpose' => 'login', + 'attempts' => 0, + 'max_attempts' => self::OTP_MAX_ATTEMPTS, + 'expires_at' => date('Y-m-d H:i:s', time() + self::OTP_TTL_SECONDS), + 'ip_created' => substr($ip, 0, 45), + ]); + + $committente = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [(int) $su['organization_id']]); + $committenteName = $committente['name'] ?? 'il committente'; + $magicUrl = rtrim(APP_URL, '/') . '/supplier-portal.html?magic=' . urlencode($magicToken); + + try { + (new EmailService())->sendViaTemplate($su['email'], 'supplier_otp', [ + 'committente' => $committenteName, + 'otp' => trim(chunk_split($otpCode, 3, ' ')), + 'magic_url' => $magicUrl, + 'full_name' => $su['full_name'] ?? '', + 'ttl_minutes' => (string) (int) (self::OTP_TTL_SECONDS / 60), + ], $committenteName); + } catch (Throwable $e) { + error_log('[SUPPLIER_PORTAL] invio OTP fallito: ' . $e->getMessage()); + } + + $this->auditSupplier((int) $su['organization_id'], 'supplier_otp_requested', 'supplier_user', + (int) $su['id'], ['supplier_id' => (int) $su['supplier_id']]); + $this->jsonSuccess(['delivered' => true], $opaqueMsg); + } + + /** POST /api/supplier-portal/verify-otp Body: {email,code} | {magic_token}. */ + public function verifyOtp(): void + { + $ip = $this->getClientIPLocal(); + RateLimitService::check('sp_verify_ip:' . $ip, self::RL_VERIFY); + RateLimitService::increment('sp_verify_ip:' . $ip); + + $magicToken = trim((string) $this->getParam('magic_token', '')); + if ($magicToken !== '') { + $otpRow = $this->resolveOtpByMagic($magicToken); + } else { + $email = strtolower(trim((string) $this->getParam('email', ''))); + $code = preg_replace('/\D+/', '', (string) $this->getParam('code', '')); + if ($email === '' || $code === '') { + $this->jsonError('Inserisci email e codice di accesso.', 422, 'MISSING_CREDENTIALS'); + } + $otpRow = $this->resolveOtpByCode($email, $code); + } + + $su = Database::fetchOne( + 'SELECT su.id, su.supplier_id, su.organization_id, su.email, su.full_name + FROM supplier_users su JOIN suppliers s ON s.id = su.supplier_id + WHERE su.id = ? AND su.is_active = 1 AND s.deleted_at IS NULL', + [(int) $otpRow['supplier_user_id']] + ); + if (!$su) { + $this->jsonError('Accesso non disponibile. Contatta il committente.', 403, 'SUPPLIER_DISABLED'); + } + + // Consumo single-use atomico. + $consumed = Database::query('UPDATE supplier_otp SET consumed_at = NOW() WHERE id = ? AND consumed_at IS NULL', + [(int) $otpRow['id']]); + if ($consumed->rowCount() === 0) { + $this->jsonError('Questo codice e\' gia\' stato utilizzato. Richiedi un nuovo accesso.', 409, 'ALREADY_USED'); + } + + $jti = $this->uuidv4(); + $expiresAt = date('Y-m-d H:i:s', time() + self::SESSION_TTL_SECONDS); + Database::insert('supplier_sessions', [ + 'supplier_user_id' => (int) $su['id'], + 'organization_id' => (int) $su['organization_id'], + 'jti' => $jti, + 'ip_address' => substr($ip, 0, 45), + 'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500), + 'expires_at' => $expiresAt, + ]); + Database::update('supplier_users', ['last_login_at' => date('Y-m-d H:i:s')], 'id = ?', [(int) $su['id']]); + + $jwt = $this->issueSupplierJwt([ + 'sp_uid' => (int) $su['id'], 'supplier_id' => (int) $su['supplier_id'], + 'org_id' => (int) $su['organization_id'], 'jti' => $jti, + ]); + $this->auditSupplier((int) $su['organization_id'], 'supplier_login', 'supplier_user', (int) $su['id'], + ['supplier_id' => (int) $su['supplier_id'], 'method' => $magicToken !== '' ? 'magic' : 'otp']); + + $supplier = Database::fetchOne('SELECT name FROM suppliers WHERE id = ?', [(int) $su['supplier_id']]); + $this->jsonSuccess([ + 'token' => $jwt, 'expires_at' => $expiresAt, + 'supplier' => ['id' => (int) $su['supplier_id'], 'name' => $supplier['name'] ?? ''], + 'user' => ['id' => (int) $su['id'], 'email' => $su['email'], 'full_name' => $su['full_name']], + ], 'Accesso effettuato'); + } + + // ════════════════ ENDPOINT AUTENTICATI (sessione fornitore) ════════════════ + + /** GET /api/supplier-portal/me */ + public function me(): void + { + $this->requireSupplierSession(); + $supplierId = $this->supplierSession['supplier_id']; + $orgId = $this->supplierSession['org_id']; + + $supplier = Database::fetchOne( + 'SELECT id, name, service_type, criticality FROM suppliers WHERE id = ? AND organization_id = ? AND deleted_at IS NULL', + [$supplierId, $orgId] + ); + if (!$supplier) { + $this->jsonError('Fornitore non disponibile', 404, 'SUPPLIER_NOT_FOUND'); + } + $campaigns = Database::fetchAll( + "SELECT id, template_id, template_version, status, due_at, sent_at, completed_at, + expires_at, show_score_to_supplier, language, + CASE WHEN show_score_to_supplier = 1 THEN score ELSE NULL END AS score + FROM questionnaire_campaigns + WHERE supplier_id = ? AND organization_id = ? + AND status IN ('sent','in_progress','completed','expired') + ORDER BY (status IN ('sent','in_progress')) DESC, (due_at IS NULL), due_at ASC, sent_at DESC", + [$supplierId, $orgId] + ); + $committente = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [$orgId]); + + $this->jsonSuccess([ + 'supplier' => [ + 'id' => (int) $supplier['id'], 'name' => $supplier['name'], + 'service_type' => $supplier['service_type'], 'criticality' => $supplier['criticality'], + ], + 'committente' => ['name' => $committente['name'] ?? ''], + 'user' => [ + 'id' => $this->supplierSession['sp_uid'], 'email' => $this->supplierSession['email'], + 'full_name' => $this->supplierSession['full_name'], + ], + 'campaigns' => array_map(static fn(array $c): array => [ + 'id' => (int) $c['id'], 'status' => $c['status'], 'due_at' => $c['due_at'], + 'sent_at' => $c['sent_at'], 'completed_at' => $c['completed_at'], 'expires_at' => $c['expires_at'], + 'language' => $c['language'], 'show_score' => (bool) $c['show_score_to_supplier'], + 'score' => $c['score'] !== null ? (int) $c['score'] : null, + ], $campaigns), + ]); + } + + /** GET /api/supplier-portal/{id} — template+domande della campagna (+ risposte salvate). */ + public function getQuestionnaire(int $id): void + { + $this->requireSupplierSession(); + $campaign = $this->loadOwnedCampaign($id); + $supplier = Database::fetchOne('SELECT name, criticality FROM suppliers WHERE id = ?', [$campaign['supplier_id']]); + $highCrit = in_array(strtolower((string) ($supplier['criticality'] ?? '')), ['high', 'critical'], true); + + $questions = $this->loadCampaignQuestions($campaign, $highCrit); + $answers = Database::fetchAll( + 'SELECT question_id, question_code, answer_value, answer_text, file_ref + FROM questionnaire_answers WHERE campaign_id = ? AND organization_id = ?', + [$campaign['id'], $campaign['organization_id']] + ); + $answersByQid = []; + foreach ($answers as $a) { + $key = $a['question_id'] !== null ? (string) (int) $a['question_id'] : ('code:' . $a['question_code']); + $answersByQid[$key] = [ + 'value' => $a['answer_value'] !== null ? json_decode($a['answer_value'], true) : null, + 'text' => $a['answer_text'], 'file_ref' => $a['file_ref'], + ]; + } + $committente = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [$campaign['organization_id']]); + + $this->jsonSuccess([ + 'campaign' => [ + 'id' => (int) $campaign['id'], 'status' => $campaign['status'], 'due_at' => $campaign['due_at'], + 'expires_at' => $campaign['expires_at'], 'language' => $campaign['language'], + 'show_score' => (bool) $campaign['show_score_to_supplier'], + ], + 'committente' => ['name' => $committente['name'] ?? ''], + 'supplier' => ['name' => $supplier['name'] ?? ''], + 'questions' => $questions, 'answers' => $answersByQid, + ]); + } + + /** PATCH /api/supplier-portal/{id}/answers — autosave per-domanda. */ + public function saveAnswers(int $id): void + { + $this->requireSupplierSession(); + $campaign = $this->loadOwnedCampaign($id); + if ($campaign['status'] === 'completed') { + $this->jsonError('Questionario gia\' inviato: non e\' piu\' modificabile.', 409, 'ALREADY_SUBMITTED'); + } + $this->assertCampaignOpen($campaign); + + $body = $this->getJsonBody(); + $answers = is_array($body['answers'] ?? null) ? $body['answers'] : []; + if (empty($answers)) { + $this->jsonError('Nessuna risposta da salvare', 422, 'NO_ANSWERS'); + } + $validQ = $this->validQuestionIds($campaign); + $saved = 0; + foreach ($answers as $a) { + if (!is_array($a)) continue; + $qid = isset($a['question_id']) ? (int) $a['question_id'] : null; + $code = isset($a['question_code']) ? (string) $a['question_code'] : null; + if ($qid !== null && !isset($validQ[$qid])) continue; + if ($qid === null && $code === null) continue; + $valueJson = array_key_exists('value', $a) && $a['value'] !== null ? json_encode($a['value'], JSON_UNESCAPED_UNICODE) : null; + $this->upsertAnswer((int) $campaign['id'], (int) $campaign['organization_id'], $qid, $code, + $valueJson, isset($a['text']) ? (string) $a['text'] : null, + isset($a['file_ref']) ? substr((string) $a['file_ref'], 0, 255) : null, $this->supplierSession['sp_uid']); + $saved++; + } + if ($campaign['status'] === 'sent') { + Database::query("UPDATE questionnaire_campaigns SET status = 'in_progress' WHERE id = ? AND organization_id = ? AND status = 'sent'", + [(int) $campaign['id'], (int) $campaign['organization_id']]); + } + $this->jsonSuccess(['saved' => $saved, 'status' => 'in_progress', 'saved_at' => date('c')], 'Bozza salvata'); + } + + /** POST /api/supplier-portal/{id}/submit — invio definitivo + scoring. */ + public function submitQuestionnaire(int $id): void + { + $this->requireSupplierSession(); + $campaign = $this->loadOwnedCampaign($id); + if ($campaign['status'] === 'completed') { + $this->jsonError('Questionario gia\' inviato.', 409, 'ALREADY_SUBMITTED'); + } + $this->assertCampaignOpen($campaign); + + $body = $this->getJsonBody(); + $answers = is_array($body['answers'] ?? null) ? $body['answers'] : []; + $supplier = Database::fetchOne('SELECT criticality FROM suppliers WHERE id = ?', [$campaign['supplier_id']]); + $highCrit = in_array(strtolower((string) ($supplier['criticality'] ?? '')), ['high', 'critical'], true); + $questions = $this->loadCampaignQuestions($campaign, $highCrit); + + Database::beginTransaction(); + try { + $validQ = $this->validQuestionIds($campaign); + foreach ($answers as $a) { + if (!is_array($a)) continue; + $qid = isset($a['question_id']) ? (int) $a['question_id'] : null; + $code = isset($a['question_code']) ? (string) $a['question_code'] : null; + if ($qid !== null && !isset($validQ[$qid])) continue; + if ($qid === null && $code === null) continue; + $valueJson = array_key_exists('value', $a) && $a['value'] !== null ? json_encode($a['value'], JSON_UNESCAPED_UNICODE) : null; + $this->upsertAnswer((int) $campaign['id'], (int) $campaign['organization_id'], $qid, $code, + $valueJson, isset($a['text']) ? (string) $a['text'] : null, + isset($a['file_ref']) ? substr((string) $a['file_ref'], 0, 255) : null, $this->supplierSession['sp_uid']); + } + $stored = Database::fetchAll( + 'SELECT question_id, question_code, answer_value, answer_text FROM questionnaire_answers WHERE campaign_id = ? AND organization_id = ?', + [(int) $campaign['id'], (int) $campaign['organization_id']] + ); + $storedByQid = []; + foreach ($stored as $s) { + if ($s['question_id'] !== null) $storedByQid[(int) $s['question_id']] = $s; + elseif ($s['question_code'] !== null) $storedByQid['code:' . $s['question_code']] = $s; + } + $missing = []; + foreach ($questions as $q) { + if (!$q['is_required']) continue; + $stKey = $q['id'] !== null ? (int) $q['id'] : ('code:' . $q['code']); + if (!$this->answerIsPresent($storedByQid[$stKey] ?? null)) $missing[] = $q['code'] ?? (string) $q['id']; + } + if (!empty($missing)) { + Database::rollback(); + $this->jsonError('Mancano risposte obbligatorie.', 422, 'REQUIRED_MISSING', ['missing' => $missing]); + } + [$score, $vulnerabilities] = $this->computeScore($questions, $storedByQid, $highCrit); + $riskLevel = $this->riskLevelFromScore($score); + $upd = Database::query( + "UPDATE questionnaire_campaigns SET status='completed', score=?, risk_level=?, answers=?, completed_at=NOW() + WHERE id = ? AND organization_id = ? AND status <> 'completed'", + [$score, $riskLevel, json_encode(['vulnerabilities' => $vulnerabilities], JSON_UNESCAPED_UNICODE), + (int) $campaign['id'], (int) $campaign['organization_id']] + ); + if ($upd->rowCount() === 0) { + Database::rollback(); + $this->jsonError('Questionario gia\' inviato.', 409, 'ALREADY_SUBMITTED'); + } + Database::query( + 'UPDATE suppliers SET risk_score = ?, security_requirements_met = ?, last_assessment_date = CURDATE() WHERE id = ? AND organization_id = ?', + [$score, $score >= 70 ? 1 : 0, (int) $campaign['supplier_id'], (int) $campaign['organization_id']] + ); + Database::commit(); + } catch (Throwable $e) { + if (Database::getInstance()->inTransaction()) Database::rollback(); + throw $e; + } + + $this->auditSupplier((int) $campaign['organization_id'], 'supplier_questionnaire_submitted', 'questionnaire_campaign', + (int) $campaign['id'], ['supplier_id' => (int) $campaign['supplier_id'], 'score' => $score, 'risk_level' => $riskLevel]); + + $payload = ['completed_at' => date('c'), 'show_score' => (bool) $campaign['show_score_to_supplier']]; + if ($campaign['show_score_to_supplier']) { $payload['score'] = $score; $payload['risk_level'] = $riskLevel; } + $this->jsonSuccess($payload, 'Questionario inviato. Grazie.'); + } + + // ════════════════ AUTH SEPARATA ════════════════ + + private function requireSupplierSession(): void + { + $token = $this->getBearerToken(); + if (!$token) $this->jsonError('Sessione mancante. Richiedi un nuovo accesso.', 401, 'MISSING_TOKEN'); + $payload = $this->verifySupplierJwt($token); + if (!$payload) $this->jsonError('Sessione non valida o scaduta. Richiedi un nuovo accesso.', 401, 'INVALID_TOKEN'); + + $jti = $payload['jti'] ?? null; + $spUid = isset($payload['sp_uid']) ? (int) $payload['sp_uid'] : 0; + if (!$jti || $spUid <= 0) $this->jsonError('Sessione non valida.', 401, 'INVALID_SESSION'); + + $session = Database::fetchOne( + 'SELECT ss.id, ss.supplier_user_id, ss.organization_id, su.supplier_id, su.email, su.full_name, su.is_active + FROM supplier_sessions ss JOIN supplier_users su ON su.id = ss.supplier_user_id + WHERE ss.jti = ? AND ss.supplier_user_id = ? AND ss.revoked_at IS NULL AND ss.expires_at > NOW()', + [$jti, $spUid] + ); + if (!$session || (int) $session['is_active'] !== 1) { + $this->jsonError('Sessione non valida o revocata. Richiedi un nuovo accesso.', 401, 'SESSION_REVOKED'); + } + if ((int) $payload['supplier_id'] !== (int) $session['supplier_id'] || (int) $payload['org_id'] !== (int) $session['organization_id']) { + $this->jsonError('Sessione incoerente. Richiedi un nuovo accesso.', 401, 'SESSION_MISMATCH'); + } + $this->supplierSession = [ + 'jti' => $jti, 'sp_uid' => (int) $session['supplier_user_id'], + 'supplier_id' => (int) $session['supplier_id'], 'org_id' => (int) $session['organization_id'], + 'email' => $session['email'], 'full_name' => $session['full_name'], + ]; + } + + private function issueSupplierJwt(array $claims): string + { + $header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']); + $payload = json_encode(array_merge([ + 'aud' => 'supplier-portal', 'iss' => APP_URL, 'iat' => time(), 'exp' => time() + self::SESSION_TTL_SECONDS, + ], $claims), JSON_UNESCAPED_UNICODE); + $h = $this->base64UrlEncode($header); + $p = $this->base64UrlEncode($payload); + $s = $this->base64UrlEncode(hash_hmac('sha256', "$h.$p", $this->supplierJwtSecret(), true)); + return "$h.$p.$s"; + } + + private function verifySupplierJwt(string $token): ?array + { + $parts = explode('.', $token); + if (count($parts) !== 3) return null; + [$h, $p, $sig] = $parts; + $expected = hash_hmac('sha256', "$h.$p", $this->supplierJwtSecret(), true); + if (!hash_equals($expected, $this->base64UrlDecode($sig))) return null; + $payload = json_decode($this->base64UrlDecode($p), true); + if (!is_array($payload)) return null; + if (($payload['aud'] ?? null) !== 'supplier-portal') return null; + if (isset($payload['exp']) && (int) $payload['exp'] < time()) return null; + if (isset($payload['user_id'])) return null; // token interno → rifiuto netto + return $payload; + } + + private function supplierJwtSecret(): string + { + if (defined('SUPPLIER_JWT_SECRET') && SUPPLIER_JWT_SECRET !== '') return SUPPLIER_JWT_SECRET; + return hash_hmac('sha256', 'supplier-portal-v1', JWT_SECRET); + } + + // ════════════════ HELPER OTP ════════════════ + + private function resolveOtpByCode(string $email, string $code): array + { + $su = Database::fetchOne('SELECT id FROM supplier_users WHERE email = ? AND is_active = 1 ORDER BY id DESC LIMIT 1', [$email]); + if (!$su) $this->jsonError('Codice non valido o scaduto. Richiedi un nuovo accesso.', 401, 'OTP_INVALID'); + + $otp = Database::fetchOne( + "SELECT * FROM supplier_otp WHERE supplier_user_id = ? AND purpose = 'login' AND otp_hash IS NOT NULL AND consumed_at IS NULL ORDER BY id DESC LIMIT 1", + [(int) $su['id']] + ); + if (!$otp) $this->jsonError('Codice non valido o scaduto. Richiedi un nuovo accesso.', 401, 'OTP_INVALID'); + $this->assertOtpUsable($otp); + + if (!hash_equals((string) $otp['otp_hash'], hash('sha256', $code))) { + $attempts = (int) $otp['attempts'] + 1; + $fields = ['attempts' => $attempts]; + $remaining = max(0, (int) $otp['max_attempts'] - $attempts); + if ($attempts >= (int) $otp['max_attempts']) { + $fields['locked_until'] = date('Y-m-d H:i:s', time() + self::OTP_LOCKOUT_SECONDS); + } + Database::update('supplier_otp', $fields, 'id = ?', [(int) $otp['id']]); + if ($remaining <= 0) { + $this->jsonError('Troppi tentativi. Accesso bloccato per ' . (int) (self::OTP_LOCKOUT_SECONDS / 60) . ' minuti.', 429, 'OTP_LOCKED'); + } + $this->jsonError('Codice errato. Hai ancora ' . $remaining . ' tentativi.', 401, 'OTP_WRONG', ['remaining' => $remaining]); + } + return $otp; + } + + private function resolveOtpByMagic(string $magicToken): array + { + if (!preg_match('/^sp_[a-f0-9]{64}$/', $magicToken)) { + $this->jsonError('Link non valido o scaduto. Richiedi un nuovo accesso.', 401, 'MAGIC_INVALID'); + } + $otp = Database::fetchOne( + "SELECT * FROM supplier_otp WHERE magic_token_hash = ? AND purpose = 'login' AND consumed_at IS NULL ORDER BY id DESC LIMIT 1", + [hash('sha256', $magicToken)] + ); + if (!$otp) $this->jsonError('Link non valido o gia\' utilizzato. Richiedi un nuovo accesso.', 401, 'MAGIC_INVALID'); + $this->assertOtpUsable($otp); + return $otp; + } + + private function assertOtpUsable(array $otp): void + { + if (!empty($otp['locked_until']) && strtotime($otp['locked_until']) > time()) { + $minutes = max(1, (int) ceil((strtotime($otp['locked_until']) - time()) / 60)); + $this->jsonError('Accesso temporaneamente bloccato. Riprova tra ' . $minutes . ' minuti.', 429, 'OTP_LOCKED'); + } + if (strtotime((string) $otp['expires_at']) < time()) { + $this->jsonError('Codice scaduto. Richiedi un nuovo accesso.', 401, 'OTP_EXPIRED'); + } + } + + private function generateNumericOtp(int $digits): string + { + $max = (10 ** $digits) - 1; + return str_pad((string) random_int(0, $max), $digits, '0', STR_PAD_LEFT); + } + + // ════════════════ HELPER CAMPAGNA / SCORING ════════════════ + + private function loadOwnedCampaign(int $id): array + { + $campaign = Database::fetchOne( + 'SELECT * FROM questionnaire_campaigns WHERE id = ? AND supplier_id = ? AND organization_id = ?', + [$id, $this->supplierSession['supplier_id'], $this->supplierSession['org_id']] + ); + if (!$campaign) $this->jsonError('Questionario non trovato', 404, 'CAMPAIGN_NOT_FOUND'); + return $campaign; + } + + private function assertCampaignOpen(array $campaign): void + { + if (in_array($campaign['status'], ['cancelled', 'draft', 'expired'], true)) { + $this->jsonError('Questionario non disponibile alla compilazione.', 409, 'CAMPAIGN_CLOSED'); + } + if (!empty($campaign['expires_at']) && strtotime((string) $campaign['expires_at']) < time()) { + Database::query("UPDATE questionnaire_campaigns SET status='expired' WHERE id = ? AND status NOT IN ('completed','cancelled')", [(int) $campaign['id']]); + $this->jsonError('Il termine per la compilazione e\' scaduto.', 410, 'CAMPAIGN_EXPIRED'); + } + } + + private function loadCampaignQuestions(array $campaign, bool $highCrit): array + { + $raw = []; + if ($campaign['template_id'] !== null && $campaign['template_version'] !== null) { + $snap = Database::fetchOne( + 'SELECT questions_snapshot FROM questionnaire_template_versions WHERE template_id = ? AND version = ? AND organization_id = ?', + [(int) $campaign['template_id'], $campaign['template_version'], (int) $campaign['organization_id']] + ); + if ($snap && !empty($snap['questions_snapshot'])) { + $decoded = json_decode($snap['questions_snapshot'], true); + if (is_array($decoded)) $raw = $decoded; + } + } + if (empty($raw) && $campaign['template_id'] !== null) { + $raw = Database::fetchAll( + 'SELECT id, question_code AS code, question_text AS text, question_type AS type, options, weight, is_required, order_index, nis2_ref, vuln_flag, high_criticality_only + FROM questionnaire_questions WHERE template_id = ? AND organization_id = ? ORDER BY order_index, id', + [(int) $campaign['template_id'], (int) $campaign['organization_id']] + ); + } + $out = []; + foreach ($raw as $q) { + if ((bool) ($q['high_criticality_only'] ?? false) && !$highCrit) continue; + $opts = $q['options'] ?? null; + if (is_string($opts)) $opts = json_decode($opts, true); + $out[] = [ + 'id' => isset($q['id']) ? (int) $q['id'] : null, + 'code' => $q['code'] ?? ($q['question_code'] ?? null), + 'text' => (string) ($q['text'] ?? $q['question_text'] ?? ''), + 'type' => (string) ($q['type'] ?? $q['question_type'] ?? 'yes_no_partial'), + 'options' => is_array($opts) ? $opts : null, + 'weight' => (float) ($q['weight'] ?? 1.0), + 'is_required' => (bool) ($q['is_required'] ?? $q['required'] ?? true), + 'nis2_ref' => $q['nis2_ref'] ?? null, + 'vuln_flag' => $q['vuln_flag'] ?? null, + 'help_text' => $q['help_text'] ?? null, + ]; + } + return $out; + } + + private function validQuestionIds(array $campaign): array + { + if ($campaign['template_id'] === null) return []; + $rows = Database::fetchAll('SELECT id FROM questionnaire_questions WHERE template_id = ? AND organization_id = ?', + [(int) $campaign['template_id'], (int) $campaign['organization_id']]); + $map = []; + foreach ($rows as $r) $map[(int) $r['id']] = true; + return $map; + } + + private function upsertAnswer(int $campaignId, int $orgId, ?int $questionId, ?string $questionCode, ?string $valueJson, ?string $text, ?string $fileRef, int $answeredBy): void + { + if ($questionId !== null) { + Database::query( + 'INSERT INTO questionnaire_answers (campaign_id, organization_id, question_id, question_code, answer_value, answer_text, file_ref, answered_by, answered_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE answer_value=VALUES(answer_value), answer_text=VALUES(answer_text), file_ref=VALUES(file_ref), answered_by=VALUES(answered_by), answered_at=NOW()', + [$campaignId, $orgId, $questionId, $questionCode, $valueJson, $text, $fileRef, $answeredBy] + ); + return; + } + $existing = $questionCode !== null ? Database::fetchOne( + 'SELECT id FROM questionnaire_answers WHERE campaign_id = ? AND organization_id = ? AND question_id IS NULL AND question_code = ? LIMIT 1', + [$campaignId, $orgId, $questionCode]) : null; + if ($existing) { + Database::update('questionnaire_answers', [ + 'answer_value' => $valueJson, 'answer_text' => $text, 'file_ref' => $fileRef, + 'answered_by' => $answeredBy, 'answered_at' => date('Y-m-d H:i:s'), + ], 'id = ?', [(int) $existing['id']]); + } else { + Database::insert('questionnaire_answers', [ + 'campaign_id' => $campaignId, 'organization_id' => $orgId, 'question_id' => null, + 'question_code' => $questionCode, 'answer_value' => $valueJson, 'answer_text' => $text, + 'file_ref' => $fileRef, 'answered_by' => $answeredBy, + ]); + } + } + + private function answerIsPresent(?array $row): bool + { + if ($row === null) return false; + if (!empty($row['answer_text'])) return true; + if (!empty($row['file_ref'])) return true; + if ($row['answer_value'] !== null) { + $decoded = json_decode((string) $row['answer_value'], true); + if (is_array($decoded)) return count($decoded) > 0; + return $decoded !== null && $decoded !== ''; + } + return false; + } + + private function computeScore(array $questions, array $storedByQid, bool $highCrit): array + { + $earned = 0.0; $maxScore = 0.0; $vulns = []; + $rtoThreshold = $highCrit ? 8 : 24; + $highExposure = ['privileg', 'amministr', 'critic', 'segret', 'sensibil', 'personal']; + + foreach ($questions as $q) { + $key = $q['id'] !== null ? (int) $q['id'] : ('code:' . $q['code']); + $value = $this->decodeAnswerValue($storedByQid[$key] ?? null); + $weight = max(0.0, (float) $q['weight']); + switch ($q['type']) { + case 'yes_no_partial': + $maxScore += $weight; + $v = is_string($value) ? strtolower($value) : ''; + $factor = match ($v) { 'yes', 'si', 'sì' => 1.0, 'partial', 'parziale' => 0.5, default => 0.0 }; + $earned += $weight * $factor; + if ($factor < 1.0 && !empty($q['vuln_flag'])) $vulns[] = $q['vuln_flag']; + break; + case 'scale_1_5': + $maxScore += $weight; + $n = $this->scaleToInt($value); + if ($n !== null && $n >= 1 && $n <= 5) { + $earned += $weight * (($n - 1) / 4); + if ($n <= 2 && !empty($q['vuln_flag'])) $vulns[] = $q['vuln_flag']; + } elseif (!empty($q['vuln_flag'])) { $vulns[] = $q['vuln_flag']; } + break; + case 'number': + $num = is_numeric($value) ? (float) $value : null; + if (($num === null || $num > $rtoThreshold) && !empty($q['vuln_flag'])) $vulns[] = $q['vuln_flag']; + break; + case 'single_choice': + case 'multi_choice': + $picks = is_array($value) ? $value : ($value !== null ? [$value] : []); + foreach ($picks as $pick) { + $p = strtolower((string) $pick); + foreach ($highExposure as $needle) { + if (str_contains($p, $needle)) { if (!empty($q['vuln_flag'])) $vulns[] = $q['vuln_flag']; break 2; } + } + } + break; + } + } + $score = $maxScore > 0.0 ? (int) round($earned / $maxScore * 100) : 0; + return [$score, array_values(array_unique($vulns))]; + } + + private function decodeAnswerValue(?array $row) + { + if ($row === null) return null; + if ($row['answer_value'] !== null) { + $decoded = json_decode((string) $row['answer_value'], true); + if ($decoded !== null) return $decoded; + } + return $row['answer_text'] ?? null; + } + + private function scaleToInt($value): ?int + { + if (is_int($value)) return $value; + if (is_numeric($value)) return (int) $value; + if (is_string($value) && preg_match('/^\s*([1-5])/', $value, $m)) return (int) $m[1]; + return null; + } + + private function riskLevelFromScore(int $score): string + { + return $score >= 80 ? 'low' : ($score >= 60 ? 'medium' : ($score >= 40 ? 'high' : 'critical')); + } + + // ════════════════ UTILITY ════════════════ + + private function getClientIPLocal(): string + { + $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''; + if ($xff !== '') { + $first = trim(explode(',', $xff)[0]); + if (filter_var($first, FILTER_VALIDATE_IP)) return $first; + } + return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; + } + + private function uuidv4(): 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 auditSupplier(int $orgId, string $action, string $entityType, ?int $entityId, array $details): void + { + try { + AuditService::log($orgId, null, $action, $entityType, $entityId, $details, + $this->getClientIPLocal(), $_SERVER['HTTP_USER_AGENT'] ?? null, + AuditService::resolveSeverity($action, $details), null); + } catch (Throwable $e) { + error_log('[SUPPLIER_PORTAL] audit fallito: ' . $e->getMessage()); + } + } +} diff --git a/public/index.php b/public/index.php index ca7a352..9b17708 100644 --- a/public/index.php +++ b/public/index.php @@ -110,6 +110,7 @@ $controllerMap = [ 'knowledgebase' => 'KnowledgeBaseController', // KB multi-livello (Migration 012-014) 'ai' => 'AiController', // Assistente AI ARIA (askWithRag + fonti certe) 'branding' => 'BrandingController', // White-label firm (Fase 5 / G16) + 'supplier-portal' => 'SupplierPortalController', // Portale fornitore Fase 3 (auth OTP/magic-link separata) ]; if (!isset($controllerMap[$controllerName])) { @@ -301,6 +302,18 @@ $actionMap = [ 'DELETE:{id}' => 'delete', ], + // ── SupplierPortalController — portale fornitore (auth OTP/magic-link, SEPARATA da users) ── + 'supplier-portal' => [ + 'POST:requestOtp' => 'requestOtp', // NO AUTH (opaco) + 'POST:request-otp' => 'requestOtp', + 'POST:verifyOtp' => 'verifyOtp', // NO AUTH (apre sessione) + 'POST:verify-otp' => 'verifyOtp', + 'GET:me' => 'me', // sessione fornitore + 'GET:{id}' => 'getQuestionnaire', + 'POST:{id}/submit' => 'submitQuestionnaire', + 'PATCH:{id}/answers' => 'saveAnswers', // autosave + ], + // ── TrainingController ────────────────────────── 'training' => [ 'GET:courses' => 'listCourses',