<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* HTTP Basic gate for /admin using Apache-style htpasswd (bcrypt lines only: htpasswd -B).
* Runs before the security firewall so it works reliably on Laragon/Apache without mod_auth expr.
*/
class AdminHtpasswdBasicAuthSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly string $projectDir,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 300],
];
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest() || \PHP_SAPI === 'cli') {
return;
}
$request = $event->getRequest();
if (!str_starts_with($request->getPathInfo(), '/admin')) {
return;
}
$htpasswdPath = $this->projectDir . '/public/.htpasswd';
if (!is_readable($htpasswdPath)) {
$event->setResponse(new Response(
'Admin HTTP Basic is enabled but public/.htpasswd is missing or not readable.',
503
));
$event->stopPropagation();
return;
}
$authHeader = $this->getAuthorizationHeader($request);
if (!str_starts_with($authHeader, 'Basic ')) {
$this->challenge($event);
return;
}
$decoded = base64_decode(substr($authHeader, 6), true);
if (false === $decoded || !str_contains($decoded, ':')) {
$this->challenge($event);
return;
}
[$username, $password] = explode(':', $decoded, 2);
foreach ($this->readHtpasswdLines($htpasswdPath) as $line) {
if (!str_contains($line, ':')) {
continue;
}
[$fileUser, $hash] = explode(':', $line, 2);
if (!hash_equals($fileUser, $username)) {
continue;
}
if (str_starts_with($hash, '$2y$') || str_starts_with($hash, '$2a$') || str_starts_with($hash, '$2b$')) {
if (password_verify($password, $hash)) {
return;
}
}
$this->challenge($event);
return;
}
$this->challenge($event);
}
/**
* @return \Generator<string>
*/
private function readHtpasswdLines(string $path): \Generator
{
$handle = fopen($path, 'rb');
if (false === $handle) {
return;
}
try {
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
yield $line;
}
} finally {
fclose($handle);
}
}
private function challenge(RequestEvent $event): void
{
$event->setResponse(new Response('Authentication required.', 401, [
'WWW-Authenticate' => 'Basic realm="Pimcore Admin"',
]));
$event->stopPropagation();
}
private function getAuthorizationHeader(Request $request): string
{
$fromHeader = $request->headers->get('Authorization');
if (\is_string($fromHeader) && $fromHeader !== '') {
return $fromHeader;
}
foreach (['HTTP_AUTHORIZATION', 'REDIRECT_HTTP_AUTHORIZATION'] as $key) {
$v = $request->server->get($key);
if (\is_string($v) && $v !== '') {
return $v;
}
}
return '';
}
}