<?php
namespace Sq\Service\EventSubscriber;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Sq\Event\Kernel\InitialiseContainerEvent;
use Sq\Exception\Controller\AdminAccessDeniedException;
use Sq\Exception\Controller\MemberAccessDeniedException;
use Sq\Exception\Controller\MFARequiredException;
use Sq\Exception\Controller\MFAVerificationException;
use Sq\Service\Environment;
use Sq\Service\FeatureChecker;
use Sq\Service\ReactApp;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\EventListener\ErrorListener as SymfonyErrorListener;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Exception\InvalidParameterException;
use Symfony\Component\Routing\Exception\MethodNotAllowedException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
class KernelErrorSubscriber implements EventSubscriberInterface
{
/** @var Environment */
protected $environment;
/** @var EventDispatcherInterface */
protected $dispatcher;
/** @var LoggerInterface */
protected $logger;
/** @var FeatureChecker */
protected $featureChecker;
/** @var ContainerInterface */
protected $container;
/** @var ReactApp */
private $reactApp;
/** @var string[] */
protected $exceptionLogLevels = [
SuspiciousOperationException::class => LogLevel::ERROR,
MethodNotAllowedException::class => LogLevel::ERROR,
InvalidParameterException::class => LogLevel::ERROR,
BadRequestHttpException::class => LogLevel::ERROR,
ResourceNotFoundException::class => LogLevel::ERROR,
];
public function __construct(
Environment $environment,
EventDispatcherInterface $dispatcher,
LoggerInterface $logger,
FeatureChecker $featureChecker,
ContainerInterface $container,
ReactApp $reactApp
) {
$this->environment = $environment;
$this->dispatcher = $dispatcher;
$this->logger = $logger;
$this->featureChecker = $featureChecker;
$this->container = $container;
$this->reactApp = $reactApp;
}
public static function getSubscribedEvents()
{
return [
KernelEvents::EXCEPTION => ['onKernelException'],
InitialiseContainerEvent::class => ['onKernelInitialiseContainer']
];
}
/**
* Removes the logKernelException listener as we do our own logging.
* For Symfony 5.x we will need to update this to remove the SymfonyExceptionListener.
*/
public function onKernelInitialiseContainer()
{
$dispatcher = $this->dispatcher;
$listeners = $dispatcher->getListeners("kernel.exception");
foreach ($listeners as $listener)
{
if (is_array($listener)
&& ($listener[0] instanceof SymfonyErrorListener)
&& $listener[1] == "logKernelException"
) {
$dispatcher->removeListener("kernel.exception", $listener);
break;
}
}
}
/**
* Handles kernel exceptions we want to (e.g. 404, 500, access denied).
*
* @param ExceptionEvent $event
*
* @throws \Exception
*/
public function onKernelException(ExceptionEvent $event)
{
// You get the exception object from the received event
$throwable = $event->getThrowable();
$response = null;
switch (get_class($throwable))
{
case MFARequiredException::class:
$response = $this->handleMFARequired($event->getRequest());
break;
case MFAVerificationException::class:
$response = $this->handleMFAVerification($event->getRequest());
break;
case AdminAccessDeniedException::class:
$response = $this->handleAdminAccessDenied($event->getRequest());
break;
case MemberAccessDeniedException::class:
$response = $this->handleMemberAccessDenied($event->getRequest());
break;
case NotFoundHttpException::class:
if ($this->reactApp->shouldUseReactForRequest())
{
try
{
$response = $this->reactApp->renderReactApp();
$event->allowCustomResponseCode();
}
catch (\Throwable $e)
{
$event->setThrowable($e);
}
}
break;
}
$this->logError($throwable);
// Send the modified response object to the event
if ($response instanceof Response)
{
$event->setResponse($response);
}
}
/**
* Logs the error to the appropriate level.
*
* @param \Throwable $throwable
*/
protected function logError(\Throwable $throwable)
{
$logLevel = LogLevel::CRITICAL;
if ($throwable instanceof NotFoundHttpException || $throwable instanceof ResourceNotFoundException)
{
return;
}
if (($throwable instanceof HttpExceptionInterface && $throwable->getStatusCode() < 500) ||
$throwable instanceof MFARequiredException ||
$throwable instanceof MFAVerificationException ||
$throwable instanceof AdminAccessDeniedException ||
$throwable instanceof MemberAccessDeniedException
) {
$logLevel = LogLevel::ERROR;
}
if (array_key_exists(get_class($throwable), $this->exceptionLogLevels))
{
$logLevel = $this->exceptionLogLevels[get_class($throwable)];
}
$this->logger->log($logLevel, "{exception}", ['exception' => $throwable]);
}
/**
* Handle MFA required for this page.
* Public method, as it's used in admin_only.inc.php for legacy pages.
*
* @param Request $request
*
* @return Response|null
*/
public function handleMFARequired(Request $request)
{
if ($request->isXmlHttpRequest())
{
return new JsonResponse(["status" => "ERROR", "msg" => "MFA is required for this page"], 401);
}
return new RedirectResponse("/mfa/required?uri=" . urlencode($request->getRequestUri()));
}
/**
* Handle MFA verification required for this page.
* Public method, as it's used in admin_only.inc.php for legacy pages.
*
* @param Request $request
*
* @return Response|null
*/
public function handleMFAVerification(Request $request)
{
if ($request->isXmlHttpRequest())
{
return new JsonResponse(["status" => "ERROR", "msg" => "MFA verification is required for this page"], 401);
}
return new RedirectResponse("/mfa/verify?r=" . urlencode($request->getRequestUri()));
}
/**
* Handle denied attempt to a member page.
* Public method, as it's used in admin_only.inc.php for legacy pages.
*
* @param Request $request
*
* @return Response|null
*/
public function handleAdminAccessDenied(Request $request)
{
if ($request->isXmlHttpRequest())
{
return new JsonResponse(["status" => "ERROR", "msg" => "You must be logged in as an administrator"], 401);
}
return new RedirectResponse($request->getSchemeAndHttpHost());
}
/**
* Handle denied attempt to a member page.
* Public method, as it's used in members_only.inc.php for legacy pages.
*
* @param Request $request
*
* @return Response|null
*/
public function handleMemberAccessDenied(Request $request)
{
if ($request->isXmlHttpRequest())
{
return new JsonResponse(["status" => "ERROR", "msg" => "You are no longer logged in"], 401);
}
return new RedirectResponse($request->getSchemeAndHttpHost() . "/login?r=" . urlencode(ltrim($request->getRequestUri(), "/")));
}
}