Search code examples
phptwighttp-status-code-404symfony4symfony-routing

Custom 404 error template in twig 2.5 with symfony 4.1


I created customs Twig templates for http error display to keep the site design unified by extending my base layout. (I want to keep my navigation menu and display the error, unlike the regular error messages)

It's working as expected but for the 404.

In the navigation menu of my base layout, I have a lot of is_granted('SOME_ROLES') to display the availables sections of the site depending of user's rights. When a 404 is thrown, the navigation menu is displayed as if the user is disconnected : {% if is_granted("IS_AUTHENTICATED_REMEMBERED") %} being false.

After some searches, I found that the router is executed before the firewall. Since no route is found when a 404 is thrown, the firewall isn't executed and the rights aren't send to the template.

The only workaround I found (source from 2014) is to add at the very bottom of the routes.yaml file this route definition :

pageNotFound:
    path: /{path}
    defaults:
        _controller: App\Exception\PageNotFound::pageNotFound

Since every other routes hasn't match, this one should be the not found.

The controller :

<?php

namespace App\Exception;

use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class PageNotFound
{
    public function pageNotFound()
    {
        return (new NotFoundHttpException());
    }
}

Because a controller is executed, the firewall is executed and the 404 error page is shown as I expected (hooray !).

My question is : Is there any proper way to fix that issue instead of that workaround?


Solution

  • We had a similar issue.

    • We wanted to have access to an authentication token in error pages.
    • In the scenario where part of the website is behind a firewall, say example.com/supersecretarea/, we wanted than unauthorized users get a 403 error code when accessing any url behind example.com/supersecretarea/, even in the event that the page doesn't exist. Symfony's behavior does not allow that and checks for a 404 (either because there is no route or because the route has parameter which didn't resolve, like example.com/supersecretarea/user/198 when the is no user 198).

    What we ended up doing was to override the default router in Symfony (Symfony\Bundle\FrameworkBundle\Routing\Router) to modify its behavior:

    public function matchRequest(Request $request): array
    {
        try {
            return parent::matchRequest($request);
        } catch (ResourceNotFoundException $e) {
            // Ignore this next line for now
            // $this->targetPathSavingStatus->disableSaveTargetPath();
            return [
                '_controller' => 'App\Controller\CatchAllController::catchAll',
                '_route' => 'catch_all'
            ];
        }
    }
    

    CatchAllController simply renders the 404 error page:

    public function catchAll(): Response
    {
        return new Response(
            $this->templating->render('bundles/TwigBundle/Exception/error404.html.twig'),
            Response::HTTP_NOT_FOUND
        );
    }
    

    What happens is that during the regular process of Symfony's router, if something should trigger a 404 error, we catch that exception within the matchRequest function. This function is supposed to return information about which controller action to run to render the page, so that's what we do: we tell the router that we want to render a 404 page (with a 404 code). All the security is handled in between matchRequest returning and catchAll being called, so firewalls get to trigger 403 errors, we have an authentication token, etc.


    There is at least one functional issue to this approach (that we managed to fix for now). Symfony has an optional system that remembers the last page you tried to load, so that if you get redirected to the login page and successfully log in, you'll be redirected to that page you were trying to load initially. When the firewall throws an exception, this occurs:

    // Symfony\Component\Security\Http\Firewall\ExceptionListener
    protected function setTargetPath(Request $request)
    {
        // session isn't required when using HTTP basic authentication mechanism for example
        if ($request->hasSession() && $request->isMethodSafe(false) && !$request->isXmlHttpRequest()) {
            $this->saveTargetPath($request->getSession(), $this->providerKey, $request->getUri());
        }
    }
    

    But now that we allow non-existing pages to trigger firewall redirections to the login page (say, example.com/registered_users_only/* redirects to the loading page, and an unauthenticated user clicks on example.com/registered_users_only/page_that_does_not_exist), we absolutely don't want to save that non-existing page as the new "TargetPath" to redirect to after a successful login, otherwise the user will see a seemingly random 404 error. We decided to extend the exception listener's setTargetPath, and defined a service that toggles whether a target path should be saved by the exception listener or not.

    // Our extended ExceptionListener
    protected function setTargetPath(Request $request): void
    {
        if ($this->targetPathSavingStatus->shouldSave()) {
            parent::setTargetPath($request);
        }
    }
    

    That's the purpose of the commented $this->targetPathSavingStatus->disableSaveTargetPath(); line from above: to turn the default-on status of whether to save target path on firewall exceptions to off when there's a 404 (the targetPathSavingStatus variables here point to a very simple service used only to store that piece of information).

    This part of the solution is not very satisfactory. I'd like to find something better. It does seem to do the job for now though.

    Of course if you have always_use_default_target_path to true, then there is no need for this particular fix.


    EDIT:

    To make Symfony use my versions of the Router and Exception listener, I added the following code in the process() method of Kernel.php:

    public function process(ContainerBuilder $container)
    {
        // Use our own CatchAll router rather than the default one
        $definition = $container->findDefinition('router.default');
        $definition->setClass(CatchAllRouter::class);
        // register the service that we use to alter the targetPath saving mechanic
        $definition->addMethodCall('setTargetPathSavingStatus', [new Reference('App\Routing\TargetPathSavingStatus')]);
    
        // Use our own ExceptionListener so that we can tell it not to use saveTargetPath
        // after the CatchAll router intercepts a 404
        $definition = $container->findDefinition('security.exception_listener');
        $definition->setClass(FirewallExceptionListener::class);
        // register the service that we use to alter the targetPath saving mechanic
        $definition->addMethodCall('setTargetPathSavingStatus', [new Reference('App\Routing\TargetPathSavingStatus')]);
    
        // ...
    }