<?php
declare(strict_types=1);
namespace VehicleIngestBundle\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Enforces HTTP Basic auth for every request under one of the automedia push
* path prefixes. Credentials are ones WE issue to automedia (not automedia's own
* mobile.de credentials), configured via %seller_api_user%/%seller_api_password%
* (bound from SELLER_API_USER/SELLER_API_PASSWORD).
*
* There are three prefixes, and they all share ONE credential pair - the vendor
* documentation is explicit about that ("Same Basic-auth credentials as every
* other push", see its "Transport and auth" section):
*
* /seller-api the mobile.de Seller API surface (ads, images, leasing)
* /campaign-api campaigns - a vendor extension, hence its own namespace
* /leasing-api the full leasing rate matrix - likewise
* /ingest-api the read side: what we hold for an ad (IngestReportController)
*
* Every prefix MUST be listed here. A push namespace missing from this list is
* not merely unguarded, it is publicly writable: the guard is the only auth
* layer in front of these endpoints (the dev ingress dropped its nginx basic
* auth, and the IP allowlist in kubernetes/dev/php-fpm/ingress-seller-api.yaml
* is deliberately inactive). Adding an endpoint outside these prefixes means
* adding the prefix here in the same change.
*
* /ingest-api fails the same way for the opposite reason: it does not write
* anything, but it reports a dealer's entire inventory state, so leaving it out
* would publish that instead of exposing a write surface.
*/
class SellerApiBasicAuthSubscriber implements EventSubscriberInterface
{
private const PREFIXES = ['/seller-api', '/campaign-api', '/leasing-api', '/ingest-api'];
public function __construct(
private string $user,
private string $password,
) {
}
public static function getSubscribedEvents(): array
{
// Priority 40 puts this ahead of Symfony's RouterListener (32) on purpose.
//
// At the firewall's usual priority of 8 the router runs first, so an
// unauthenticated request to a path inside a guarded prefix that has no
// matching route answers 404 instead of 401 - the guard never sees it.
// That made the guard depend on route wiring: a namespace whose routes
// are added later, or under a typo, would be silently unprotected, and
// an unauthenticated caller could map which paths exist. Running before
// the router makes the whole prefix fail closed regardless of routing.
//
// Everything this listener touches (path info, Authorization header) is
// available before routing, so the earlier slot costs us nothing.
return [KernelEvents::REQUEST => ['onRequest', 40]];
}
public function onRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
if (!$this->isGuarded($event->getRequest()->getPathInfo())) {
return;
}
$req = $event->getRequest();
$user = (string) $req->getUser();
$pass = (string) $req->getPassword();
$ok = $user !== '' && hash_equals($this->user, $user) && hash_equals($this->password, $pass);
if (!$ok) {
$event->setResponse(new Response('Unauthorized', 401, [
'WWW-Authenticate' => 'Basic realm="automedia-ingest"',
]));
}
}
private function isGuarded(string $path): bool
{
foreach (self::PREFIXES as $prefix) {
if (str_starts_with($path, $prefix)) {
return true;
}
}
return false;
}
}