lifetimeSeconds()); $this->rememberTokenRepository->create($userId, $selector, $tokenHash, $expiresAt); $this->setCookie($selector, $token); } public function autoLoginFromCookie(): bool { if (!empty($_SESSION['user']['id'])) { return false; } $value = $_COOKIE[$this->cookieName()] ?? ''; if ($value === '' || strpos($value, ':') === false) { return false; } [$selector, $token] = explode(':', $value, 2); $selector = trim($selector); $token = trim($token); if ($selector === '' || $token === '') { $this->clearCookie(); return false; } $record = $this->rememberTokenRepository->findBySelector($selector); if (!$record) { $this->clearCookie(); return false; } $expiresAt = (string) ($record['expires_at'] ?? ''); if ($expiresAt !== '' && strtotime($expiresAt . ' UTC') <= time()) { $this->rememberTokenRepository->deleteById((int) $record['id']); $this->clearCookie(); return false; } $hash = (string) ($record['token_hash'] ?? ''); if ($hash === '' || !hash_equals($hash, hash('sha256', $token))) { $this->rememberTokenRepository->deleteById((int) $record['id']); $this->clearCookie(); return false; } $userId = (int) ($record['user_id'] ?? 0); if ($userId <= 0) { $this->clearCookie(); return false; } $user = $this->userReadRepository->find($userId); if (!$user || empty($user['id']) || !($user['active'] ?? 1)) { $this->rememberTokenRepository->deleteById((int) $record['id']); $this->clearCookie(); return false; } Session::regenerate(); $_SESSION['user'] = $user; if (!empty($user['locale'])) { I18n::$locale = (string) $user['locale']; } $userId = (int) $user['id']; if ($userId > 0) { $this->permissionGateway->warmUserPermissions($userId, true); $this->loadTenantDataIntoSession($userId); if (!empty($_SESSION['no_active_tenant'])) { // Enforce the same tenant policy as interactive login. $this->forgetCurrentUser(); Auth::logout(); return false; } $this->userWriteRepository->updateLastLogin($userId, 'local'); } $newToken = bin2hex(random_bytes(32)); $newHash = hash('sha256', $newToken); $newExpires = gmdate('Y-m-d H:i:s', time() + $this->lifetimeSeconds()); $this->rememberTokenRepository->updateToken((int) $record['id'], $newHash, $newExpires); $this->setCookie($selector, $newToken); return true; } public function forgetCurrentUser(): void { $value = $_COOKIE[$this->cookieName()] ?? ''; if ($value !== '' && strpos($value, ':') !== false) { [$selector] = explode(':', $value, 2); $selector = trim($selector); if ($selector !== '') { $record = $this->rememberTokenRepository->findBySelector($selector); if ($record && isset($record['id'])) { $this->rememberTokenRepository->deleteById((int) $record['id']); } } } $this->clearCookie(); } public function forgetAllForUser(int $userId): void { $this->rememberTokenRepository->deleteByUserId($userId); } public function expireAllTokensByAdmin(): int { return $this->rememberTokenRepository->expireAllByAdmin(); } private function setCookie(string $selector, string $token): void { $value = $selector . ':' . $token; $expires = time() + $this->lifetimeSeconds(); $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); setcookie($this->cookieName(), $value, [ 'expires' => $expires, 'path' => '/', 'secure' => $secure, 'httponly' => true, 'samesite' => 'Lax', ]); } private function clearCookie(): void { $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); setcookie($this->cookieName(), '', [ 'expires' => time() - 3600, 'path' => '/', 'secure' => $secure, 'httponly' => true, 'samesite' => 'Lax', ]); } private function lifetimeSeconds(): int { return self::LIFETIME_DAYS * 24 * 60 * 60; } private function cookieName(): string { $name = getenv('REMEMBER_COOKIE_NAME') ?: self::COOKIE_NAME; return trim($name) !== '' ? $name : self::COOKIE_NAME; } private function loadTenantDataIntoSession(int $userId): void { if ($userId <= 0) { return; } $availableTenants = $this->userTenantContextService->getAvailableTenants($userId); $_SESSION['available_tenants'] = $availableTenants; $_SESSION['available_departments_by_tenant'] = $this->userTenantContextService->getAvailableDepartmentsByTenant($userId); $_SESSION['address_book_saved_filters'] = $this->savedFilterGateway->listAddressBookFilters($userId); if (!$availableTenants) { $_SESSION['no_active_tenant'] = true; unset($_SESSION['current_tenant']); return; } $_SESSION['no_active_tenant'] = false; $currentTenant = $this->userTenantContextService->getCurrentTenant($userId); if (!$currentTenant) { $currentTenant = $availableTenants[0]; } if ($currentTenant) { $_SESSION['current_tenant'] = $currentTenant; } } }