| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158 |
- <?php
- declare(strict_types=1);
- namespace App\Http;
- use App\ApiClient\AdminClient;
- use App\ApiClient\ApiAuthException;
- use App\ApiClient\ApiException;
- use App\ApiClient\ApiNotFoundException;
- use App\Auth\SessionManager;
- use App\Auth\UserContext;
- use Psr\Http\Message\ResponseFactoryInterface;
- use Psr\Http\Message\ResponseInterface;
- use Psr\Http\Message\ServerRequestInterface;
- use Psr\Http\Server\MiddlewareInterface;
- use Psr\Http\Server\RequestHandlerInterface;
- use Psr\Log\LoggerInterface;
- /**
- * Gates `/app/*` routes. If no user is in the session, stash the
- * requested URL as `next` and 302 to `/login`. After a successful
- * login the controller pops `next` and redirects there.
- *
- * SEC_REVIEW F36: the cached UserContext (role, identity) is
- * periodically re-checked against `GET /api/v1/admin/me`. If the api
- * reports the user as disabled, deleted, or with a changed role, the
- * UI session is brought back in line — disabled / unknown users are
- * logged out immediately and a role downgrade in Entra propagates to
- * the next protected request rather than waiting for the 8h idle
- * timeout. The api itself is also the authoritative gate (every data
- * call goes through `X-Acting-User-Id` and the api re-resolves the
- * principal each time), so this middleware is mostly about avoiding
- * the UI rendering nav items / forms the user can no longer use.
- */
- final class AuthRequiredMiddleware implements MiddlewareInterface
- {
- public function __construct(
- private readonly SessionManager $sessions,
- private readonly ResponseFactoryInterface $responseFactory,
- private readonly AdminClient $admin,
- private readonly LoggerInterface $logger,
- private readonly int $revalidateAfterSeconds = 300,
- ) {
- }
- public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
- {
- $user = $this->sessions->getUser();
- if ($user === null) {
- $uri = $request->getUri();
- $path = $uri->getPath();
- $query = $uri->getQuery();
- $next = $query !== '' ? $path . '?' . $query : $path;
- $this->sessions->setNext($next);
- return $this->responseFactory
- ->createResponse(302)
- ->withHeader('Location', '/login');
- }
- if ($this->shouldRevalidate()) {
- $revoked = $this->revalidate($user);
- if ($revoked) {
- $this->sessions->clear();
- $this->sessions->flash('error', 'Your access was revoked. Please sign in again.');
- return $this->responseFactory
- ->createResponse(302)
- ->withHeader('Location', '/login');
- }
- }
- return $handler->handle($request);
- }
- private function shouldRevalidate(): bool
- {
- $lastRev = $this->sessions->lastRevalidatedAt();
- if ($lastRev === null) {
- // Legacy/pre-F36 session — seed the clock and let the next
- // request past the threshold be the first real revalidation.
- $this->sessions->markRevalidated();
- return false;
- }
- return (time() - $lastRev) >= $this->revalidateAfterSeconds;
- }
- /**
- * @return bool `true` if the session must be cleared (user revoked).
- */
- private function revalidate(UserContext $user): bool
- {
- try {
- $live = $this->admin->getMe($user->userId);
- } catch (ApiAuthException $e) {
- // 401 = ui_service_token deployment problem; 403 = this user
- // is disabled / unknown. Only the 403 case revokes; a 401
- // would lock every session out on a deployment slip-up.
- if ($e->statusCode === 403) {
- $this->logger->warning('session revalidation: user revoked by api', [
- 'user_id' => $user->userId,
- 'api_error' => $e->apiError,
- ]);
- return true;
- }
- $this->logger->error('session revalidation: api auth error, keeping session', [
- 'user_id' => $user->userId,
- 'status' => $e->statusCode,
- 'api_error' => $e->apiError,
- ]);
- $this->sessions->markRevalidated();
- return false;
- } catch (ApiNotFoundException $e) {
- // Defensive: the api currently returns 403 (not 404) when the
- // user record is missing. Treat 404 the same way for safety.
- $this->logger->warning('session revalidation: user record missing', [
- 'user_id' => $user->userId,
- 'api_error' => $e->apiError,
- ]);
- return true;
- } catch (ApiException $e) {
- // Api unreachable / 5xx / unexpected. Don't lock everyone out
- // on a backend blip; mark revalidated so we don't grind on it
- // every request, and try again after the next interval.
- $this->logger->warning('session revalidation: api unreachable, keeping session', [
- 'user_id' => $user->userId,
- 'error' => $e->getMessage(),
- ]);
- $this->sessions->markRevalidated();
- return false;
- }
- $live_email = $live->email;
- $changed = $live->role !== $user->role
- || ($live->displayName !== '' && $live->displayName !== $user->displayName)
- || $live_email !== $user->email;
- if ($changed) {
- $this->sessions->updateUser(new UserContext(
- userId: $user->userId,
- displayName: $live->displayName !== '' ? $live->displayName : $user->displayName,
- role: $live->role,
- email: $live_email,
- source: $user->source,
- ));
- }
- $this->sessions->markRevalidated();
- return false;
- }
- }
|