Skip to content
This repository has been archived by the owner on Dec 2, 2021. It is now read-only.

Two-factor authentication form is not shown after login #289

Closed
hardiksinh opened this issue May 29, 2020 · 16 comments
Closed

Two-factor authentication form is not shown after login #289

hardiksinh opened this issue May 29, 2020 · 16 comments
Labels

Comments

@hardiksinh
Copy link

Bundle version: 4.16.0 and also tried downgrading to 3.29.0
Symfony version: 4.3.3

Description

Followed Troubleshooting guide and reached at step 5 which returns email as one value in array.

config\packages\security.yaml

security:
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    encoders:
        FOS\UserBundle\Model\UserInterface: bcrypt

    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN: ROLE_ADMIN

    providers:
#        in_memory: { memory: ~ }
        fos_userbundle:
            id: fos_user.user_provider.username
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            provider: fos_userbundle
            pattern: ^/
            user_checker: fos_user.user_checker
            form_login:
                provider: fos_userbundle
                csrf_token_generator: security.csrf.token_manager
            logout:
                handlers: [app_logoutlistener]
            anonymous:    true
            access_denied_handler: App\Security\AccessDeniedHandler
            two_factor:
                provider: fos_userbundle
                auth_form_path: /2fa                  # Path or route name of the two-factor form
                check_path: /2fa_check                # Path or route name of the two-factor code check
                #post_only: false                      # If the check_path should accept the code only as a POST request
                default_target_path: /                # Where to redirect by default after successful authentication
                always_use_default_target_path: false # If it should always redirect to default_target_path
                auth_code_parameter_name: _auth_code  # Name of the parameter for the two-factor authentication code
                trusted_parameter_name: _trusted      # Name of the parameter for the trusted device option
                multi_factor: false                   # If ALL active two-factor methods need to be fulfilled
                                                      # (multi-factor authentication)

            # activate different ways to authenticate

            # http_basic: true
            # https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate

            # form_login: true
            # https://symfony.com/doc/current/security/form_login_setup.html

    # Easy way to control access for large sections of your site
    # Note: Only the *first* access control that matches will be used
    access_control:
        - { path: ^/api/mobile/, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/api/backend/, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/application$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/logout, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/notification/add, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/user/enroll, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS }
        - { path: ^/, role: ROLE_USER }
        # - { path: ^/admin, roles: ROLE_ADMIN }
        # - { path: ^/profile, roles: ROLE_USER }

config\routes.yaml

filter_sidebar:
    path: /filter_sidebar
    controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
    defaults:
        # the path of the template to render
        template:  'filter_sidebar.html.twig'

fos_user:
    resource: "@FOSUserBundle/Resources/config/routing/all.xml"

css_route:
       path: /agenda_dynamic_style
       controller: App\Controller\StyleController::style

2fa_login:
    path: /2fa
    defaults:
        # "scheb_two_factor.form_controller" references the controller service provided by the bundle.
        # You don't HAVE to use it, but - except you have very special requirements - it is recommended.
        _controller: "scheb_two_factor.form_controller:form"

2fa_login_check:
    path: /2fa_check

config\packages\scheb_two_factor.yaml

