0, 'path' => '/', 'httponly' => true, 'samesite' => 'Lax', 'secure' => $secure, ]); session_start(); } public static function login(User $user): void { self::start(); // Fresh id on privilege change, but preserve any pre-login state // (the OIDC client stores its nonce/state in the session). session_regenerate_id(true); $_SESSION['user_id'] = $user->id; $_SESSION['login_at'] = time(); } public static function logout(): void { if (session_status() !== PHP_SESSION_ACTIVE) { self::start(); } $_SESSION = []; if (ini_get('session.use_cookies')) { $p = session_get_cookie_params(); setcookie( session_name(), '', [ 'expires' => time() - 42000, 'path' => $p['path'], 'domain' => $p['domain'], 'secure' => $p['secure'], 'httponly' => $p['httponly'], 'samesite' => $p['samesite'] ?? 'Lax', ] ); } session_destroy(); } public static function currentUserId(): ?int { self::start(); $id = $_SESSION['user_id'] ?? null; return is_int($id) ? $id : null; } public static function currentUser(UserRepository $users): ?User { $id = self::currentUserId(); return $id === null ? null : $users->find($id); } public static function csrfToken(): string { self::start(); if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } return (string) $_SESSION['csrf_token']; } /** Returns true when the request is GET/HEAD (not guarded) or the token matches. */ public static function verifyCsrf(Request $req): bool { if (in_array($req->method, ['GET', 'HEAD', 'OPTIONS'], true)) { return true; } $token = $req->header('x-csrf-token'); if ($token === null || $token === '') { $token = isset($req->post['_csrf']) ? (string) $req->post['_csrf'] : ''; } if ($token === '') { return false; } return hash_equals(self::csrfToken(), $token); } /** * Return the signed-in User, or a redirect Response to /auth/login if not * authenticated. Controllers use: * * $user = SessionGuard::requireAuth($this->users); * if ($user instanceof Response) return $user; */ public static function requireAuth(UserRepository $users): User|Response { $user = self::currentUser($users); return $user ?? Response::redirect('/auth/login'); } /** * Same as requireAuth but also requires is_admin. Returns 403 when signed * in as a non-admin; a redirect to /auth/login when anonymous. */ public static function requireAdmin(UserRepository $users): User|Response { $user = self::currentUser($users); if ($user === null) { return Response::redirect('/auth/login'); } if (!$user->isAdmin) { return Response::text('Forbidden — admin access required', 403); } return $user; } /** * JSON-flavoured admin gate: auth + admin + CSRF. Returns the signed-in * User on success, or a JSON error envelope response per spec §7. */ public static function requireAdminJson(Request $req, UserRepository $users): User|Response { $user = self::currentUser($users); if ($user === null) { return Response::err('unauthenticated', 'Sign in required', 401); } if (!$user->isAdmin) { return Response::err('forbidden', 'Admin access required', 403); } if (!self::verifyCsrf($req)) { return Response::err('csrf', 'CSRF token invalid', 403); } return $user; } }