Search code examples
symfonysymfony-3.4symfony-security

How to handle an Ajax call on pages that requires authentication and / or authorization?


If a page needs authentication and no User is found Symfony simply redirects or shows the login page. So simple enough I got that working.

Next, I would like to send a custom message (or html) if the User makes an Ajax call inside a page that requires authentication, but the session has died for instance (the User is not authenticated anymore).

security.yml

security:
    encoders:
        AppBundle\Entity\User:
            algorithm: bcrypt

    role_hierarchy:
        ROLE_ADMIN: ROLE_USER

    providers:
        db_provider:
            entity:
                class: AppBundle:User

    firewalls:
        # disables authentication for assets and the profiler, adapt it according to your needs
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        main:
            anonymous: ~

            pattern: ^/

            form_login:
                login_path: security_login
                check_path: security_login
                use_forward: false
                failure_handler: AppBundle\Security\AuthenticationHandler

            logout:
                path: /logout
                target: /

            access_denied_handler: AppBundle\Security\AccessDeniedHandler

    access_control:
        - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/, role: ROLE_ADMIN }

I have tried to intercept an event error by using access_denied_handler or failure_handler.

AppBundle\Security\AccessDeniedHandler.php

namespace AppBundle\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;

class AccessDeniedHandler implements AccessDeniedHandlerInterface {

    public function handle(Request $request, AccessDeniedException $exception) {

        return new JsonResponse([
            'success' => 0,
            'error'   => 1,
            'message' => $exception -> getMessage(),
            'from'    => 'AccessDeniedHandler'
        ]);
    }
}

AppBundle\Security\AuthenticationHandler.php

namespace AppBundle\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;

class AuthenticationHandler implements AuthenticationFailureHandlerInterface {

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception) {
        return new JsonResponse(['error' => 1, 'from' => 'AuthenticationHandler']);
    }
}

None of those classes are accessed. What am I missing?


