[FEAT] Allineamento NIS2 ↔ TRPG (Fasi 1-5): SSO + Sessions + Reset + Impersonate + Branding
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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c37423f900
commit
e4f9e9179e
@@ -7,6 +7,7 @@
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
require_once APP_PATH . '/services/RateLimitService.php';
|
||||
require_once APP_PATH . '/services/SsoHelper.php';
|
||||
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
@@ -132,8 +133,66 @@ class AuthController extends BaseController
|
||||
[$email]
|
||||
);
|
||||
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
$this->jsonError('Credenziali non valide', 401, 'INVALID_CREDENTIALS');
|
||||
// --- SSO Federation (Fase 1 / G02) ---
|
||||
// In SSO_MODE=local (default) tutto questo blocco è no-op: SsoHelper::login()
|
||||
// ritorna null e $ssoAuthorized resta false → cade nel fallback locale invariato.
|
||||
$ssoAuthorized = false;
|
||||
$sso = new SsoHelper();
|
||||
if (!$sso->isLocalOnly()) {
|
||||
$ssoResp = $sso->login($email, $password, 'nis2');
|
||||
if ($ssoResp === null) {
|
||||
// SSO unreachable
|
||||
if ($sso->isSsoOnly()) {
|
||||
$this->jsonError('SSO non disponibile, riprovare', 503, 'SSO_UNAVAILABLE');
|
||||
}
|
||||
// dual: fallback locale (procedi al password_verify sotto)
|
||||
} else {
|
||||
$httpStatus = (int) ($ssoResp['_httpStatus'] ?? 0);
|
||||
if ($httpStatus === 200) {
|
||||
$ssoIdentityId = (int) ($ssoResp['identity_id'] ?? $ssoResp['user']['sso_identity_id'] ?? 0);
|
||||
$ssoPasswordVersion = (int) ($ssoResp['password_version'] ?? 1);
|
||||
if (!$user) {
|
||||
// Utente SSO non ancora presente localmente: crealo
|
||||
$ssoUser = $ssoResp['user'] ?? [];
|
||||
$newId = Database::insert('users', [
|
||||
'email' => $email,
|
||||
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||
'full_name' => $ssoUser['full_name'] ?? $email,
|
||||
'role' => 'employee',
|
||||
'sso_identity_id' => $ssoIdentityId,
|
||||
'password_version' => $ssoPasswordVersion,
|
||||
'is_active' => 1,
|
||||
]);
|
||||
$user = Database::fetchOne('SELECT * FROM users WHERE id = ?', [$newId]);
|
||||
} else {
|
||||
// Utente esistente: link/sync se necessario (lazy backfill — decisione 2)
|
||||
$updates = [];
|
||||
if ((int) ($user['sso_identity_id'] ?? 0) !== $ssoIdentityId) {
|
||||
$updates['sso_identity_id'] = $ssoIdentityId;
|
||||
}
|
||||
if ((int) ($user['password_version'] ?? 0) !== $ssoPasswordVersion) {
|
||||
$updates['password_hash'] = password_hash($password, PASSWORD_DEFAULT);
|
||||
$updates['password_version'] = $ssoPasswordVersion;
|
||||
}
|
||||
if ($updates) {
|
||||
Database::update('users', $updates, 'id = ?', [$user['id']]);
|
||||
$user = Database::fetchOne('SELECT * FROM users WHERE id = ?', [$user['id']]);
|
||||
}
|
||||
}
|
||||
$ssoAuthorized = true;
|
||||
} elseif ($httpStatus === 401 && $sso->isSsoOnly()) {
|
||||
$this->jsonError('Credenziali non valide', 401, 'INVALID_CREDENTIALS');
|
||||
} elseif ($sso->isSsoOnly()) {
|
||||
$this->jsonError('Errore SSO', 502, 'SSO_ERROR');
|
||||
}
|
||||
// dual + non-200: cade nel fallback locale
|
||||
}
|
||||
}
|
||||
|
||||
if (!$ssoAuthorized) {
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
$this->jsonError('Credenziali non valide', 401, 'INVALID_CREDENTIALS');
|
||||
}
|
||||
}
|
||||
|
||||
// Aggiorna ultimo login
|
||||
@@ -141,9 +200,22 @@ class AuthController extends BaseController
|
||||
'last_login_at' => date('Y-m-d H:i:s'),
|
||||
], 'id = ?', [$user['id']]);
|
||||
|
||||
// Genera tokens
|
||||
$accessToken = $this->generateJWT((int) $user['id']);
|
||||
$refreshToken = $this->generateRefreshToken((int) $user['id']);
|
||||
// --- Multi-device session tracking (Fase 2 / G05-G06) ---
|
||||
$jti = bin2hex(random_bytes(16));
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
Database::insert('active_sessions', [
|
||||
'id' => $jti,
|
||||
'user_id' => (int) $user['id'],
|
||||
'organization_id'=> null,
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => substr($ua, 0, 512),
|
||||
'device_label' => $this->parseDeviceLabel($ua),
|
||||
'expires_at' => date('Y-m-d H:i:s', time() + JWT_REFRESH_EXPIRES_IN),
|
||||
]);
|
||||
|
||||
// Genera tokens (jti come claim JWT + FK su refresh_tokens.session_jti)
|
||||
$accessToken = $this->generateJWT((int) $user['id'], ['jti' => $jti]);
|
||||
$refreshToken = $this->generateRefreshToken((int) $user['id'], $jti);
|
||||
|
||||
// Carica organizzazioni
|
||||
$organizations = Database::fetchAll(
|
||||
@@ -179,8 +251,19 @@ class AuthController extends BaseController
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
// Invalida tutti i refresh token dell'utente
|
||||
Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]);
|
||||
// --- Revoca selettiva della sessione corrente (Fase 2 / G06) ---
|
||||
$jti = $this->currentUser['session_jti'] ?? null;
|
||||
if ($jti) {
|
||||
Database::query(
|
||||
"UPDATE active_sessions SET revoked_at = NOW(), revoked_reason = 'logout'
|
||||
WHERE id = ? AND revoked_at IS NULL",
|
||||
[$jti]
|
||||
);
|
||||
Database::delete('refresh_tokens', 'session_jti = ?', [$jti]);
|
||||
} else {
|
||||
// Fallback (non dovrebbe accadere — requireAuth ora pretende jti)
|
||||
Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]);
|
||||
}
|
||||
|
||||
$this->logAudit('user_logout', 'user', $this->getCurrentUserId());
|
||||
|
||||
@@ -211,13 +294,34 @@ class AuthController extends BaseController
|
||||
$this->jsonError('Refresh token non valido o scaduto', 401, 'INVALID_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
$userId = (int) $tokenRecord['user_id'];
|
||||
$jti = $tokenRecord['session_jti'] ?? null;
|
||||
|
||||
// Verifica che la sessione associata sia ancora valida (Fase 2 / G06)
|
||||
if ($jti) {
|
||||
$session = Database::fetchOne(
|
||||
'SELECT id FROM active_sessions
|
||||
WHERE id = ? AND revoked_at IS NULL AND expires_at > NOW()',
|
||||
[$jti]
|
||||
);
|
||||
if (!$session) {
|
||||
Database::rollback();
|
||||
$this->jsonError('Sessione revocata, login richiesto', 401, 'SESSION_REVOKED');
|
||||
}
|
||||
// Touch last_activity
|
||||
Database::update('active_sessions',
|
||||
['last_activity_at' => date('Y-m-d H:i:s')],
|
||||
'id = ?',
|
||||
[$jti]
|
||||
);
|
||||
}
|
||||
|
||||
// Elimina vecchio token atomicamente
|
||||
Database::delete('refresh_tokens', 'id = ?', [$tokenRecord['id']]);
|
||||
|
||||
// Genera nuovi tokens
|
||||
$userId = (int) $tokenRecord['user_id'];
|
||||
$accessToken = $this->generateJWT($userId);
|
||||
$newRefreshToken = $this->generateRefreshToken($userId);
|
||||
// Genera nuovi tokens preservando il jti della sessione
|
||||
$accessToken = $this->generateJWT($userId, $jti ? ['jti' => $jti] : []);
|
||||
$newRefreshToken = $this->generateRefreshToken($userId, $jti);
|
||||
|
||||
Database::commit();
|
||||
} catch (Throwable $e) {
|
||||
@@ -319,16 +423,74 @@ class AuthController extends BaseController
|
||||
$this->jsonError(implode('. ', $errors), 400, 'WEAK_PASSWORD');
|
||||
}
|
||||
|
||||
// --- SSO change-password propagation (Fase 1 / G03) ---
|
||||
// Solo per utenti federati (sso_identity_id != NULL) e SSO_MODE != local.
|
||||
// Se SSO 200 → procediamo con cambio locale.
|
||||
// Se SSO error → blocchiamo: niente cambio locale, manteniamo consistency.
|
||||
// Se SSO unreachable in dual → log e procediamo localmente (sync verrà via cron).
|
||||
$sso = new SsoHelper();
|
||||
$isFederated = !empty($this->currentUser['sso_identity_id']);
|
||||
$newPasswordVersion = (int) ($this->currentUser['password_version'] ?? 1) + 1;
|
||||
|
||||
if ($isFederated && !$sso->isLocalOnly()) {
|
||||
$jwt = $this->getBearerToken() ?? '';
|
||||
$ssoResp = $sso->changePassword($jwt, $currentPassword, $newPassword);
|
||||
if ($ssoResp === null) {
|
||||
// unreachable
|
||||
if ($sso->isSsoOnly()) {
|
||||
$this->jsonError('SSO non disponibile, riprovare', 503, 'SSO_UNAVAILABLE');
|
||||
}
|
||||
// dual: log e procedi locale (cron riallineerà appena SSO torna)
|
||||
error_log("[SSO] changePassword unreachable for user {$this->getCurrentUserId()} (dual mode → local only)");
|
||||
} else {
|
||||
$httpStatus = (int) ($ssoResp['_httpStatus'] ?? 0);
|
||||
if ($httpStatus !== 200) {
|
||||
$msg = $ssoResp['error'] ?? $ssoResp['message'] ?? 'Errore SSO change-password';
|
||||
$this->jsonError($msg, $httpStatus ?: 502, 'SSO_CHANGE_PWD_FAILED');
|
||||
}
|
||||
// 200: usa la versione restituita da SSO se disponibile
|
||||
if (isset($ssoResp['password_version'])) {
|
||||
$newPasswordVersion = (int) $ssoResp['password_version'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Database::update('users', [
|
||||
'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT),
|
||||
'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT),
|
||||
'password_version' => $newPasswordVersion,
|
||||
], 'id = ?', [$this->getCurrentUserId()]);
|
||||
|
||||
// Invalida tutti i refresh token (force re-login)
|
||||
Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]);
|
||||
// --- Revoca altre sessioni, mantieni quella corrente (Fase 2 / G06) ---
|
||||
$currentJti = $this->currentUser['session_jti'] ?? null;
|
||||
$userId = $this->getCurrentUserId();
|
||||
if ($currentJti) {
|
||||
Database::query(
|
||||
"UPDATE active_sessions
|
||||
SET revoked_at = NOW(), revoked_reason = 'password_change'
|
||||
WHERE user_id = ? AND id != ? AND revoked_at IS NULL",
|
||||
[$userId, $currentJti]
|
||||
);
|
||||
Database::query(
|
||||
"DELETE FROM refresh_tokens WHERE user_id = ? AND (session_jti IS NULL OR session_jti != ?)",
|
||||
[$userId, $currentJti]
|
||||
);
|
||||
} else {
|
||||
// Nessun jti corrente → revoca tutto (comportamento pre-Fase2)
|
||||
Database::query(
|
||||
"UPDATE active_sessions
|
||||
SET revoked_at = NOW(), revoked_reason = 'password_change'
|
||||
WHERE user_id = ? AND revoked_at IS NULL",
|
||||
[$userId]
|
||||
);
|
||||
Database::delete('refresh_tokens', 'user_id = ?', [$userId]);
|
||||
}
|
||||
|
||||
$this->logAudit('password_changed', 'user', $this->getCurrentUserId());
|
||||
$this->logAudit('password_changed', 'user', $this->getCurrentUserId(), [
|
||||
'federated' => $isFederated,
|
||||
'password_version' => $newPasswordVersion,
|
||||
]);
|
||||
|
||||
$this->jsonSuccess(null, 'Password modificata. Effettua nuovamente il login.');
|
||||
$this->jsonSuccess(null, 'Password modificata. Le altre sessioni sono state disconnesse.');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -374,4 +536,498 @@ class AuthController extends BaseController
|
||||
'recipient' => $recipient, // dati pre-compilazione form (null se non presenti)
|
||||
], 'Invito valido');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PREFERENCES — Tema / timezone / notifiche (Fase 4 / G12)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/auth/preferences
|
||||
*/
|
||||
public function getPreferences(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$u = $this->currentUser;
|
||||
$this->jsonSuccess([
|
||||
'preferred_language' => $u['preferred_language'] ?? 'it',
|
||||
'theme' => $u['theme'] ?? 'auto',
|
||||
'timezone' => $u['timezone'] ?? 'Europe/Rome',
|
||||
'notif_email' => (bool) ($u['notif_email'] ?? 1),
|
||||
'notif_inapp' => (bool) ($u['notif_inapp'] ?? 1),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/auth/preferences
|
||||
* Body: { theme?, timezone?, preferred_language?, notif_email?, notif_inapp? }
|
||||
*/
|
||||
public function updatePreferences(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$updates = [];
|
||||
|
||||
if ($this->hasParam('preferred_language')) {
|
||||
$lang = $this->getParam('preferred_language');
|
||||
if (!in_array($lang, ['it','en','fr','de'], true)) {
|
||||
$this->jsonError('Lingua non supportata', 400, 'INVALID_LANGUAGE');
|
||||
}
|
||||
$updates['preferred_language'] = $lang;
|
||||
}
|
||||
if ($this->hasParam('theme')) {
|
||||
$theme = $this->getParam('theme');
|
||||
if (!in_array($theme, ['light','dark','auto'], true)) {
|
||||
$this->jsonError('Tema non valido', 400, 'INVALID_THEME');
|
||||
}
|
||||
$updates['theme'] = $theme;
|
||||
}
|
||||
if ($this->hasParam('timezone')) {
|
||||
$tz = $this->getParam('timezone');
|
||||
// Validation minimale: max 64 chars, formato IANA-like (Region/City)
|
||||
if (!preg_match('#^[A-Za-z_]+(?:/[A-Za-z_+\-0-9]+)*$#', $tz) || strlen($tz) > 64) {
|
||||
$this->jsonError('Timezone non valida', 400, 'INVALID_TIMEZONE');
|
||||
}
|
||||
$updates['timezone'] = $tz;
|
||||
}
|
||||
if ($this->hasParam('notif_email')) {
|
||||
$updates['notif_email'] = $this->getParam('notif_email') ? 1 : 0;
|
||||
}
|
||||
if ($this->hasParam('notif_inapp')) {
|
||||
$updates['notif_inapp'] = $this->getParam('notif_inapp') ? 1 : 0;
|
||||
}
|
||||
if (empty($updates)) {
|
||||
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
|
||||
}
|
||||
|
||||
Database::update('users', $updates, 'id = ?', [$this->getCurrentUserId()]);
|
||||
$this->logAudit('preferences_updated', 'user', $this->getCurrentUserId(), $updates);
|
||||
|
||||
$this->jsonSuccess($updates, 'Preferenze aggiornate');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// IMPERSONATE — Super admin / Consulente entra come altro utente (Fase 4 / G11)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* POST /api/auth/impersonate
|
||||
* Body: { user_id }
|
||||
* Emette un JWT speciale per "loggarsi come" l'utente target.
|
||||
* - Permesso solo a super_admin (qualunque target) o consulente verso utenti delle org del suo firm.
|
||||
* - JWT TTL ridotto (~1h, no refresh) + claim `impersonated_by` per audit.
|
||||
* - Sessione corrente del super_admin/consulente NON viene revocata.
|
||||
*/
|
||||
public function impersonate(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->validateRequired(['user_id']);
|
||||
|
||||
$targetId = (int) $this->getParam('user_id');
|
||||
$actorId = $this->getCurrentUserId();
|
||||
$actorRole = $this->currentUser['role'];
|
||||
|
||||
if ($targetId === $actorId) {
|
||||
$this->jsonError('Non puoi impersonare te stesso', 400, 'SELF_IMPERSONATE');
|
||||
}
|
||||
|
||||
$target = Database::fetchOne(
|
||||
'SELECT id, email, full_name, role, is_active, consulting_firm_id
|
||||
FROM users WHERE id = ?',
|
||||
[$targetId]
|
||||
);
|
||||
if (!$target || (int) $target['is_active'] !== 1) {
|
||||
$this->jsonError('Utente target non trovato o disabilitato', 404, 'TARGET_NOT_FOUND');
|
||||
}
|
||||
if ($target['role'] === 'super_admin' && $actorRole !== 'super_admin') {
|
||||
$this->jsonError('Non puoi impersonare un super admin', 403, 'IMPERSONATE_FORBIDDEN');
|
||||
}
|
||||
|
||||
// Permessi: super_admin OR consulente sullo stesso firm
|
||||
$allowed = false;
|
||||
if ($actorRole === 'super_admin') {
|
||||
$allowed = true;
|
||||
} elseif ($actorRole === 'consultant') {
|
||||
$actorFirmId = (int) ($this->currentUser['consulting_firm_id'] ?? 0);
|
||||
$targetFirmId = (int) ($target['consulting_firm_id'] ?? 0);
|
||||
if ($actorFirmId > 0 && $actorFirmId === $targetFirmId) {
|
||||
$allowed = true;
|
||||
}
|
||||
// In alternativa: target è org_admin/employee di una org del consulente.
|
||||
// Per ora copertura minimale: stesso firm.
|
||||
}
|
||||
if (!$allowed) {
|
||||
$this->jsonError('Permessi insufficienti per impersonare questo utente', 403, 'IMPERSONATE_FORBIDDEN');
|
||||
}
|
||||
|
||||
// Nuova sessione tracciata (durata ridotta: 1h, niente refresh persistente)
|
||||
$jti = bin2hex(random_bytes(16));
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
Database::insert('active_sessions', [
|
||||
'id' => $jti,
|
||||
'user_id' => $targetId,
|
||||
'organization_id'=> null,
|
||||
'ip_address' => $this->getClientIP(),
|
||||
'user_agent' => substr($ua, 0, 512),
|
||||
'device_label' => 'Impersonato da #' . $actorId,
|
||||
'expires_at' => date('Y-m-d H:i:s', time() + 3600),
|
||||
]);
|
||||
|
||||
$accessToken = $this->generateJWT($targetId, [
|
||||
'jti' => $jti,
|
||||
'impersonated_by' => $actorId,
|
||||
'exp' => time() + 3600,
|
||||
]);
|
||||
|
||||
$this->logAudit('user_impersonated', 'user', $targetId, [
|
||||
'actor_id' => $actorId,
|
||||
'actor_role' => $actorRole,
|
||||
'duration_seconds' => 3600,
|
||||
]);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'user' => [
|
||||
'id' => (int) $target['id'],
|
||||
'email' => $target['email'],
|
||||
'full_name' => $target['full_name'],
|
||||
'role' => $target['role'],
|
||||
],
|
||||
'access_token' => $accessToken,
|
||||
'expires_in' => 3600,
|
||||
'impersonated' => true,
|
||||
], 'Impersonate attivo per 1 ora');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// CONTEXT SWITCH — Cambio organization attiva (Fase 3 / G09)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* POST /api/auth/switch-context
|
||||
* Body: { organization_id }
|
||||
* Verifica membership, revoca la sessione corrente con reason='context_switch',
|
||||
* crea una nuova sessione e ritorna nuovo JWT + refresh con organization_id come claim.
|
||||
*/
|
||||
public function switchContext(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$this->validateRequired(['organization_id']);
|
||||
|
||||
$targetOrgId = (int) $this->getParam('organization_id');
|
||||
$userId = $this->getCurrentUserId();
|
||||
$oldJti = $this->currentUser['session_jti'] ?? null;
|
||||
|
||||
// Super admin bypass membership check
|
||||
if ($this->currentUser['role'] !== 'super_admin') {
|
||||
$membership = Database::fetchOne(
|
||||
'SELECT role FROM user_organizations WHERE user_id = ? AND organization_id = ?',
|
||||
[$userId, $targetOrgId]
|
||||
);
|
||||
if (!$membership) {
|
||||
$this->jsonError('Non sei membro di questa organizzazione', 403, 'NOT_MEMBER');
|
||||
}
|
||||
}
|
||||
|
||||
$org = Database::fetchOne(
|
||||
'SELECT id, name FROM organizations WHERE id = ? AND is_active = 1',
|
||||
[$targetOrgId]
|
||||
);
|
||||
if (!$org) {
|
||||
$this->jsonError('Organizzazione non trovata o disabilitata', 404, 'ORG_NOT_FOUND');
|
||||
}
|
||||
|
||||
Database::beginTransaction();
|
||||
try {
|
||||
// Revoca sessione corrente
|
||||
if ($oldJti) {
|
||||
Database::query(
|
||||
"UPDATE active_sessions
|
||||
SET revoked_at = NOW(), revoked_reason = 'context_switch'
|
||||
WHERE id = ? AND revoked_at IS NULL",
|
||||
[$oldJti]
|
||||
);
|
||||
Database::delete('refresh_tokens', 'session_jti = ?', [$oldJti]);
|
||||
}
|
||||
|
||||
// Nuova sessione con organization_id valorizzato
|
||||
$newJti = bin2hex(random_bytes(16));
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
Database::insert('active_sessions', [
|
||||
'id' => $newJti,
|
||||
'user_id' => $userId,
|
||||
'organization_id' => $targetOrgId,
|
||||
'ip_address' => $this->getClientIP(),
|
||||
'user_agent' => substr($ua, 0, 512),
|
||||
'device_label' => $this->parseDeviceLabel($ua),
|
||||
'expires_at' => date('Y-m-d H:i:s', time() + JWT_REFRESH_EXPIRES_IN),
|
||||
]);
|
||||
|
||||
$accessToken = $this->generateJWT($userId, [
|
||||
'jti' => $newJti,
|
||||
'organization_id' => $targetOrgId,
|
||||
]);
|
||||
$refreshToken = $this->generateRefreshToken($userId, $newJti);
|
||||
|
||||
Database::commit();
|
||||
} catch (Throwable $e) {
|
||||
Database::rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->logAudit('context_switched', 'organization', $targetOrgId);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'organization' => [
|
||||
'id' => (int) $org['id'],
|
||||
'name' => $org['name'],
|
||||
],
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_in' => JWT_EXPIRES_IN,
|
||||
], 'Contesto cambiato');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PASSWORD RESET — Forgot/Reset (Fase 3 / G08)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* POST /api/auth/forgot-password
|
||||
* Body: { email }
|
||||
* Risposta SEMPRE 200 + messaggio generico (anti enumeration).
|
||||
*/
|
||||
public function forgotPassword(): void
|
||||
{
|
||||
$this->validateRequired(['email']);
|
||||
$email = strtolower(trim($this->getParam('email')));
|
||||
$ip = $this->getClientIP();
|
||||
|
||||
// Rate limit per IP+email combinati (anti spam)
|
||||
$rlKey = 'forgot:' . $ip . ':' . md5($email);
|
||||
RateLimitService::check($rlKey, RATE_LIMIT_AUTH_FORGOT);
|
||||
RateLimitService::increment($rlKey);
|
||||
|
||||
$user = Database::fetchOne(
|
||||
'SELECT id, email, full_name FROM users WHERE email = ? AND is_active = 1',
|
||||
[$email]
|
||||
);
|
||||
|
||||
// Risposta opaca: anche se l'email non esiste rispondiamo come se ok
|
||||
$okMessage = 'Se l\'indirizzo email è registrato, riceverai a breve un link per reimpostare la password.';
|
||||
|
||||
if ($user) {
|
||||
// Invalida eventuali token precedenti non usati
|
||||
Database::query(
|
||||
'UPDATE password_reset_tokens SET used_at = NOW()
|
||||
WHERE user_id = ? AND used_at IS NULL',
|
||||
[$user['id']]
|
||||
);
|
||||
|
||||
// Genera token (in chiaro inviato via mail, hash sul DB)
|
||||
$token = bin2hex(random_bytes(32));
|
||||
Database::insert('password_reset_tokens', [
|
||||
'user_id' => (int) $user['id'],
|
||||
'token_hash' => hash('sha256', $token),
|
||||
'expires_at' => date('Y-m-d H:i:s', time() + PASSWORD_RESET_TTL_SECONDS),
|
||||
'ip_address' => $ip,
|
||||
]);
|
||||
|
||||
// Invia email
|
||||
try {
|
||||
require_once APP_PATH . '/services/EmailService.php';
|
||||
$email_service = new EmailService();
|
||||
$email_service->sendPasswordReset($user, $token, PASSWORD_RESET_TTL_SECONDS);
|
||||
} catch (Throwable $e) {
|
||||
error_log("[forgot-password] sendPasswordReset failed for user {$user['id']}: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$this->logAudit('password_reset_requested', 'user', (int) $user['id']);
|
||||
}
|
||||
|
||||
$this->jsonSuccess(null, $okMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/reset-password
|
||||
* Body: { token, new_password }
|
||||
*/
|
||||
public function resetPassword(): void
|
||||
{
|
||||
$this->validateRequired(['token', 'new_password']);
|
||||
$token = $this->getParam('token');
|
||||
$newPassword = $this->getParam('new_password');
|
||||
|
||||
$errors = $this->validatePassword($newPassword);
|
||||
if (!empty($errors)) {
|
||||
$this->jsonError(implode('. ', $errors), 400, 'WEAK_PASSWORD');
|
||||
}
|
||||
|
||||
$hash = hash('sha256', $token);
|
||||
$row = Database::fetchOne(
|
||||
'SELECT prt.id, prt.user_id, prt.expires_at, prt.used_at, u.email, u.sso_identity_id
|
||||
FROM password_reset_tokens prt
|
||||
JOIN users u ON u.id = prt.user_id
|
||||
WHERE prt.token_hash = ?',
|
||||
[$hash]
|
||||
);
|
||||
|
||||
if (!$row) {
|
||||
$this->jsonError('Token non valido', 400, 'INVALID_TOKEN');
|
||||
}
|
||||
if ($row['used_at']) {
|
||||
$this->jsonError('Token già utilizzato', 400, 'TOKEN_USED');
|
||||
}
|
||||
if (strtotime($row['expires_at']) < time()) {
|
||||
$this->jsonError('Token scaduto. Richiedere un nuovo link.', 400, 'TOKEN_EXPIRED');
|
||||
}
|
||||
|
||||
$userId = (int) $row['user_id'];
|
||||
$isFederated = !empty($row['sso_identity_id']);
|
||||
$newPasswordVersion = 2; // bump da 1 base; ci aggiorneremo dal cron sync
|
||||
|
||||
Database::beginTransaction();
|
||||
try {
|
||||
// Consume token
|
||||
Database::update('password_reset_tokens',
|
||||
['used_at' => date('Y-m-d H:i:s')],
|
||||
'id = ?',
|
||||
[$row['id']]
|
||||
);
|
||||
|
||||
// Update password
|
||||
Database::update('users', [
|
||||
'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT),
|
||||
'password_version' => $newPasswordVersion,
|
||||
], 'id = ?', [$userId]);
|
||||
|
||||
// Revoca TUTTE le sessioni (no sessione corrente = reset da non-loggato)
|
||||
Database::query(
|
||||
"UPDATE active_sessions
|
||||
SET revoked_at = NOW(), revoked_reason = 'password_change'
|
||||
WHERE user_id = ? AND revoked_at IS NULL",
|
||||
[$userId]
|
||||
);
|
||||
Database::delete('refresh_tokens', 'user_id = ?', [$userId]);
|
||||
|
||||
Database::commit();
|
||||
} catch (Throwable $e) {
|
||||
Database::rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->logAudit('password_reset_completed', 'user', $userId, [
|
||||
'federated_user' => $isFederated,
|
||||
]);
|
||||
|
||||
// Nota: per utenti federati (sso_identity_id != NULL) il cambio è solo locale.
|
||||
// Il cron `sso-password-sync` (richiesto via AgileHub Ticket #220) potrebbe
|
||||
// riallineare la password lato SSO al prossimo run.
|
||||
|
||||
$this->jsonSuccess(null, 'Password reimpostata. Puoi accedere con la nuova password.');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// SESSIONS — Multi-device management (Fase 2 / G06)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/auth/sessions
|
||||
* Lista le sessioni attive dell'utente corrente.
|
||||
*/
|
||||
public function listSessions(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$userId = $this->getCurrentUserId();
|
||||
$currentJti = $this->currentUser['session_jti'] ?? null;
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, ip_address, device_label, created_at, last_activity_at, expires_at
|
||||
FROM active_sessions
|
||||
WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY last_activity_at DESC',
|
||||
[$userId]
|
||||
);
|
||||
|
||||
$sessions = array_map(function ($r) use ($currentJti) {
|
||||
return [
|
||||
'id' => $r['id'],
|
||||
'ip_address' => $r['ip_address'],
|
||||
'device_label' => $r['device_label'] ?? 'Browser',
|
||||
'created_at' => $r['created_at'],
|
||||
'last_activity_at' => $r['last_activity_at'],
|
||||
'expires_at' => $r['expires_at'],
|
||||
'is_current' => $r['id'] === $currentJti,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
$this->jsonSuccess(['sessions' => $sessions]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/auth/sessions/{id}
|
||||
* Revoca una sessione specifica. Se id == sessione corrente equivale a logout.
|
||||
*/
|
||||
public function revokeSession(string $id): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$userId = $this->getCurrentUserId();
|
||||
$currentJti = $this->currentUser['session_jti'] ?? null;
|
||||
|
||||
$row = Database::fetchOne(
|
||||
'SELECT id FROM active_sessions
|
||||
WHERE id = ? AND user_id = ? AND revoked_at IS NULL',
|
||||
[$id, $userId]
|
||||
);
|
||||
if (!$row) {
|
||||
$this->jsonError('Sessione non trovata', 404, 'SESSION_NOT_FOUND');
|
||||
}
|
||||
|
||||
$reason = ($id === $currentJti) ? 'logout' : 'admin';
|
||||
Database::query(
|
||||
"UPDATE active_sessions SET revoked_at = NOW(), revoked_reason = ?
|
||||
WHERE id = ?",
|
||||
[$reason, $id]
|
||||
);
|
||||
Database::delete('refresh_tokens', 'session_jti = ?', [$id]);
|
||||
|
||||
$this->logAudit('session_revoked', 'session', null, [
|
||||
'session_id' => $id,
|
||||
'self' => ($id === $currentJti),
|
||||
]);
|
||||
|
||||
$this->jsonSuccess(null, 'Sessione revocata');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/auth/sessions
|
||||
* Revoca tutte le sessioni dell'utente tranne quella corrente.
|
||||
*/
|
||||
public function revokeAllSessions(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
$userId = $this->getCurrentUserId();
|
||||
$currentJti = $this->currentUser['session_jti'] ?? null;
|
||||
|
||||
if ($currentJti) {
|
||||
Database::query(
|
||||
"UPDATE active_sessions SET revoked_at = NOW(), revoked_reason = 'admin'
|
||||
WHERE user_id = ? AND id != ? AND revoked_at IS NULL",
|
||||
[$userId, $currentJti]
|
||||
);
|
||||
Database::query(
|
||||
"DELETE FROM refresh_tokens
|
||||
WHERE user_id = ? AND (session_jti IS NULL OR session_jti != ?)",
|
||||
[$userId, $currentJti]
|
||||
);
|
||||
} else {
|
||||
Database::query(
|
||||
"UPDATE active_sessions SET revoked_at = NOW(), revoked_reason = 'admin'
|
||||
WHERE user_id = ? AND revoked_at IS NULL",
|
||||
[$userId]
|
||||
);
|
||||
Database::delete('refresh_tokens', 'user_id = ?', [$userId]);
|
||||
}
|
||||
|
||||
$this->logAudit('sessions_revoked_all', 'user', $userId);
|
||||
|
||||
$this->jsonSuccess(null, 'Sessioni revocate (tranne questa)');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user