bundles/VehicleIngestBundle/Security/SellerApiBasicAuthSubscriber.php line 65

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace VehicleIngestBundle\Security;
  4. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  5. use Symfony\Component\HttpFoundation\Response;
  6. use Symfony\Component\HttpKernel\Event\RequestEvent;
  7. use Symfony\Component\HttpKernel\KernelEvents;
  8. /**
  9.  * Enforces HTTP Basic auth for every request under one of the automedia push
  10.  * path prefixes. Credentials are ones WE issue to automedia (not automedia's own
  11.  * mobile.de credentials), configured via %seller_api_user%/%seller_api_password%
  12.  * (bound from SELLER_API_USER/SELLER_API_PASSWORD).
  13.  *
  14.  * There are three prefixes, and they all share ONE credential pair - the vendor
  15.  * documentation is explicit about that ("Same Basic-auth credentials as every
  16.  * other push", see its "Transport and auth" section):
  17.  *
  18.  *   /seller-api    the mobile.de Seller API surface (ads, images, leasing)
  19.  *   /campaign-api  campaigns - a vendor extension, hence its own namespace
  20.  *   /leasing-api   the full leasing rate matrix - likewise
  21.  *   /ingest-api    the read side: what we hold for an ad (IngestReportController)
  22.  *
  23.  * Every prefix MUST be listed here. A push namespace missing from this list is
  24.  * not merely unguarded, it is publicly writable: the guard is the only auth
  25.  * layer in front of these endpoints (the dev ingress dropped its nginx basic
  26.  * auth, and the IP allowlist in kubernetes/dev/php-fpm/ingress-seller-api.yaml
  27.  * is deliberately inactive). Adding an endpoint outside these prefixes means
  28.  * adding the prefix here in the same change.
  29.  *
  30.  * /ingest-api fails the same way for the opposite reason: it does not write
  31.  * anything, but it reports a dealer's entire inventory state, so leaving it out
  32.  * would publish that instead of exposing a write surface.
  33.  */
  34. class SellerApiBasicAuthSubscriber implements EventSubscriberInterface
  35. {
  36.     private const PREFIXES = ['/seller-api''/campaign-api''/leasing-api''/ingest-api'];
  37.     public function __construct(
  38.         private string $user,
  39.         private string $password,
  40.     ) {
  41.     }
  42.     public static function getSubscribedEvents(): array
  43.     {
  44.         // Priority 40 puts this ahead of Symfony's RouterListener (32) on purpose.
  45.         //
  46.         // At the firewall's usual priority of 8 the router runs first, so an
  47.         // unauthenticated request to a path inside a guarded prefix that has no
  48.         // matching route answers 404 instead of 401 - the guard never sees it.
  49.         // That made the guard depend on route wiring: a namespace whose routes
  50.         // are added later, or under a typo, would be silently unprotected, and
  51.         // an unauthenticated caller could map which paths exist. Running before
  52.         // the router makes the whole prefix fail closed regardless of routing.
  53.         //
  54.         // Everything this listener touches (path info, Authorization header) is
  55.         // available before routing, so the earlier slot costs us nothing.
  56.         return [KernelEvents::REQUEST => ['onRequest'40]];
  57.     }
  58.     public function onRequest(RequestEvent $event): void
  59.     {
  60.         if (!$event->isMainRequest()) {
  61.             return;
  62.         }
  63.         if (!$this->isGuarded($event->getRequest()->getPathInfo())) {
  64.             return;
  65.         }
  66.         $req $event->getRequest();
  67.         $user = (string) $req->getUser();
  68.         $pass = (string) $req->getPassword();
  69.         $ok $user !== '' && hash_equals($this->user$user) && hash_equals($this->password$pass);
  70.         if (!$ok) {
  71.             $event->setResponse(new Response('Unauthorized'401, [
  72.                 'WWW-Authenticate' => 'Basic realm="automedia-ingest"',
  73.             ]));
  74.         }
  75.     }
  76.     private function isGuarded(string $path): bool
  77.     {
  78.         foreach (self::PREFIXES as $prefix) {
  79.             if (str_starts_with($path$prefix)) {
  80.                 return true;
  81.             }
  82.         }
  83.         return false;
  84.     }
  85. }