Solution

  • Notes

    Created for a Symfony 3.4 project, should be compatible with Symfony 4, but I haven't tested;

    All services are auto-wired, so there's nothing to add in services.yml

    I'm not using FOSUserBundle;

    I'm not following Symfony coding standars;

    I've made notes, here and there; also I've put some comments in the code itself;

    The important part is at the end (LoginFormAuthenticator), I'm posting the whole code, hopefully someone will have an easier time than me.

    Source of inspiration:

    https://symfony.com/doc/3.4/security.html

    https://symfonycasts.com/screencast/symfony3-security

    https://www.sitepoint.com/easier-authentication-with-guard-in-symfony-3/

    Wall of code

    security.yml

    Security configuration

    For the "memory" user the username and password is "admin"

    security:
        encoders:
            Symfony\Component\Security\Core\User\User:
                algorithm: bcrypt
    
            AppBundle\Entity\User:
                algorithm: bcrypt
    
        role_hierarchy:
            ROLE_ADMIN:       ROLE_USER
            ROLE_SUPER_ADMIN: ROLE_ADMIN
    
        providers:
            chain_provider:
                chain:
                    providers: [memory_provider, db_provider]
    
            memory_provider:
                memory:
                    users:
                        admin:
                            password: '$2y$13$21gXkzksqlR68HhAYB2WLOqcQvJZzgIrSH/KRq1aEzkkOnjI7lR9e'
                            roles: 'ROLE_SUPER_ADMIN'
    
            db_provider:
                entity:
                    class: AppBundle:User
                    property: email
    
        firewalls:
            # disables authentication for assets and the profiler, adapt it according to your needs
            dev:
                pattern: ^/(_(profiler|wdt)|css|images|js)/
                security: false
    
            main:
                anonymous: ~
    
                pattern: ^/
    
                logout:
                    path: /logout
                    target: /
    
                guard:
                    authenticators:
                        - AppBundle\Security\LoginFormAuthenticator
    
        access_control:
            - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
            - { path: ^/, role: ROLE_USER }
    

    Template app/Resources/views/Security/_content.login.html.twig

    {% set form_action = path('security_login') %}
    
    <form action="{{ form_action }}" method="post" autocomplete="off" id="login_f">
        {% if error %}
            <div class="login_form_error">{{ error.messageKey }}</div>
        {% endif %}
    
        <div class="closed">
            <input type="hidden" name="_csrf_token" value="{{ csrf_token(login_csrf_token) }}" />
        </div>
    
        <div class="login_field login_field_0">
            <label for="login_username" class="login_l">
                <i class="fas fa-user"></i>
            </label>
            <input type="text" class="login_i" id="login_username" name="_username" placeholder="Username" />
        </div>
    
        <div class="login_field login_field_1">
            <label for="login_password" class="login_l">
                <i class="fas fa-key"></i>
            </label>
            <input type="password" class="login_i" id="login_password" name="_password" placeholder="Password" />
        </div>
    
        <div>
            <input type="submit" class="login_bttn" id="_submit" value="Login" />
        </div>
    </form>
    

    Template app/Resources/views/Security/login.html.twig

    No need for base.html.twig

    {% extends 'base.html.twig' %}
    
    {% block content %}
        <div id="login_c">
            {% include 'Security/_content.login.html.twig' %}
        </div>
    {% endblock %}
    

    The service

    Renders the login page or the login content

    Replace the CSRF_TOKEN constant value with your own

    namespace AppBundle\Services\User;
    
    use Symfony\Component\Templating\EngineInterface;
    use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
    
    class LoginFormService {
    
        private $templatingEngine;
        private $authenticationUtils;
    
        const CSRF_TOKEN = 'login:token:w4kSzA3v5VJyb4aWLbV7stAY92cNwgL77J6QrXpU!';
    
        function __construct(
            EngineInterface $templatingEngine,
            AuthenticationUtils $authenticationUtils) {
    
            $this -> templatingEngine    = $templatingEngine;
            $this -> authenticationUtils = $authenticationUtils;
    
        }
    
        function getHtml($contentOnly = False) {
    
            // last username entered by the user
            $lastUsername = $this -> authenticationUtils -> getLastUsername();
    
            // get the login error if there is one
            $error = $this -> authenticationUtils -> getLastAuthenticationError();
    
            $html_vars = array(
                'lastUsername'     => $lastUsername,
                'error'            => $error,
                'login_csrf_token' => self::CSRF_TOKEN,
            );
    
            $html_template = 'Security/login.html.twig';
            if ( $contentOnly ) {
                $html_template = 'Security/_content.login.html.twig';
            }
    
            $html = $this -> templatingEngine -> render($html_template, $html_vars);
    
            return $html;
        }
    
    }
    

    The login controller

    A simple buffer controller to render the login page if the user will access /login

    namespace AppBundle\Controller;
    
    use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
    use Symfony\Bundle\FrameworkBundle\Controller\Controller;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\Response;
    
    use AppBundle\Services\User\LoginFormService;
    
    class SecurityController extends Controller {
    
    
        /**
         @Route("/login", name="security_login")
        */
        public function loginAction(LoginFormService $loginFormService, Request $request) {
            return new Response($loginFormService -> getHtml());
        }
    
        /**
         @Route("/logout", name="security_logout")
        */
        public function logoutAction() {}
    
    }
    

    The Guard authenticator

    Instead of "project_homepage_route" use whatever route you want

    namespace AppBundle\Security;
    
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\HttpFoundation\RedirectResponse;
    use Symfony\Component\HttpFoundation\JsonResponse;
    use Symfony\Component\Security\Core\Security;
    use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
    use Symfony\Component\Security\Core\User\UserInterface;
    use Symfony\Component\Security\Core\User\UserProviderInterface;
    use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
    use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
    use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
    use Symfony\Component\Security\Core\Exception\AuthenticationException;
    use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException;
    use Symfony\Component\Security\Csrf\CsrfToken;
    use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
    use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
    use Symfony\Component\Routing\RouterInterface;
    
    use AppBundle\Services\User\LoginFormService;
    
    class LoginFormAuthenticator extends AbstractGuardAuthenticator {
    
        private $router;
        private $templatingEngine;
        private $passwordEncoder;
        private $csrfTokenManager;
    
        private $loginService;
    
        protected $auth_error_csrf    = 'Invalid CSRF token!!!';
        protected $auth_error_message = 'Invalid credentials!!!';
    
        function __construct(
            RouterInterface $router, 
            UserPasswordEncoderInterface $passwordEncoder,
            CsrfTokenManagerInterface $csrfTokenManager,
            LoginFormService $loginService) {
    
            $this -> router           = $router;
            $this -> passwordEncoder  = $passwordEncoder;
            $this -> csrfTokenManager = $csrfTokenManager;
            $this -> loginService     = $loginService;
    
        }
    
        /* Methods */
    
        protected function loginResponse(Request $request, $forbidden = False) {
    
            // The javascript library must set the 'X-Requested-With' header
            // xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
            if ( $request -> isXmlHttpRequest() ) {
                $response = new JsonResponse([
                    'error' => 1,
                    'html'  => $this -> loginService -> getHtml(True)
                ]);
            } else {
                $html = $this -> loginService -> getHtml();
                $response = new Response($html);
            }
    
            if ($forbidden) {
                $response -> setStatusCode(Response::HTTP_FORBIDDEN);
            }
    
            return $response;
        }
    
        /* AbstractGuardAuthenticator methods */
    
        public function supports(Request $request) {
            return $request -> attributes -> get('_route') === 'security_login' && $request -> isMethod('POST');
        }
    
        public function getCredentials(Request $request) {
    
            // Add csrf protection
            $csrfData  = $request -> request -> get('_csrf_token');
            $csrfToken = new CsrfToken(LoginFormService::CSRF_TOKEN, $csrfData);
    
            if ( !$this -> csrfTokenManager -> isTokenValid($csrfToken) ) {
                throw new InvalidCsrfTokenException( $this -> auth_error_csrf );
            }
    
            return array(
                'username' => $request -> request -> get('_username'),
                'password' => $request -> request -> get('_password'),
            );
        }
    
        public function getUser($credentials, UserProviderInterface $userProvider) {
    
            $username = $credentials['username'];
    
            try {
                return $userProvider -> loadUserByUsername($username);
            } catch (UsernameNotFoundException $e) {
                throw new CustomUserMessageAuthenticationException( $this -> auth_error_message );
            }
    
            return null;
        }
    
        public function checkCredentials($credentials, UserInterface $user) {
    
            $is_valid_password = $this -> passwordEncoder -> isPasswordValid($user, $credentials['password']);
    
            if ( !$is_valid_password ) {
                throw new CustomUserMessageAuthenticationException( $this -> auth_error_message );
                return;
            }
    
            return True;
        }
    
        public function onAuthenticationFailure(Request $request, AuthenticationException $authException) {
    
            $session = $request -> getSession();
            $session -> set(Security::AUTHENTICATION_ERROR, $authException);
            $session -> set(Security::LAST_USERNAME, $request -> request -> get('_username'));
    
            // Shows the login form instead of the page content
            return $this -> loginResponse($request, True);
    
            // If you want redirect make sure the line below is used
            // return new RedirectResponse($this -> router -> generate('security_login'));
        }
    
        public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) {
    
            if ( $request -> isXmlHttpRequest() ) {
                return new JsonResponse([
                    'success' => 1,
                    'message' => 'Authentication success!'
                ]);
            }
    
            return new RedirectResponse($this -> router -> generate('project_homepage_route'));
        }
    
        public function start(Request $request, AuthenticationException $authException = null) {
    
            // Shows the login form instead of the page content
            return $this -> loginResponse($request);
    
            // If you want redirect make sure the line below is used
            // return new RedirectResponse($this -> router -> generate('security_login'));
        }
    
        public function supportsRememberMe() {
            return false;
        }
    }