| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181 |
- <?php
- declare(strict_types=1);
- namespace App\Auth;
- use App\Domain\User;
- use App\Http\Request;
- use App\Http\Response;
- use App\Repositories\UserRepository;
- /**
- * Session lifecycle + CSRF helpers.
- *
- * Session data:
- * $_SESSION['user_id'] — int id, set after successful login
- * $_SESSION['login_at'] — unix timestamp of login
- * $_SESSION['csrf_token'] — hex token, lazy-created
- *
- * Intentionally stateless utility methods; the caller hydrates the full User
- * from the repo on demand.
- */
- final class SessionGuard
- {
- public const COOKIE_NAME = 'sp_session';
- public static function start(): void
- {
- if (session_status() === PHP_SESSION_ACTIVE) {
- return;
- }
- $savePath = getenv('SESSION_PATH');
- if (is_string($savePath) && $savePath !== '') {
- if (!is_dir($savePath)) {
- @mkdir($savePath, 0o700, true);
- }
- session_save_path($savePath);
- }
- $baseUrl = (string) (getenv('APP_BASE_URL') ?: '');
- $secure = str_starts_with($baseUrl, 'https://');
- ini_set('session.use_strict_mode', '1');
- ini_set('session.use_only_cookies', '1');
- ini_set('session.cookie_httponly', '1');
- ini_set('session.cookie_samesite', 'Lax');
- ini_set('session.cookie_secure', $secure ? '1' : '0');
- ini_set('session.gc_maxlifetime', '28800'); // 8h
- session_name(self::COOKIE_NAME);
- session_set_cookie_params([
- 'lifetime' => 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;
- }
- }
|