scheb_two_factor:
    security_tokens:
        - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
        # If you're using guard-based authentication, you have to use this one:
        # - Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken
    
    email:
        enabled: true                  # If email authentication should be enabled, default false
        #mailer: app.custom_mailer_service  # Use alternative service to send the authentication code
        #code_generator: app.custom_code_generator_service  # Use alternative service to generate authentication code
        sender_email: "%env(resolve:MAILER_USER)%"   # Sender email address
        sender_name: "%env(resolve:MAILER_USER)%"       # Sender name
        digits: 6                      # Number of digits in authentication code
        template: security/2fa_form.html.twig   # Template used to render the authentication form
    
    trusted_device:
        enabled: false                 # If the trusted device feature should be enabled
        #manager: acme.custom_trusted_device_manager  # Use a custom trusted device manager
        lifetime: 5184000              # Lifetime of the trusted device token
        extend_lifetime: false         # Automatically extend lifetime of the trusted cookie on re-login
        cookie_name: trusted_device    # Name of the trusted device cookie
        cookie_secure: false           # Set the 'Secure' (HTTPS Only) flag on the trusted device cookie
        cookie_same_site: "lax"        # The same-site option of the cookie, can be "lax", "strict" or null
        cookie_domain: ".example.com"  # Domain to use when setting the cookie, fallback to the request domain if not set
        cookie_path: "/"               # Path to use when setting the cookie
    
    backup_codes:
        enabled: false                 # If the backup code feature should be enabled
        #manager: acme.custom_backup_code_manager  # Use a custom backup code manager

    # The service which is used to persist data in the user object. By default Doctrine is used. If your entity is
    # managed by something else (e.g. an API), you have to implement a custom persister
    #persister: acme.custom_persister

    # If your Doctrine user object is managed by a model manager, which is not the default one, you have to
    # set this option. Name of entity manager or null, which uses the default one.
    model_manager_name: ~

    # A list of IP addresses or netmasks, which will not trigger two-factor authentication.
    # Supports IPv4, IPv6 and IP subnet masks.
    ip_whitelist:
        #- 127.0.0.1 # One IPv4
        #- 192.168.0.0/16 # IPv4 subnet
        #- 2001:0db8:85a3:0000:0000:8a2e:0370:7334 # One IPv6
        #- 2001:db8:abcd:0012::0/64 # IPv6 subnet

    # If you want to have your own implementation to retrieve the whitelisted IPs.
    # The configuration option "ip_whitelist" becomes meaningless in such a case.
    #ip_whitelist_provider: acme.custom_ip_whitelist_provider

    # If you want to exchange/extend the TwoFactorToken class, which is used by the bundle, you can have a factory
    # service providing your own implementation.
    #two_factor_token_factory: acme.custom_two_factor_token_factory

src\Entity\User.php

<?php

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Doctrine\ORM\Mapping\AttributeOverrides;
use Doctrine\ORM\Mapping\AttributeOverride;
use FOS\UserBundle\Model\User as BaseUser;
...
use Scheb\TwoFactorBundle\Model\Email\TwoFactorInterface;

class User extends BaseUser implements TwoFactorInterface
{
     /**
     * @ORM\Column(type="integer", nullable=true)
     */
    private $authCode;

...
    public function isEmailAuthEnabled(): bool
    {
        return true; // This can be a persisted field to switch email code authentication on/off
    }

    public function getEmailAuthRecipient(): string
    {
        return $this->email;
    }

    public function getEmailAuthCode(): string
    {
        return $this->authCode;
    }

    public function setEmailAuthCode(string $authCode): void
    {
        $this->authCode = $authCode;
    }

Additional Context

Using FOSUserBundle for authentication.
Using Doctrine2 behavioral extension for soft-delete (just for the info, because another extension, audit logger extension, I once used had event subscriber priority related issue, could that be the case?).

@hardiksinh hardiksinh added the Bug label May 29, 2020
@scheb scheb added Support and removed Bug labels May 30, 2020
@scheb
Copy link
Owner

scheb commented May 30, 2020

If the 2fa is not shown, what's shown instead? I've seen that your entire application is protected with - { path: ^/, role: ROLE_USER }, so none of the pages should be accessible after login.

What happens when you navigate to /2fa manually? Does it show the 2fa form?

@hardiksinh
Copy link
Author

hardiksinh commented May 31, 2020

All the pages are accessible(based on dynamic roles I have) except /2fa, that redirects me to access denied page I have.

My User entity role getter updated with custom roles I have in my permissions table, which gets assigned based on user group to different users. Because in user table by default all user will have ROLE_USER while admin will have ROLE_ADMIN, others will be from permissions and group relationship as shown in User entity getRoles().

public function getRoles()
    {
        $roles = $this->roles;
        // guarantee every user at least has ROLE_USER
        $roles[] = 'ROLE_USER';

        // Add group assinged roles
        if ($this->typegroup) {
            $permissions = $this->typegroup->getPermissions();
            if ($permissions) {
                foreach ($permissions as $permission) {
                    $roles[] = $permission->getRole();
                }
            }
        }

        return array_unique($roles);
    }

I also have event listener which gets called on SecurityEvents::INTERACTIVE_LOGIN

My custom event subscriber class which adds all the permissions(ROLES) to admin/superadmin users.

<?php

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\Security\Http\SecurityEvents;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Doctrine\ORM\EntityManagerInterface;
use FOS\UserBundle\Model\UserInterface;
use FOS\UserBundle\Model\UserManagerInterface;
use App\Entity\Permission;
use App\Entity\FrontendConfig;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\SessionInterface;

class ControllerSubscriber implements EventSubscriberInterface
{
    private $tokenStorage;

