nis2-agile/application/controllers/AuthController.php
DevEnv nis2-agile c134a2d52a [FIX] Auth CRITICI da test multi-agente: register senza jti + revoca sessione singola
CRITICO #2 — register() generava il token SENZA jti, ma requireAuth lo rifiuta
(JWT_NO_JTI): l'utente appena registrato veniva sbattuto fuori al primo
getMe/completeOnboarding e doveva rifare login. Ora register crea una riga
active_sessions con jti e genera access+refresh token col jti, come login().

CRITICO #1 — DELETE /auth/sessions/<jti> (revoca sessione singola) tornava 404:
il jti è esadecimale (non numerico), il router cadeva nel ramo "nome composto"
e generava solo {action}/{camelResource}, mai {action}/{id}. Aggiunto fallback
{action}/{id} con id passato come STRINGA (revokeSession(string $id) lo accetta).
Il candidato composito resta primo, quindi evidence/upload ecc. non si rompono.

php -l OK su entrambi. version 1.10.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:01:22 +02:00

1050 lines
42 KiB
PHP

<?php
/**
* NIS2 Agile - Auth Controller
*
* Gestisce registrazione, login, JWT tokens, profilo utente.
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/RateLimitService.php';
require_once APP_PATH . '/services/SsoHelper.php';
class AuthController extends BaseController
{
/**
* Restituisce l'IP reale del client, gestendo proxy/nginx.
*/
private function getClientIP(): string
{
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
// Fidati di X-Forwarded-For solo se la richiesta arriva da localhost (nginx proxy)
if (in_array($remoteAddr, ['127.0.0.1', '::1', 'unknown'])
&& !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$xForwardedFor = $_SERVER['HTTP_X_FORWARDED_FOR'];
$ips = array_map('trim', explode(',', $xForwardedFor));
$firstIp = filter_var($ips[0], FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
if ($firstIp !== false) {
return $firstIp;
}
}
return $remoteAddr;
}
/**
* POST /api/auth/register
*/
public function register(): void
{
// Rate limiting
$ip = $this->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,
]);
// --- Sessione tracciata (jti) come nel login: requireAuth rifiuta i token
// senza jti, quindi senza questo l'utente appena registrato verrebbe
// sbattuto fuori al primo getMe/completeOnboarding (401 JWT_NO_JTI). ---
$jti = bin2hex(random_bytes(16));
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$ip = $this->getClientIP();
Database::insert('active_sessions', [
'id' => $jti,
'user_id' => (int) $userId,
'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 (con jti, come login)
$accessToken = $this->generateJWT($userId, ['jti' => $jti]);
$refreshToken = $this->generateRefreshToken($userId, $jti);
// 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)');
}
}