05 March 2020

Symfony 4: Using the JSON Login system with Symfony's Remember Me service

As with all topics related to software development, there is always more than one solution to a problem. The following proposition is simply one possible solution and not intended to be definitive.

Summary:

  • Configure the security firewall with a JSON login and Remember Me listener
  • Create a public Alias for the Symfony's private Remember Me Service; including the RememberMeServicesInterface to enable Autowiring
  • Add a Login Controller Method for creating the Remember Me Cookie

 

If you're building a Restful API using Symfony 4, conventional wisdom suggests that JSON Web Tokens (JWT) should be your go to solution for authenticating clients... However, in this fancy, modern world of Single Page Applications (SPAs), JavaScript frameworks (Angular, React, Vue, et al) and jQuery, quite often you'll have a website or blog, with membership services, that will have integrated numerous asynchronous transactions between the client and server.

The standard Symfony Form Login system includes a simple and reliable system for allowing members to authenticate and remain logged in beyond the expiry of the PHP session. Unfortunately, but for valid reasons, Symfony's JSON Login doesn't provide this feature.

If you're the inquisitive type, you can review Symfony's source and at approximately line 80 you'll realise that you're never going to make the JSON Login "remember you". It's a hard-coded NO!

Circumventing this restriction isn't difficult - Once you know how...

Firstly, ensure you set-up your configuration files so that you're security firewall has the remember me available. You will have to include the "form_login" section otherwise you'll get the following exception:

You must configure at least one remember-me aware listener (such as form-login)
for each firewall that has remember-me enabled.

You can always go and configure a remember me aware listener and/or a ​GuardAuthenticator​ (the "elite" thing to do), but you can also avoid this (and save development time) by adding two lines to the "security.yaml" config.

# config/packages/security.yaml
security:
    # ...
    firewalls:
        # ...	
        main:
            remember_me:
                secret: '%kernel.secret%'
                lifetime: 604800 # in seconds
                path: /
                always_remember_me: false
                
            json_login:
                remember_me: true
                check_path: api_auth_login

            form_login:
                remember_me: true

You will also need to create a public alias for the remember me service so that you can access it within your Application's Controllers or Services using Dependency Injection (aka Autowiring):

			
# config/services.yaml
security:
    # ...	
	
    services:
        # ...	

        # This section allows you to access via the container
# '.main' must match the firewall provider key app.rememberme.services: alias: security.authentication.rememberme.services.simplehash.main public: true # This section enables autowiring of the Remember Me Service Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface: '@app.rememberme.services'

As a final step, you need to complete the Remember Me process inside your own Login method within your App's Authentication Controller or within the Authentication Success handler defined by the firewall (i.e. success_handler).

  
# src/Controller/AuthController.php

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;

use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Http\RememberMe\RememberMeServicesInterface;

use App\Entity\MyUserEntity;

class AuthController extends AbstractController
{
  /**
  * @Route("/api/auth/login", name="api_auth_login", methods={"POST"})
  */
  public function login(
      TokenStorageInterface $tokenStorage,
      RememberMeServicesInterface $rememberMeService,
      Request $request
  ){
    $user = $this->getUser();
    if( $user instanceof MyUserEntity ){

      // Create a response object so we can attach the Remember Me cookie
      // to be sent back to the client
      $response = new JsonResponse( [
        'username' => $user->getUsername(),
        'roles' => $user->getRoles()
        ... code
      ] );


      // Capture the JSON Payload posted from the client
      $payload = [];
      if( $request->getContentType() === 'json' ) {
        $payload = json_decode($request->getContent(), true);
      
        if (json_last_error() !== JSON_ERROR_NONE) {
          throw new \Exception('Invalid json: ' . json_last_error_msg() );
        }
      }

      // Look for the "_remember_me" form field and cast it to a Boolean
      $rememberMe = isset($payload['_remember_me']) 
        ? filter_var( $payload['_remember_me'], FILTER_VALIDATE_BOOLEAN )
        : false;
      
      // Set the remember me token
      if( $rememberMe ){
      
        $securityToken = $tokenStorage->getToken();
        
        $rememberMeService->loginSuccess(
          $request,
          $response,
          $securityToken
        );
      }

      return $response;
    }
    
    new JsonResponse( [
      'message' => 'Login failed'
    ]);
  }

  ... code
}

TIP:
Symfony 4+ discourages direct access to the "container" and suggests using dependency injection.

$rememberMeService= $this->container->get('app.rememberme.services');

Service ... not found: even though it exists in the app's container, the container inside ... 
is a smaller service locator that only knows about the... Try using dependency injection instead.

 As an additional example, on the client-side you'll probably do something like this:

# Vanilla JavaScript
const login = (username, password, rememberMe) => {
  return fetch("https://www.domain.com.au/auth/login", {
      method: "POST",

      // With every request
      credentials: "include",

      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        username: username,
        password: password,
        _remember_me: rememberMe
      })
    })
    .then( response => response.json() );
}

# Angular Service
public login(username: string, password: string, rememberMe: boolean): Observable<User> {
    return this.http.post<User>(`https://www.domain.com.au/auth/login`, {
      username: username,
      password: password,
      _remember_me: rememberMe
    }, 
    {
       // With every request (tip: Use an Interceptor)
       withCredentials: true
    });
}

# jQuery AJAX
var login = function( username, password, rememberMe ){
    return $.ajax({
        type: "POST",
        contentType: "application/json",
        url: "https://www.domain.com.au/auth/login",
        xhrFields: {
           // With every request
           withCredentials: true
        },
        data: JSON.stringify( {
            username: username,
            password: password,
            _remember_me: rememberMe
        } )
    });
}

 

Cross-Origin Resource Sharing (CORS) trouble

Keep in mind that when you're working with RESTful services and client apps. you are bound to run into Cross-Origin Resource Sharing (CORS) trouble, especially when your client is running locally but your service is remote.

Access to XMLHttpRequest at 'https://domainA.com.au/auth/login' from origin 
'https://domainB.com.au/auth/login' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin'
header is present on the requested resource.

An easy way to get going is to check out this Symfony CORS bundle.  

 


PurcellYoon are a team of expert Symfony Developers with a passion for creating exceptional digital experiences. We are committed to delivering superior Symfony Applications for all our partners and clients.

We'd love to talk with you about Symfony Development.
More questions? Get in touch.