    private $em;

    protected $userManager;

    protected $authenticationContextProvider;

    public function __construct(TokenStorageInterface $tokenStorage, 
                                EntityManagerInterface $em, 
                                UserManagerInterface $userManager, 
                                SessionInterface $session)
    {
        $this->tokenStorage = $tokenStorage;
        $this->em = $em;
        $this->userManager = $userManager;
        $this->session = $session;
    }

    public function onKernelController(ControllerEvent $event)
    {
        $frontEndConfig = $this->em->getRepository(FrontendConfig::class)->findAll();
        $config_arr = [];
        foreach($frontEndConfig as $config) {
            $config_arr[$config->getAliasName()] = $config->getValue();
        }

        $this->session->set('frontEndConfig', $config_arr);
    }

    /**
     * @param InteractiveLoginEvent $event
     */
    public function onSecurityInteractiveLogin(InteractiveLoginEvent $event)
    {
        $token = $this->tokenStorage->getToken();
        $user = $token->getUser();

        if ($user instanceof UserInterface) {
            //Add all permissions for admin and superadmin users
            $user_roles = $user->getRoles();

            if (in_array('ROLE_ADMIN', $user_roles) || in_array('ROLE_SUPER_ADMIN', $user_roles) || in_array('ROLE_TEACHER', $user_roles)) {

                if (in_array('ROLE_TEACHER', $user_roles)) {
                    $permissionsNeedToBeMerged = [  'ROLE_CREATE_MEETING', 
                                                    'ROLE_VIEW_AGENDA_MEETINGS', 
                                                    'ROLE_VIEW_AGENDA_HOLIDAYS',
                                                    'ROLE_VIEW_AGENDA_BREAKS'
                                                ];
                    $user_roles = array_merge($user_roles, $permissionsNeedToBeMerged);
                } else {
                    $permissions = $this->em->getRepository(Permission::class)->findAll();
                    foreach ($permissions as $permission) {
                        $user_roles[] = $permission->getRole();
                    }
                }

                // there didn't seem to be an easier way to grab the provider key, 
                // so using bound closure to retrieve it
                $providerKeyGetter = function($token) {
                    return $token->providerKey;
                };
                $boundProviderKeyGetter = \Closure::bind($providerKeyGetter, null, $token);

                // check & load roles for user here if necessary

                $this->tokenStorage->setToken(
                    new UsernamePasswordToken(
                        $user,
                        $token->getCredentials(),
                        $boundProviderKeyGetter($token), //main == firewall setting
                        array_unique($user_roles)
                    )
                );
            }
         }
    }

