[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:
parent
261fc4cdd5
commit
de09af6d7e
706
application/controllers/SupplierPortalController.php
Normal file
706
application/controllers/SupplierPortalController.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user