[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 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-05-31 17:40:20 +02:00
parent 261fc4cdd5
commit de09af6d7e
2 changed files with 719 additions and 0 deletions

View File

@ -0,0 +1,706 @@
<?php
/**
* NIS2 Agile - Supplier Portal Controller (Fase 3)
* ----------------------------------------------------------------------------
* Portale fornitore ad accesso PASSWORDLESS (OTP 8 cifre + magic-link), con
* autenticazione COMPLETAMENTE SEPARATA dagli utenti interni:
* supplier_users / supplier_otp / supplier_sessions (MAI users/active_sessions).
*
* Sicurezza (CRITICI design §3 + UX_MINI_SPEC §3):
* 1) JWT con SUPPLIER_JWT_SECRET dedicato, aud="supplier-portal", claim sp_uid+
* supplier_id+org_id, MAI user_id. requireSupplierSession() verifica il jti in
* supplier_sessions (revocabile), NON tocca users e NON riusa verifyJWT().
* 2) OTP 8 cifre, SHA-256, 15min, max 5 tentativi con lockout PERSISTENTE,
* invalida OTP precedenti, hash_equals, rate-limit email+IP.
* 3) Magic-link 32 byte hashed, single-use, consumo solo su verify esplicito.
* 4) request-otp risposta OPACA (anti-enumerazione).
* 5) OTP via EmailService::sendViaTemplate (/api/emails/send, NON send-raw -> 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());
}
}
}

View File

@ -110,6 +110,7 @@ $controllerMap = [
'knowledgebase' => 'KnowledgeBaseController', // KB multi-livello (Migration 012-014) 'knowledgebase' => 'KnowledgeBaseController', // KB multi-livello (Migration 012-014)
'ai' => 'AiController', // Assistente AI ARIA (askWithRag + fonti certe) 'ai' => 'AiController', // Assistente AI ARIA (askWithRag + fonti certe)
'branding' => 'BrandingController', // White-label firm (Fase 5 / G16) 'branding' => 'BrandingController', // White-label firm (Fase 5 / G16)
'supplier-portal' => 'SupplierPortalController', // Portale fornitore Fase 3 (auth OTP/magic-link separata)
]; ];
if (!isset($controllerMap[$controllerName])) { if (!isset($controllerMap[$controllerName])) {
@ -301,6 +302,18 @@ $actionMap = [
'DELETE:{id}' => 'delete', '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 ────────────────────────── // ── TrainingController ──────────────────────────
'training' => [ 'training' => [
'GET:courses' => 'listCourses', 'GET:courses' => 'listCourses',