    public static function getSubscribedEvents()
    {
        return [
            ControllerEvent::class => 'onKernelController',
            SecurityEvents::INTERACTIVE_LOGIN => 'onSecurityInteractiveLogin',
        ];
    }
}

@scheb
Copy link
Owner

scheb commented May 31, 2020

ControllerSubscriber::onSecurityInteractiveLogin looks like a problem to me, It replaces the security token (which should be a TwoFactorToken at that point) with a UsernamePasswordToken. This will skip the 2fa process, which requires the TwoFactorToken to be present.

I'd suggest you change your code in ControllerSubscriber from:

if ($user instanceof UserInterface)

to:

if ($user instanceof UserInterface && $token instanceof UsernamePasswordToken)

This will make sure:

  • the TwoFactorToken is kept on login
  • your code is still executed, once 2fa was completed

@scheb
Copy link
Owner

scheb commented May 31, 2020

Alternatively, instead of using SecurityEvents::INTERACTIVE_LOGIN, you could also subscribe to the AuthenticationEvents::AUTHENTICATION_SUCCESS event, which is only dispatched after 2fa was completed.

@hardiksinh
Copy link
Author

Wow, super fast. Thanks for the replies. It now redirects to 2fa but could not load form, giving below error.

Unable to find template "security/2fa_form.html.twig"

@scheb
Copy link
Owner

scheb commented May 31, 2020

Well, that is the template you have configured in config\packages\scheb_two_factor.yaml:

scheb_two_factor:
    security_tokens:
        template: security/2fa_form.html.twig   # Template used to render the authentication form

Make sure the file is there ;)

@hardiksinh
Copy link
Author

Ohh, I forgot to add template thought using default you provided. Add now (had to remove logout and cancel link as I have different path). So now it redirects to form but no email process.

@hardiksinh
Copy link
Author

I have MAILER_DSN set which uses my Mailgun Account in env.

MESSENGER_TRANSPORT_DSN=doctrine://default

messagener:consume command already run on cmd and processing all other emails. Messenger table not updated with any email and no data on command verbose log.

@scheb
Copy link
Owner

scheb commented May 31, 2020

This method should be called to send the email. https://github.com/scheb/two-factor-bundle/blob/master/Mailer/AuthCodeMailer.php#L33

Once that method is called, it's the responsibility of your email setup to send-out the mail.

@hardiksinh
Copy link
Author

hardiksinh commented May 31, 2020

Yes, that method gets called. Thank you very much for your help. I will follow documentation for creating Custom Mailer from there I will need to send email using Mailer Interface.

@hardiksinh
Copy link
Author

hardiksinh commented May 31, 2020

Replacing
if ($user instanceof UserInterface)
with
if ($user instanceof UserInterface && $token instanceof UsernamePasswordToken)
redirects properly to /2fa and login works with auth code after implementing custom Mailer service.

Updated event you suggested from
SecurityEvents::INTERACTIVE_LOGIN
to
AuthenticationEvents::AUTHENTICATION_SUCCESS

However, instance of $token is not UsernamePasswordToken but \Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorToken in AuthenticationEvents::AUTHENTICATION_SUCCESS listener.

Hence, my code to update roles for user fails, due to if condition check fail as mentioned above.

Replacing
if ($user instanceof UserInterface && $token instanceof UsernamePasswordToken)
with
if ($user instanceof UserInterface && $token instanceof \Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorToken)
updates UsernamePasswordToken User instance roles but it does not update actual UsernamePasswordToken which I see after login in symfony profiler.

Updated subscriber class:

<?php

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\Security\Http\SecurityEvents;
use Symfony\Component\Security\Core\AuthenticationEvents;
use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Doctrine\ORM\EntityManagerInterface;
use FOS\UserBundle\Model\UserInterface;
use FOS\UserBundle\Model\UserManagerInterface;
use App\Entity\Permission;
use App\Entity\FrontendConfig;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\SessionInterface;

class ControllerSubscriber implements EventSubscriberInterface
{
    private $tokenStorage;

    private $em;

    protected $userManager;

    protected $authenticationContextProvider;

    public function __construct(TokenStorageInterface $tokenStorage, 
                                EntityManagerInterface $em, 
                                UserManagerInterface $userManager, 
                                SessionInterface $session)
    {
        $this->tokenStorage = $tokenStorage;
        $this->em = $em;
        $this->userManager = $userManager;
        $this->session = $session;
    }

