SessionGuard.php 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Auth;
  4. use App\Domain\User;
  5. use App\Http\Request;
  6. use App\Http\Response;
  7. use App\Repositories\UserRepository;
  8. /**
  9. * Session lifecycle + CSRF helpers.
  10. *
  11. * Session data:
  12. * $_SESSION['user_id'] — int id, set after successful login
  13. * $_SESSION['login_at'] — unix timestamp of login
  14. * $_SESSION['csrf_token'] — hex token, lazy-created
  15. *
  16. * Intentionally stateless utility methods; the caller hydrates the full User
  17. * from the repo on demand.
  18. */
  19. final class SessionGuard
  20. {
  21. public const COOKIE_NAME = 'sp_session';
  22. public static function start(): void
  23. {
  24. if (session_status() === PHP_SESSION_ACTIVE) {
  25. return;
  26. }
  27. $savePath = getenv('SESSION_PATH');
  28. if (is_string($savePath) && $savePath !== '') {
  29. if (!is_dir($savePath)) {
  30. @mkdir($savePath, 0o700, true);
  31. }
  32. session_save_path($savePath);
  33. }
  34. $baseUrl = (string) (getenv('APP_BASE_URL') ?: '');
  35. $secure = str_starts_with($baseUrl, 'https://');
  36. ini_set('session.use_strict_mode', '1');
  37. ini_set('session.use_only_cookies', '1');
  38. ini_set('session.cookie_httponly', '1');
  39. ini_set('session.cookie_samesite', 'Lax');
  40. ini_set('session.cookie_secure', $secure ? '1' : '0');
  41. ini_set('session.gc_maxlifetime', '28800'); // 8h
  42. session_name(self::COOKIE_NAME);
  43. session_set_cookie_params([
  44. 'lifetime' => 0,
  45. 'path' => '/',
  46. 'httponly' => true,
  47. 'samesite' => 'Lax',
  48. 'secure' => $secure,
  49. ]);
  50. session_start();
  51. }
  52. public static function login(User $user): void
  53. {
  54. self::start();
  55. // Fresh id on privilege change, but preserve any pre-login state
  56. // (the OIDC client stores its nonce/state in the session).
  57. session_regenerate_id(true);
  58. $_SESSION['user_id'] = $user->id;
  59. $_SESSION['login_at'] = time();
  60. }
  61. public static function logout(): void
  62. {
  63. if (session_status() !== PHP_SESSION_ACTIVE) {
  64. self::start();
  65. }
  66. $_SESSION = [];
  67. if (ini_get('session.use_cookies')) {
  68. $p = session_get_cookie_params();
  69. setcookie(
  70. session_name(),
  71. '',
  72. [
  73. 'expires' => time() - 42000,
  74. 'path' => $p['path'],
  75. 'domain' => $p['domain'],
  76. 'secure' => $p['secure'],
  77. 'httponly' => $p['httponly'],
  78. 'samesite' => $p['samesite'] ?? 'Lax',
  79. ]
  80. );
  81. }
  82. session_destroy();
  83. }
  84. public static function currentUserId(): ?int
  85. {
  86. self::start();
  87. $id = $_SESSION['user_id'] ?? null;
  88. return is_int($id) ? $id : null;
  89. }
  90. public static function currentUser(UserRepository $users): ?User
  91. {
  92. $id = self::currentUserId();
  93. return $id === null ? null : $users->find($id);
  94. }
  95. public static function csrfToken(): string
  96. {
  97. self::start();
  98. if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
  99. $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
  100. }
  101. return (string) $_SESSION['csrf_token'];
  102. }
  103. /** Returns true when the request is GET/HEAD (not guarded) or the token matches. */
  104. public static function verifyCsrf(Request $req): bool
  105. {
  106. if (in_array($req->method, ['GET', 'HEAD', 'OPTIONS'], true)) {
  107. return true;
  108. }
  109. $token = $req->header('x-csrf-token');
  110. if ($token === null || $token === '') {
  111. $token = isset($req->post['_csrf']) ? (string) $req->post['_csrf'] : '';
  112. }
  113. if ($token === '') {
  114. return false;
  115. }
  116. return hash_equals(self::csrfToken(), $token);
  117. }
  118. /**
  119. * Return the signed-in User, or a redirect Response to /auth/login if not
  120. * authenticated. Controllers use:
  121. *
  122. * $user = SessionGuard::requireAuth($this->users);
  123. * if ($user instanceof Response) return $user;
  124. */
  125. public static function requireAuth(UserRepository $users): User|Response
  126. {
  127. $user = self::currentUser($users);
  128. return $user ?? Response::redirect('/auth/login');
  129. }
  130. /**
  131. * Same as requireAuth but also requires is_admin. Returns 403 when signed
  132. * in as a non-admin; a redirect to /auth/login when anonymous.
  133. */
  134. public static function requireAdmin(UserRepository $users): User|Response
  135. {
  136. $user = self::currentUser($users);
  137. if ($user === null) {
  138. return Response::redirect('/auth/login');
  139. }
  140. if (!$user->isAdmin) {
  141. return Response::text('Forbidden — admin access required', 403);
  142. }
  143. return $user;
  144. }
  145. /**
  146. * JSON-flavoured admin gate: auth + admin + CSRF. Returns the signed-in
  147. * User on success, or a JSON error envelope response per spec §7.
  148. */
  149. public static function requireAdminJson(Request $req, UserRepository $users): User|Response
  150. {
  151. $user = self::currentUser($users);
  152. if ($user === null) {
  153. return Response::err('unauthenticated', 'Sign in required', 401);
  154. }
  155. if (!$user->isAdmin) {
  156. return Response::err('forbidden', 'Admin access required', 403);
  157. }
  158. if (!self::verifyCsrf($req)) {
  159. return Response::err('csrf', 'CSRF token invalid', 403);
  160. }
  161. return $user;
  162. }
  163. }