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] ); 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']]); // Genera tokens $accessToken = $this->generateJWT((int) $user['id']); $refreshToken = $this->generateRefreshToken((int) $user['id']); // 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(); // Invalida tutti i refresh token dell'utente 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'); } // 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); 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'); } Database::update('users', [ 'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT), ], 'id = ?', [$this->getCurrentUserId()]); // Invalida tutti i refresh token (force re-login) Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]); $this->logAudit('password_changed', 'user', $this->getCurrentUserId()); $this->jsonSuccess(null, 'Password modificata. Effettua nuovamente il login.'); } /** * 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']; $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, ], 'Invito valido'); } }