    public function onKernelController(ControllerEvent $event)
    {
        $frontEndConfig = $this->em->getRepository(FrontendConfig::class)->findAll();
        $config_arr = [];
        foreach($frontEndConfig as $config) {
            $config_arr[$config->getAliasName()] = $config->getValue();
        }

        $this->session->set('frontEndConfig', $config_arr);
    }

    /**
     * @param AuthenticationSuccessEvent $event
     */
    public function onAuthenticationSuccess(AuthenticationSuccessEvent $event)
    {
        $token = $this->tokenStorage->getToken();

        if ($token) {

            $user = $token->getUser();

            if ($user instanceof UserInterface && $token instanceof \Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorToken) {
                //Add all permissions for admin and superadmin users
                $user_roles = $user->getRoles();

                if (in_array('ROLE_ADMIN', $user_roles) || in_array('ROLE_SUPER_ADMIN', $user_roles) || in_array('ROLE_TEACHER', $user_roles)) {

                    if (in_array('ROLE_TEACHER', $user_roles)) {
                        $permissionsNeedToBeMerged = [  'ROLE_CREATE_MEETING', 
                                                        'ROLE_VIEW_AGENDA_MEETINGS', 
                                                        'ROLE_VIEW_AGENDA_HOLIDAYS',
                                                        'ROLE_VIEW_AGENDA_BREAKS'
                                                    ];
                        $user_roles = array_merge($user_roles, $permissionsNeedToBeMerged);
                    } else {
                        $permissions = $this->em->getRepository(Permission::class)->findAll();
                        foreach ($permissions as $permission) {
                            $user_roles[] = $permission->getRole();
                        }
                    }

                    // there didn't seem to be an easier way to grab the provider key, 
                    // so using bound closure to retrieve it
                    $providerKeyGetter = function($token) {
                        return $token->providerKey;
                    };
                    $boundProviderKeyGetter = \Closure::bind($providerKeyGetter, null, $token);

                    // check & load roles for user here if necessary

                    $this->tokenStorage->setToken(
                        new UsernamePasswordToken(
                            $user,
                            $token->getCredentials(),
                            $boundProviderKeyGetter($token), //main == firewall setting
                            array_unique($user_roles)
                        )
                    );
                }
            }
        }
    }

    public static function getSubscribedEvents()
    {
        return [
            ControllerEvent::class => 'onKernelController',
            AuthenticationEvents::AUTHENTICATION_SUCCESS => 'onAuthenticationSuccess',
        ];
    }
}

@scheb
Copy link
Owner

scheb commented May 31, 2020

Please use $event->getAuthenticationToken() instead of $this->tokenStorage->getToken(). The one in the token storage is not necessary updated at that point.

And please note that AuthenticationEvents::AUTHENTICATION_SUCCESS is triggered twice. First directly after login (with a TwoFactorToken) and then a second time when 2fa was completed (with the original UsernamePasswordToken). Only on that second call your code should execute.

Btw., the way you're injecting these extra roles, I don't think this is the best way to do it, messing around with the security token. Overwriting the security token is a bit hacky, I believe the security token should only be modified by the security system. The more safe way to do this would probably be to write your own user provider, which adds these extra roles to the user when it's loaded from the database.

@hardiksinh
Copy link
Author

Thanks for the help and suggestions. Instead of replacing security token, updated User roles with UserManager interface and everything works fine.

                    $user->setRoles(array_unique($user_roles));
                    $this->userManager->updateUser($user);

@scheb
Copy link
Owner

scheb commented Jun 1, 2020

Sounds good to me 👍

@scheb
Copy link
Owner

scheb commented Jun 4, 2020

@hardiksinh Can we close this?

@hardiksinh
Copy link
Author

@hardiksinh Can we close this?

Yes

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

No branches or pull requests

2 participants