AuthRequiredMiddleware.php 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Http;
  4. use App\ApiClient\AdminClient;
  5. use App\ApiClient\ApiAuthException;
  6. use App\ApiClient\ApiException;
  7. use App\ApiClient\ApiNotFoundException;
  8. use App\Auth\SessionManager;
  9. use App\Auth\UserContext;
  10. use Psr\Http\Message\ResponseFactoryInterface;
  11. use Psr\Http\Message\ResponseInterface;
  12. use Psr\Http\Message\ServerRequestInterface;
  13. use Psr\Http\Server\MiddlewareInterface;
  14. use Psr\Http\Server\RequestHandlerInterface;
  15. use Psr\Log\LoggerInterface;
  16. /**
  17. * Gates `/app/*` routes. If no user is in the session, stash the
  18. * requested URL as `next` and 302 to `/login`. After a successful
  19. * login the controller pops `next` and redirects there.
  20. *
  21. * SEC_REVIEW F36: the cached UserContext (role, identity) is
  22. * periodically re-checked against `GET /api/v1/admin/me`. If the api
  23. * reports the user as disabled, deleted, or with a changed role, the
  24. * UI session is brought back in line — disabled / unknown users are
  25. * logged out immediately and a role downgrade in Entra propagates to
  26. * the next protected request rather than waiting for the 8h idle
  27. * timeout. The api itself is also the authoritative gate (every data
  28. * call goes through `X-Acting-User-Id` and the api re-resolves the
  29. * principal each time), so this middleware is mostly about avoiding
  30. * the UI rendering nav items / forms the user can no longer use.
  31. */
  32. final class AuthRequiredMiddleware implements MiddlewareInterface
  33. {
  34. public function __construct(
  35. private readonly SessionManager $sessions,
  36. private readonly ResponseFactoryInterface $responseFactory,
  37. private readonly AdminClient $admin,
  38. private readonly LoggerInterface $logger,
  39. private readonly int $revalidateAfterSeconds = 300,
  40. ) {
  41. }
  42. public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
  43. {
  44. $user = $this->sessions->getUser();
  45. if ($user === null) {
  46. $uri = $request->getUri();
  47. $path = $uri->getPath();
  48. $query = $uri->getQuery();
  49. $next = $query !== '' ? $path . '?' . $query : $path;
  50. $this->sessions->setNext($next);
  51. return $this->responseFactory
  52. ->createResponse(302)
  53. ->withHeader('Location', '/login');
  54. }
  55. if ($this->shouldRevalidate()) {
  56. $revoked = $this->revalidate($user);
  57. if ($revoked) {
  58. $this->sessions->clear();
  59. $this->sessions->flash('error', 'Your access was revoked. Please sign in again.');
  60. return $this->responseFactory
  61. ->createResponse(302)
  62. ->withHeader('Location', '/login');
  63. }
  64. }
  65. return $handler->handle($request);
  66. }
  67. private function shouldRevalidate(): bool
  68. {
  69. $lastRev = $this->sessions->lastRevalidatedAt();
  70. if ($lastRev === null) {
  71. // Legacy/pre-F36 session — seed the clock and let the next
  72. // request past the threshold be the first real revalidation.
  73. $this->sessions->markRevalidated();
  74. return false;
  75. }
  76. return (time() - $lastRev) >= $this->revalidateAfterSeconds;
  77. }
  78. /**
  79. * @return bool `true` if the session must be cleared (user revoked).
  80. */
  81. private function revalidate(UserContext $user): bool
  82. {
  83. try {
  84. $live = $this->admin->getMe($user->userId);
  85. } catch (ApiAuthException $e) {
  86. // 401 = ui_service_token deployment problem; 403 = this user
  87. // is disabled / unknown. Only the 403 case revokes; a 401
  88. // would lock every session out on a deployment slip-up.
  89. if ($e->statusCode === 403) {
  90. $this->logger->warning('session revalidation: user revoked by api', [
  91. 'user_id' => $user->userId,
  92. 'api_error' => $e->apiError,
  93. ]);
  94. return true;
  95. }
  96. $this->logger->error('session revalidation: api auth error, keeping session', [
  97. 'user_id' => $user->userId,
  98. 'status' => $e->statusCode,
  99. 'api_error' => $e->apiError,
  100. ]);
  101. $this->sessions->markRevalidated();
  102. return false;
  103. } catch (ApiNotFoundException $e) {
  104. // Defensive: the api currently returns 403 (not 404) when the
  105. // user record is missing. Treat 404 the same way for safety.
  106. $this->logger->warning('session revalidation: user record missing', [
  107. 'user_id' => $user->userId,
  108. 'api_error' => $e->apiError,
  109. ]);
  110. return true;
  111. } catch (ApiException $e) {
  112. // Api unreachable / 5xx / unexpected. Don't lock everyone out
  113. // on a backend blip; mark revalidated so we don't grind on it
  114. // every request, and try again after the next interval.
  115. $this->logger->warning('session revalidation: api unreachable, keeping session', [
  116. 'user_id' => $user->userId,
  117. 'error' => $e->getMessage(),
  118. ]);
  119. $this->sessions->markRevalidated();
  120. return false;
  121. }
  122. $live_email = $live->email;
  123. $changed = $live->role !== $user->role
  124. || ($live->displayName !== '' && $live->displayName !== $user->displayName)
  125. || $live_email !== $user->email;
  126. if ($changed) {
  127. $this->sessions->updateUser(new UserContext(
  128. userId: $user->userId,
  129. displayName: $live->displayName !== '' ? $live->displayName : $user->displayName,
  130. role: $live->role,
  131. email: $live_email,
  132. source: $user->source,
  133. ));
  134. }
  135. $this->sessions->markRevalidated();
  136. return false;
  137. }
  138. }