true, 'secure' => self::isSecureRequest(), 'samesite' => 'Strict', 'path' => '/', ]); session_start(); $now = time(); $idle = max(300, (int) (getenv('GLOBINOURS_SESSION_IDLE') ?: 1800)); $absolute = max($idle, (int) (getenv('GLOBINOURS_SESSION_ABSOLUTE') ?: 43200)); if ( !empty($_SESSION['user_id']) && ((isset($_SESSION['last_activity']) && $now - (int) $_SESSION['last_activity'] > $idle) || (isset($_SESSION['authenticated_at']) && $now - (int) $_SESSION['authenticated_at'] > $absolute)) ) { self::logout(false); session_regenerate_id(true); } if ( empty($_SESSION['user_id']) && class_exists(TrustedDeviceService::class) && ($id = TrustedDeviceService::authenticate()) ) { session_regenerate_id(true); $_SESSION['user_id'] = $id; $_SESSION['authenticated_at'] = $now; } if (!empty($_SESSION['user_id'])) { $_SESSION['last_activity'] = $now; } } public static function countUsers(): int { return (int) DB::pdo()->query('SELECT COUNT(*) FROM users')->fetchColumn(); } public static function user(): ?array { if (self::$user !== null) { return self::$user; } $id = (int) ($_SESSION['user_id'] ?? 0); if (!$id) { return null; } $s = DB::pdo()->prepare( 'SELECT id,username,display_name,role,active,last_login_at,animal_list_view,agenda_view FROM users WHERE id=:id AND active=1', ); $s->execute([':id' => $id]); $u = $s->fetch(PDO::FETCH_ASSOC); if (!$u) { self::logout(false); return null; } return self::$user = $u; } public static function id(): ?int { return ($u = self::user()) ? (int) $u['id'] : null; } public static function login( string $username, string $password, bool $remember = false, string $deviceName = '', ): bool { $db = DB::pdo(); $username = trim($username); $ip = mb_substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64); $db->exec("DELETE FROM login_attempts WHERE attempted_atprepare( "SELECT COUNT(*) FROM login_attempts WHERE succeeded=0 AND ip_address=? AND attempted_at>=datetime('now','-15 minutes')", ); $rate->execute([$ip ?: null]); if ((int) $rate->fetchColumn() >= 20) { self::recordAttempt($username, $ip, false); return false; } $s = $db->prepare('SELECT * FROM users WHERE lower(username)=lower(:u) LIMIT 1'); $s->execute([':u' => $username]); $u = $s->fetch(PDO::FETCH_ASSOC); $valid = $u && (int) $u['active'] === 1 && (empty($u['locked_until']) || strtotime($u['locked_until']) <= time()) && password_verify($password, (string) $u['password_hash']); if (!$u) { password_verify($password, '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.'); } if (!$valid) { if ($u) { $failed = (int) $u['failed_attempts'] + 1; $lock = $failed >= 5 ? date('Y-m-d H:i:s', time() + 900) : null; $db->prepare('UPDATE users SET failed_attempts=:f,locked_until=:l WHERE id=:id')->execute([ ':f' => $failed, ':l' => $lock, ':id' => $u['id'], ]); } self::recordAttempt($username, $ip, false); return false; } session_regenerate_id(true); $_SESSION['user_id'] = (int) $u['id']; $_SESSION['authenticated_at'] = time(); $_SESSION['last_activity'] = time(); self::$user = null; $db->prepare( "UPDATE users SET failed_attempts=0,locked_until=NULL,last_login_at=datetime('now') WHERE id=:id", )->execute([':id' => $u['id']]); self::recordAttempt($username, $ip, true); if ($remember && class_exists(TrustedDeviceService::class)) { TrustedDeviceService::create((int) $u['id'], $deviceName); } return true; } public static function logout(bool $destroy = true): void { if ($destroy && class_exists(TrustedDeviceService::class)) { TrustedDeviceService::revokeCurrent(); } self::$user = null; unset($_SESSION['user_id']); if ($destroy) { $_SESSION = []; if (ini_get('session.use_cookies')) { $p = session_get_cookie_params(); setcookie( session_name(), '', time() - 42000, $p['path'], $p['domain'] ?? '', (bool) $p['secure'], (bool) $p['httponly'], ); } session_destroy(); } } public static function csrf(): string { if (empty($_SESSION['csrf'])) { $_SESSION['csrf'] = bin2hex(random_bytes(32)); } return (string) $_SESSION['csrf']; } public static function validCsrf(?string $token): bool { return is_string($token) && hash_equals(self::csrf(), $token); } public static function is(string $role): bool { return (self::user()['role'] ?? '') === $role; } public static function canWrite(string $path): bool { $module = PermissionService::moduleForPath($path); return $module === null || PermissionService::can($module, 'edit'); } public static function isSecureRequest(): bool { if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') { return true; } if (str_starts_with(strtolower((string) getenv('APP_URL')), 'https://')) { return true; } $remote = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); $trusted = array_filter(array_map('trim', explode(',', (string) getenv('GLOBINOURS_TRUSTED_PROXIES')))); if (in_array($remote, ['127.0.0.1', '::1'], true) || in_array($remote, $trusted, true)) { return strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0] ?? '')) === 'https'; } return false; } private static function recordAttempt(string $username, string $ip, bool $success): void { try { DB::pdo() ->prepare('INSERT INTO login_attempts(username_hash,ip_address,succeeded) VALUES(?,?,?)') ->execute([hash('sha256', mb_strtolower($username)), $ip ?: null, $success ? 1 : 0]); } catch (Throwable) { } } }