Implementazione completa del progetto allineamento alla suite Evix (TRPG/lg231),
basato sul doc canonico docs/GAP_TRPG_NIS2_ALIGNMENT.md (5 fasi, 18 gap).
Version 1.0.0 → 1.5.0
Fase 1 — SSO Federation (v1.1.0)
- Migration 015_sso_columns: users.sso_identity_id + password_version
- application/services/SsoHelper.php (client SSO dual-mode, cURL nativo, zero deps)
- AuthController::login() + changePassword() conditional SSO (SSO_MODE=local default)
Fase 2 — Multi-device Sessions (v1.2.0)
- Migration 016_active_sessions: tabella + refresh_tokens.session_jti
- BaseController::requireAuth() verifica jti + last_activity throttle + parseDeviceLabel
- login() genera jti, logout/changePassword revoca selettiva
- GET/DELETE /auth/sessions[/{id}]
- UI settings.html tab Sicurezza con lista device + revoca
Fase 3 — Password Reset + Tenant Switcher (v1.3.0)
- Migration 017_password_reset_tokens (TTL 30min, single-use)
- POST /auth/forgot-password (risposta opaca) + reset-password
- Pagine forgot-password.html + reset-password.html (con strength bar)
- EmailService::sendPasswordReset
- POST /auth/switchContext con rotazione JWT + organization_id claim
- Dropdown tenant in sidebar esposto a tutti gli utenti con ≥2 org
Fase 4 — Impersonate + Preferences + Versioning UI (v1.4.0)
- POST /auth/impersonate (super_admin o consulente stesso firm, TTL 1h, audit)
- Migration 018_user_preferences: users.theme/timezone/notif_email/notif_inapp
- GET/PUT /auth/preferences
- Sidebar footer mostra versione + changelog modal su click
Fase 5 — Branding white-label + Auth-gate (v1.5.0)
- Migration 019_firm_branding (logo/colori/brand_name per consulting firm)
- BrandingController GET /branding/current (auth opzionale) + PUT
- common.js auto-applica CSS variables al boot
- public/js/auth-gate.js (gate password client-side per docs riservati, da TRPG)
Skip motivati:
- G15 demo login: simulator esistenti coprono
- G18 refactor controllers: rinviato (~5gg, valore tecnico solo)
Cron sync SSO: AgileHub Ticket #220 aperto a team AGILEHUB per estendere
sso-password-sync.sh al DB nis2_agile_db. Prerequisito per switch SSO_MODE=dual.
Backup files: tutti i file modificati hanno .bak.pre-{fase}-{ts} sia in DEV
sia in /var/www/nis2-agile/.backups/ su Hetzner (rollback ready).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
177 lines
6.0 KiB
PHP
177 lines
6.0 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile — SsoHelper
|
|
*
|
|
* Client SSO leggero per integrazione con AgileHub Tenant MS (http://172.18.0.1:4214).
|
|
* Usa cURL nativo, zero dipendenze Composer.
|
|
*
|
|
* Adattato 2026-05-29 da TRPG /var/www/trpg-agile/shared/SsoHelper.php
|
|
* (Progetto allineamento NIS2 ↔ TRPG — Fase 1 / G02).
|
|
*
|
|
* Workaround PHP-FPM Alpine + musl:
|
|
* getenv() può ritornare false in worker FPM → leggi anche da $_SERVER / $_ENV,
|
|
* poi cadi su default hardcoded (stesso pattern usato in VectorService/EmbedService).
|
|
*
|
|
* Modalità (SSO_MODE):
|
|
* - local: nessuna chiamata SSO, login solo locale (default sicuro)
|
|
* - dual: prova SSO, fallback locale se Tenant MS unreachable
|
|
* - sso_only: solo SSO, nessun fallback (massima centralizzazione)
|
|
*
|
|
* Configurazione .env:
|
|
* SSO_ENDPOINT=http://172.18.0.1:4214
|
|
* SSO_TIMEOUT_MS=3000
|
|
* SSO_MODE=local
|
|
* SSO_INTERNAL_KEY=<da vault-steward>
|
|
*
|
|
* Convenzioni di ritorno:
|
|
* - null → SSO non raggiungibile (timeout/connessione) → caller deve fare fallback
|
|
* - array → risposta JSON parsata (include '_httpStatus' int)
|
|
* caller deve controllare ['_httpStatus'] e ['error'] per gestire 401/422/ecc.
|
|
*/
|
|
class SsoHelper
|
|
{
|
|
private string $endpoint;
|
|
private int $timeoutMs;
|
|
private string $mode;
|
|
private string $internalKey;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->endpoint = rtrim(
|
|
self::env('SSO_ENDPOINT', 'http://172.18.0.1:4214'),
|
|
'/'
|
|
);
|
|
$this->timeoutMs = (int) self::env('SSO_TIMEOUT_MS', '3000');
|
|
$this->mode = self::env('SSO_MODE', 'local');
|
|
$this->internalKey = self::env('SSO_INTERNAL_KEY', '');
|
|
}
|
|
|
|
/** Multi-source env lookup (PHP-FPM Alpine workaround). */
|
|
private static function env(string $key, string $default): string
|
|
{
|
|
$v = getenv($key);
|
|
if ($v !== false && $v !== '') return $v;
|
|
if (!empty($_SERVER[$key])) return (string) $_SERVER[$key];
|
|
if (!empty($_ENV[$key])) return (string) $_ENV[$key];
|
|
return $default;
|
|
}
|
|
|
|
public function getMode(): string { return $this->mode; }
|
|
public function isLocalOnly(): bool { return $this->mode === 'local'; }
|
|
public function isSsoOnly(): bool { return $this->mode === 'sso_only'; }
|
|
public function isDual(): bool { return $this->mode === 'dual'; }
|
|
|
|
/**
|
|
* Login SSO.
|
|
* @return array|null null = unreachable (fallback locale), array = risposta SSO
|
|
*/
|
|
public function login(string $email, string $password, string $product = 'nis2'): ?array
|
|
{
|
|
if ($this->isLocalOnly()) return null;
|
|
return $this->post('/auth/sso/login', [
|
|
'email' => $email,
|
|
'password' => $password,
|
|
'product' => $product,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Cambio password SSO (richiede JWT Bearer utente).
|
|
*/
|
|
public function changePassword(string $jwt, string $currentPassword, string $newPassword): ?array
|
|
{
|
|
if ($this->isLocalOnly()) return null;
|
|
return $this->post('/auth/sso/change-password', [
|
|
'currentPassword' => $currentPassword,
|
|
'newPassword' => $newPassword,
|
|
], $jwt);
|
|
}
|
|
|
|
/**
|
|
* Verifica password senza emettere JWT (uso interno — server-to-server).
|
|
*/
|
|
public function verifyPassword(string $email, string $password): ?array
|
|
{
|
|
if ($this->isLocalOnly()) return null;
|
|
return $this->postInternal('/auth/sso/verify-password', [
|
|
'email' => $email,
|
|
'password' => $password,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Health check: SSO raggiungibile entro 1s?
|
|
*/
|
|
public function isAvailable(): bool
|
|
{
|
|
$ch = curl_init($this->endpoint . '/health');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT_MS => 1000,
|
|
CURLOPT_CONNECTTIMEOUT_MS => 1000,
|
|
]);
|
|
curl_exec($ch);
|
|
$ok = curl_errno($ch) === 0
|
|
&& (int) curl_getinfo($ch, CURLINFO_HTTP_CODE) === 200;
|
|
curl_close($ch);
|
|
return $ok;
|
|
}
|
|
|
|
// --- HTTP helpers ---
|
|
|
|
private function post(string $path, array $data, ?string $jwt = null): ?array
|
|
{
|
|
$headers = ['Content-Type: application/json'];
|
|
if ($jwt) {
|
|
$headers[] = 'Authorization: Bearer ' . preg_replace('/^Bearer\s+/i', '', $jwt);
|
|
}
|
|
|
|
$ch = curl_init($this->endpoint . $path);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => json_encode($data),
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT_MS => $this->timeoutMs,
|
|
CURLOPT_CONNECTTIMEOUT_MS => $this->timeoutMs,
|
|
CURLOPT_HTTPHEADER => $headers,
|
|
]);
|
|
$body = curl_exec($ch);
|
|
$errno = curl_errno($ch);
|
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($errno !== 0) return null;
|
|
|
|
$result = json_decode($body, true);
|
|
if (is_array($result)) {
|
|
$result['_httpStatus'] = $httpCode;
|
|
return $result;
|
|
}
|
|
return ['_httpStatus' => $httpCode, 'raw' => $body];
|
|
}
|
|
|
|
private function postInternal(string $path, array $data): ?array
|
|
{
|
|
if ($this->internalKey === '') return null;
|
|
|
|
$ch = curl_init($this->endpoint . $path);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => json_encode($data),
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT_MS => $this->timeoutMs,
|
|
CURLOPT_CONNECTTIMEOUT_MS => $this->timeoutMs,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Content-Type: application/json',
|
|
'X-Internal-Key: ' . $this->internalKey,
|
|
],
|
|
]);
|
|
$body = curl_exec($ch);
|
|
$errno = curl_errno($ch);
|
|
curl_close($ch);
|
|
|
|
if ($errno !== 0) return null;
|
|
return json_decode($body, true);
|
|
}
|
|
}
|