getClientIP(); RateLimitService::check("register:{$ip}", RATE_LIMIT_AUTH_REGISTER); RateLimitService::increment("register:{$ip}"); // $ip defined above via getClientIP() $this->validateRequired(['email', 'password', 'full_name']); $email = strtolower(trim($this->getParam('email'))); $password = $this->getParam('password'); $fullName = trim($this->getParam('full_name')); $phone = $this->getParam('phone'); // Supporta sia `role` diretto (nuovo register.html) che `user_type` legacy $validRoles = ['super_admin', 'org_admin', 'compliance_manager', 'board_member', 'auditor', 'employee', 'consultant']; $roleParam = trim($this->getParam('role', '')); $userType = $this->getParam('user_type', 'azienda'); if ($roleParam && in_array($roleParam, $validRoles, true) && $roleParam !== 'super_admin') { $role = $roleParam; } elseif ($userType === 'consultant') { $role = 'consultant'; } else { $role = 'employee'; } // Validazione email if (!$this->validateEmail($email)) { $this->jsonError('Formato email non valido', 400, 'INVALID_EMAIL'); } // Validazione password $passwordErrors = $this->validatePassword($password); if (!empty($passwordErrors)) { $this->jsonError( implode('. ', $passwordErrors), 400, 'WEAK_PASSWORD' ); } // Verifica email duplicata $existing = Database::fetchOne('SELECT id FROM users WHERE email = ?', [$email]); if ($existing) { $this->jsonError('Email già registrata', 409, 'EMAIL_EXISTS'); } // Crea utente $userId = Database::insert('users', [ 'email' => $email, 'password_hash' => password_hash($password, PASSWORD_DEFAULT), 'full_name' => $fullName, 'phone' => $phone, 'role' => $role, 'is_active' => 1, ]); // Genera tokens $accessToken = $this->generateJWT($userId); $refreshToken = $this->generateRefreshToken($userId); // Audit log $this->currentUser = ['id' => $userId]; $this->logAudit('user_registered', 'user', $userId, ['user_type' => $userType]); $this->jsonSuccess([ 'user' => [ 'id' => $userId, 'email' => $email, 'full_name' => $fullName, 'role' => $role, ], 'access_token' => $accessToken, 'refresh_token' => $refreshToken, 'expires_in' => JWT_EXPIRES_IN, ], 'Registrazione completata', 201); } /** * POST /api/auth/login */ public function login(): void { // Rate limiting $ip = $this->getClientIP(); RateLimitService::check("login:{$ip}", RATE_LIMIT_AUTH_LOGIN); RateLimitService::increment("login:{$ip}"); $this->validateRequired(['email', 'password']); $email = strtolower(trim($this->getParam('email'))); $password = $this->getParam('password'); // Trova utente $user = Database::fetchOne( 'SELECT * FROM users WHERE email = ? AND is_active = 1', [$email] ); // --- 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 Database::update('users', [ 'last_login_at' => date('Y-m-d H:i:s'), ], 'id = ?', [$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( 'SELECT uo.organization_id, uo.role, uo.is_primary, o.name, o.sector, o.entity_type FROM user_organizations uo JOIN organizations o ON o.id = uo.organization_id WHERE uo.user_id = ? AND o.is_active = 1', [$user['id']] ); $this->currentUser = $user; $this->logAudit('user_login', 'user', (int) $user['id']); $this->jsonSuccess([ 'user' => [ 'id' => (int) $user['id'], 'email' => $user['email'], 'full_name' => $user['full_name'], 'role' => $user['role'], 'preferred_language' => $user['preferred_language'], ], 'organizations' => $organizations, 'access_token' => $accessToken, 'refresh_token' => $refreshToken, 'expires_in' => JWT_EXPIRES_IN, ], 'Login effettuato'); } /** * POST /api/auth/logout */ public function logout(): void { $this->requireAuth(); // --- 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()); $this->jsonSuccess(null, 'Logout effettuato'); } /** * POST /api/auth/refresh */ public function refresh(): void { $this->validateRequired(['refresh_token']); $refreshToken = $this->getParam('refresh_token'); $hashedToken = hash('sha256', $refreshToken); // Transazione atomica per evitare race condition (double-spend del refresh token) Database::beginTransaction(); try { // SELECT FOR UPDATE: blocca il record per tutta la transazione $tokenRecord = Database::fetchOne( 'SELECT * FROM refresh_tokens WHERE token = ? AND expires_at > NOW() FOR UPDATE', [$hashedToken] ); if (!$tokenRecord) { Database::rollback(); $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 preservando il jti della sessione $accessToken = $this->generateJWT($userId, $jti ? ['jti' => $jti] : []); $newRefreshToken = $this->generateRefreshToken($userId, $jti); Database::commit(); } catch (Throwable $e) { Database::rollback(); throw $e; } $this->jsonSuccess([ 'access_token' => $accessToken, 'refresh_token' => $newRefreshToken, 'expires_in' => JWT_EXPIRES_IN, ], 'Token rinnovato'); } /** * GET /api/auth/me */ public function me(): void { $this->requireAuth(); $user = $this->getCurrentUser(); // Carica organizzazioni $organizations = Database::fetchAll( 'SELECT uo.organization_id, uo.role as org_role, uo.is_primary, o.name, o.sector, o.entity_type, o.subscription_plan FROM user_organizations uo JOIN organizations o ON o.id = uo.organization_id WHERE uo.user_id = ? AND o.is_active = 1', [$user['id']] ); $this->jsonSuccess([ 'id' => (int) $user['id'], 'email' => $user['email'], 'full_name' => $user['full_name'], 'phone' => $user['phone'], 'role' => $user['role'], 'preferred_language' => $user['preferred_language'], 'last_login_at' => $user['last_login_at'], 'created_at' => $user['created_at'], 'organizations' => $organizations, ]); } /** * PUT /api/auth/profile */ public function updateProfile(): void { $this->requireAuth(); $updates = []; if ($this->hasParam('full_name')) { $updates['full_name'] = trim($this->getParam('full_name')); } if ($this->hasParam('phone')) { $updates['phone'] = $this->getParam('phone'); } if ($this->hasParam('preferred_language')) { $lang = $this->getParam('preferred_language'); if (in_array($lang, ['it', 'en', 'fr', 'de'])) { $updates['preferred_language'] = $lang; } } if (empty($updates)) { $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES'); } Database::update('users', $updates, 'id = ?', [$this->getCurrentUserId()]); $this->logAudit('profile_updated', 'user', $this->getCurrentUserId(), $updates); $this->jsonSuccess($updates, 'Profilo aggiornato'); } /** * POST /api/auth/change-password */ public function changePassword(): void { $this->requireAuth(); $this->validateRequired(['current_password', 'new_password']); $currentPassword = $this->getParam('current_password'); $newPassword = $this->getParam('new_password'); // Verifica password attuale if (!password_verify($currentPassword, $this->currentUser['password_hash'])) { $this->jsonError('Password attuale non corretta', 400, 'WRONG_PASSWORD'); } // Validazione nuova password $errors = $this->validatePassword($newPassword); if (!empty($errors)) { $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_version' => $newPasswordVersion, ], '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(), [ 'federated' => $isFederated, 'password_version' => $newPasswordVersion, ]); $this->jsonSuccess(null, 'Password modificata. Le altre sessioni sono state disconnesse.'); } /** * POST /api/auth/validate-invite * * Valida un codice invito B2B e restituisce piano e metadati. * Nessuna autenticazione richiesta — usato dalla pagina register.html * prima della registrazione per mostrare l'anteprima del piano. * * Body: { "invite_token": "inv_xxxx..." } * Response: { valid: true, plan: "professional", duration_months: 12, ... } */ public function validateInvite(): void { $token = trim($this->getParam('invite_token', '')); if (!$token) { $this->jsonError('invite_token mancante', 400, 'MISSING_TOKEN'); } require_once APP_PATH . '/controllers/InviteController.php'; $result = InviteController::resolveInvite($token); if (!$result['valid']) { $this->jsonError($result['error'], 422, $result['code'] ?? 'INVALID_INVITE'); } $inv = $result['invite']; $metadata = !empty($inv['metadata']) ? json_decode($inv['metadata'], true) : []; $recipient = $metadata['recipient'] ?? null; $this->jsonSuccess([ 'valid' => true, 'plan' => $inv['plan'], 'duration_months' => (int) $inv['duration_months'], 'expires_at' => $inv['expires_at'], 'remaining_uses' => (int)$inv['max_uses'] - (int)$inv['used_count'], 'channel' => $inv['channel'], 'label' => $inv['label'], 'restrict_vat' => $inv['restrict_vat'] ? true : false, 'restrict_email' => $inv['restrict_email'] ? true : false, '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)'); } }