0

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?

StefanNch
  • 2,569
  • 24
  • 31
  • know is not the perfect answer, but you have tried to keep anonymously the acl of the ajax call, and inside check the authentication? so you can manage the response, with a message if the user is not authenticate. Similar than a api logic. – NicolaPez Sep 23 '18 at 07:25
  • Well thats what i'm trying to do, when someone makes a call to check if is authenticated, but symfony will just send as response the login page. I want to modify the default behaviour and send a custom message. The only solution I haven't tried yet is for every Controller to put something like if `isGranted(ROLE)` and throw an AccessDenied error. But it seems strange even if I automate the process and don't copy/paste it in every method. – StefanNch Sep 23 '18 at 08:17
  • I think you could take the approach of using a custom stateless authenticator. Send the auth token along with the AJAX request and the firewall will handle the authentication automatically. – Gimsly Sep 23 '18 at 09:03
  • You should try to remove use_forward: true option – iiirxs Sep 23 '18 at 22:37
  • use_forward it's a behaviour that I want – StefanNch Sep 25 '18 at 14:21

1 Answers1

0

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;
    }
}
StefanNch
  • 2,569
  • 24
  • 31