w0yyag
Last Updated: November 02, 2017
·
7.113K
· zdenekdrahos
Avatar

Redirect authenticated user on anonymous pages in Symfony

What should happen when an authenticated user goes to login page, registration page, forgotten password etc.? One option is to do nothing - just display the page and let the user decide what he wants to do. Redirecting the user might be a better solution.

Anonymous access in Symfony

Roles are hierarchical so following definition in app/config/security.yml causes that everybody can access login page. The authenticated user can access login page even when he has already logged in.

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

It can confuse users, especially if an app doesn't support switching between multiple accounts. Showing error page or redirecting the user to another page is better approach. Let's cover that behavior with test and experiment with different implementations.

Scenario Outline: Authenticated user is redirected on anonymous page
    Given I am logged as admin
    When I am on "<url>"
    Then I should not see "<text>"
    # And the url should match "/admin/" # assert if user is redirected

    Examples:
    | url                | text               |
    | /login             | Login              |
    | /resetting/request | Forgotten password |
    | /registration      | Create account     |

1. Deny access in Access Control

Update security expression without writing single line of code.Let's say you have default role ROLE_USER like in FOSUserBundle:

security:
    access_control:
        - { path: ^/login$, allow_if: "not has_role('ROLE_USER')" }
        - { path: ^/resetting, allow_if: "not has_role('ROLE_USER')" }

When authenticated (or remembered) user goes on login page then he ends up on 403 Forbidden page (AccessDeniedException). Tests passed, but if you want to redirect the user to another page, you have to listen on kernel.exception and then do do some magic in determining when the user is redirected.

If 403 Forbidden page is good enough for you then this is the simplest solution. Redirects needs to be handled somewhere else. Code smells, right? Using exceptions for control flow and code gets distributed into multiple places (condition in security.yml and redirect in listener).

2. Check user in controller(s)

First idea that comes to mind. Well, it's kinda naive approach, but it works. Copy-paste three controllers and voilá tests just passed. It's just three pages, so it's ok, isn't it?

class SomeController extends BaseController
{
    public function someAction()
    {
        if ($this->isUserLogged()) {
            return $this->redirectToRoute('somewhere');
        }
        // do default action
    }
}

Duplication in multiple controllers can become massive problem. Just imagine the code if every action needs to do such check. For example if you want to force users to change password every month? On top of that if you are using FOSUserBundle (or any other external user bundle) you have to override 3rd bundle's controllers. That's a lot of boilerplate code, so I'd rather avoid this solution. Don't repeat my mistakes and read StackOverflow more carefully :)

3. Listen on kernel.request event

Let's recap disadvantages of previous solutions:

  • ACL rule cannot define security check and redirected page in one place
  • Modifying controllers causes duplication and requires overriding external bundles

Symfony events solves it all. Subscribe kernel.request event, if conditions are met then redirect user:

services:
    app.tokens.action_listener:
        class: AppBundle\EventListener\RedirectUserListener
        arguments:
            - "@security.token_storage"
            - "@router"
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }
<?php

namespace AppBundle\EventListener;

use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use FOS\UserBundle\Model\User;

class RedirectUserListener
{
    private $tokenStorage;
    private $router;

    public function __construct(TokenStorageInterface $t, RouterInterface $r)
    {
        $this->tokenStorage = $t;
        $this->router = $r;
    }

    public function onKernelRequest(GetResponseEvent $event)
    {
        if ($this->isUserLogged() && $event->isMasterRequest()) {
            $currentRoute = $event->getRequest()->attributes->get('_route');
            if ($this->isAuthenticatedUserOnAnonymousPage($currentRoute) {
                $response = new RedirectResponse($this->router->generate('homepage'));
                $event->setResponse($response);
            }
        }
    }

    private function isUserLogged()
    {
        $user = $this->tokenStorage->getToken()->getUser();
        return $user instanceof User;
    }

    private function isAuthenticatedUserOnAnonymousPage($currentRoute)
    {
        return in_array(
            $currentRoute,
            ['fos_user_security_login', 'fos_user_resetting_request', 'app_user_registration']
        );
    }
}

This principle can be used for similar actions - when user must change automatically generated password, change password once in a year etc. Event will catch such user at every url (ignore profiler in dev mode, otherwise all profiler's requests will be redirected to change password page). Listener can even handle multiple conditions and redirects:

public function onKernelRequest(GetResponseEvent $event)
{
    $loggedUser = $this->getLoggedUser();
    if ($loggedUser && $event->isMasterRequest()) {
        $currentRoute = $event->getRequest()->attributes->get('_route');
        $redirectRoute = $this->getRedirectRoute($loggedUser, $currentRoute);
        if ($redirectRoute) {
            $response = new RedirectResponse($this->router->generate($redirectRoute));
            $event->setResponse($response);
        }
    }
}

private function getLoggedUser()
{
    $user = $this->securityContext->getToken()->getUser();
    return $user instanceof User ? $user : null;
}

private function getRedirectRoute($user, $currentRoute)
{
    if ($this->isChangingPassword($user, $currentRoute)) {
        return 'fos_user_change_password';
    } elseif ($this->isAuthenticatedUserOnAnonymousPage($currentRoute)) {
        return 'homepage';
    }
}

private function isChangingPassword($user, $currentRoute)
{
    return $user->hasRole('ROLE_CHANGE_PASSWORD')
        && strpos($currentRoute, '_profiler') === false
        && !in_array($currentRoute, ['_wdt', 'fos_user_change_password']);
}
Say Thanks
Respond