Backbeat Software
Photo by Sarah Kilian on Unsplash

Logged out ajax requests in Symfony applications

Handling logged out ajax requests properly using Symfony's security features.

Glynn Forrest
Monday, June 29, 2020

At Backbeat we log all of our work time for clients with a web app built in Symfony. The work log form is fairly basic, but serves our needs well:

The page is simple - on the right is a list of work loaded with ajax, on the left is a form submitted with an ajax request. When the form submissions succeeds, the form inputs are cleared and the list of work is fetched again without reloading the page.

I keep this page open throughout the day, which can be a problem: after not loading a page for a while, I can fill out the form, hit submit, and have the request fail due to being logged out. Even worse, because a logged out request will result in a redirect to the login page, the javascript that submits the form will consider that a successful request, clearing the form inputs!

This post shares some different approaches to gracefully handle the issue.

Examining the problem

The (simplified) javascript code for submitting the form looks something like this:

import axios from 'axios';

function submitForm() {
  axios
    .post('/work-logs/add', getFormData())
    .then(() => {
      clearForm();
      loadLogList();
    })
    .catch(() => {
      alert('Unable to save logs. Please try again!');
    });
}

And the firewall in security.yaml looks like this:

security:
    firewalls:
        main:
            pattern: '^/'
            form_login:
                login_path: login
                check_path: login
            anonymous: false
    access_control:
        - {path: ^/login, role: IS_AUTHENTICATED_ANONYMOUSLY}

The cause of the problem is clear - when the submitForm() function runs and the user is logged out, the ajax request will be caught by the Symfony firewall, which will redirect to the firewall’s ‘entry point’ - the login form. The ajax request will follow the redirect and get the login form response back with a 200 OK status code. The then() function will run, clearing the form.

It would be much better if the ajax request returned a 401 Unauthorized response instead. The catch() function would run, and the user would get another chance to submit the form again with the text they’ve written. If they saw a logged out error, they would open another tab, login, and submit the form again in the current tab.

How do we stop Symfony intercepting the request and redirecting to the login form? Let’s look at some different approaches to solve this.

Approach 1 - manual security on the controller

A straightforward way is to manually check if the user is logged in. We’ll make an exception to /work-logs/add in the firewall, and check manually in the controller if the request is authenticated:

  security:
      firewalls:
          main:
              pattern: '^/'
              form_login:
                  login_path: login
                  check_path: login
              anonymous: false
      access_control:
          - {path: ^/login, role: IS_AUTHENTICATED_ANONYMOUSLY}
+         - {path: ^/work-logs/add, role: IS_AUTHENTICATED_ANONYMOUSLY}
  class WorkLogsController extends Controller
  {
      /**
       * @Route("/work-logs/add", name="work_logs_add", methods={"POST"})
       */
      public function addAction(Request $request)
      {
+         if(!$this->isGranted('ROLE_USER')) {
+             return new JsonResponse([
+                 'message' => "You're not logged in! Use another tab to login, then try the request again.",
+             ], JsonResponse::HTTP_UNAUTHORIZED);
+         }

          // code to save the log

          return new JsonResponse([
              'message' => 'Logged work!'
          ]);
      }
  }

We could also adjust the javascript to show the reason the error occurred:

- .catch(() => {
-   alert('Unable to save logs. Please try again!');
+ .catch((error) => {
+   alert(error.response.data.message);
});

This is the quickest and easiest approach, but isn’t particularly reusable. What about other controllers that are called via ajax? What if the security setup changes in the future, will we have to update all of the controllers?

Approach 2 - a custom exception listener

A more reusable approach is to create an event listener that listens for an AccessDeniedException, returning a JsonResponse if the request is an ajax request and there is no logged in user:

<?php

namespace App\EventListener;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security;

class LoggedOutAjaxListener
{
    private $security;

    public function __construct(Security $security)
    {
        $this->security = $security;
    }

    public function onKernelException(ExceptionEvent $event)
    {
        $request = $event->getRequest();
        if (($request->getAcceptableContentTypes()[0] ?? '') !== 'application/json') {
            // Not an ajax request
            return;
        }

        if (!$event->getThrowable() instanceof AccessDeniedException) {
            // Pass all other exceptions to the next exception listener
            return;
        }

        if ($this->security->isGranted('ROLE_USER')) {
            // The user is logged in already, the access denied exception is for something else
            return;
        }

        $event->setResponse(new JsonResponse([
            'message' => "You're not logged in! Use another tab to login, then try the request again.",
        ]));
    }
}

Note that the listener needs to be registered with a priority higher than the Symfony firewall:

App\EventListener\LoggedOutAjaxListener:
    tags:
        - {name: kernel.event_listener, event: kernel.exception, priority: 8}

This approach has the advantage of keeping the logic in one place, which can be easily modified if requirements change.

Approach 3 - using a custom Guard authenticator

Instead of working around the form_login security logic, an alternative approach is to write your own logic.

A custom Guard authenticator could be easily written to return a JsonResponse in certain circumstances:

<?php

namespace App\Security;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;

class CustomFormAuthenticator extends AbstractFormLoginAuthenticator
{
    // ...

    public function start(Request $request, AuthenticationException $authException = null)
    {
        if (($request->getAcceptableContentTypes()[0] ?? '') === 'application/json') {
            return new JsonResponse([
                'message' => "You're not logged in! Use another tab to login, then try the request again.",
            ]);
        }

        // Not an ajax request, so redirect to the login form as usual
        return new RedirectResponse('/login');
    }

    // ...
}
  security:
      firewalls:
          main:
              pattern: '^/'
-             form_login:
-                 login_path: login
-                 check_path: login
+             guard:
+                 authenticators:
+                     - App\Security\CustomFormAuthenticator
              anonymous: false

Approach 4 - the new Symfony authenticator system

An exciting new development in Symfony 5.1 is the new Authenticator-based Security system, which promises to make it even easier to write custom authentication logic. Again, the code could be easily extended to return a different response when the unauthenticated request looks like an ajax request.

Read more about the rationale behind the new system on the author Wouter’s blog here: Meet the new Symfony Security: Authenticators .

Conclusion

As with many things in Symfony, there are many ways to extend and modify the framework to suit different requirements.

For our system we actually went with approach 1. The work log form is one of the few ajax controllers in the application that submits data, and we didn’t want to spend too long writing a fix for the sake of an internal application. Perhaps in the future we’ll create a more comprehensive solution, maybe using Symfony 5.1’s new authentication system, but for now this solution works well.

These decisions are often a tradeoff between the “most correct” solution and the solution that works now. At Backbeat we like to aim for a pragmatic balance between the two.

Do you have a Symfony authentication issue you need help with? Send us an email, we’d love to help.

More from the blog

Symfony routing tricks (part 2) cover image

Symfony routing tricks (part 2)

Writing a custom router to handle some unusual requirements.


Glynn Forrest
Tuesday, March 31, 2020

Symfony routing tricks (part 1) cover image

Symfony routing tricks (part 1)

Advanced routing techniques for your Symfony applications.


Glynn Forrest
Saturday, February 29, 2020

Use Bootstrap 3 and 4 form themes in the same Symfony project cover image

Use Bootstrap 3 and 4 form themes in the same Symfony project

How to get different sections of your project using different form themes automatically.


Glynn Forrest
Monday, February 8, 2021