Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
nmeirik opened this issue Aug 23, 2020 · 10 comments
Closed

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

nmeirik opened this issue Aug 23, 2020 · 10 comments
Labels

Comments

@nmeirik
Copy link

nmeirik commented Aug 23, 2020

Bundle version: 5.1.0
Symfony version: 5.1.3

Description
I'm attempting to get the two-factor authentication form to appear for a user which has a googleAuthenticatorSecret. The secret has been created by the service auto-injected for the GoogleAuthenticatorInterface.

However, when I attempt to log in, I'm logged normally, without ever being sent to 2fa_login route to inout the two factor code. The debug toolbar indicates that I do receive the Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorToken, though.

I've tried following https://github.com/scheb/2fa/blob/5.x/doc/troubleshooting.md#two-factor-authentication-form-is-not-shown-after-login and discovered the following:

  1. Is a TwoFactorToken present after the login?: Yes (or so the debug toolbar says)
  2. Try accessing a page that requires the user to be authenticated. Does it redirect to the two-factor authentication form?: No
  3. On login, do you reach the end (return statement) of method Scheb\TwoFactorBundle\Security\Authentication\Provider\AuthenticationProviderDecorator::authenticate()?: Yes
  4. On login, is method Scheb\TwoFactorBundle\Security\TwoFactor\Handler\TwoFactorProviderHandler::getActiveTwoFactorProviders() called?: Yes
  5. Does Scheb\TwoFactorBundle\Security\TwoFactor\Handler\TwoFactorProviderHandler::getActiveTwoFactorProviders() return any values?: Yes:
^ array:1 [▼
  0 => "google"
]

The only time I'm able to trigger the two factor authentication form is when I start of by going to the 2fa_login route (/2fa). In that case, I get redirected to the 2fa input form after providing my username and password, and the authentication appears to work as expected.

The content of my security.yaml file is as follows:

security:

    providers:
        fos_userbundle:
            id: fos_user.user_provider.username

    encoders:
        FOS\UserBundle\Model\UserInterface: auto

    role_hierarchy:
        ROLE_EMPLOYEE:    [ ROLE_USER, ROLE_ALLOWED_TO_SWITCH ]
        ROLE_ADMIN:       [ ROLE_USER, ROLE_EMPLOYEE ]
        ROLE_SUPER_ADMIN: [ ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH ]

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        main:
            pattern: ^/
            form_login:
                provider: fos_userbundle
                csrf_token_generator: security.csrf.token_manager
            logout:       true
            anonymous:    lazy
            remember_me:
                secret:   '%secret%'
            user_checker: App\Security\UserChecker
            two_factor:
                auth_form_path: 2fa_login    # The route name you have used in the routes.yaml
                check_path: 2fa_login_check  # The route name you have used in the routes.yaml

    access_control:
        # This makes the logout route accessible during two-factor authentication. Allows the user to
        # cancel two-factor authentication, if they need to.
        - { path: ^/logout, role: IS_AUTHENTICATED_ANONYMOUSLY }
        # This ensures that the form can only be accessed when two-factor authentication is in progress.
        - { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS }
        - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/system_task, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/docs, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/nav/employee, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/teams$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/team/[0-9]+/procedure$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/procedure/[0-9]+/spec, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/template/[0-9]+, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/attachment/view/[0-9]+, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/page_template/[0-9]+, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/procedure/redirect/[0-9]+, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/dashboard, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/organization/new, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/, role: ROLE_USER }
@scheb
Copy link
Owner

scheb commented Aug 23, 2020

Some things that I'd like to clarify before we dive deeper. Because to me everything seems to look fine.

when I attempt to log in, I'm logged normally, without ever being sent to 2fa_login route to inout the two factor code. The debug toolbar indicates that I do receive the Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorToken, though.

You're definitely not logged in normally, because TwoFactorToken is an intermediate state without any privileges as it doesn't expose any roles (such as ROLE_USER). You could check, what roles are present when you reach that state. Which brings me to the next question:

Try accessing a page that requires the user to be authenticated. Does it redirect to the two-factor authentication form?: No

What route did you use? I have doubts because it reads as if your whole application allows anonymous access (many IS_AUTHENTICATED_ANONYMOUSLY paths). So when you checked that, did you use one of the paths that are configured with IS_AUTHENTICATED_ANONYMOUSLY? Because these will always be accessible (even in the intermediate state when TwoFactorToken is present) and therefore the application will not ask for 2fa when they're called. It must be a route that requires ROLE_USER, only then the bundle would redirect to the 2fa form because only then the user has to complete authentication to access the path.

@nmeirik
Copy link
Author

nmeirik commented Aug 24, 2020

@scheb Thanks for getting back to me so quickly.

What route did you use? I have doubts because it reads as if your whole application allows anonymous access (many IS_AUTHENTICATED_ANONYMOUSLY paths). So when you checked that, did you use one of the paths that are configured with IS_AUTHENTICATED_ANONYMOUSLY?

I used the / and /todo route, none of which should allow anonymous access.

One thing that now hits me as potentially relevant is that I have a setup where the roles are not stored as part of the user entity, but rather on an entity associating the user with a tenant. So I am, among other things, extending the Symfony\Component\Security\Core\Authorization\Voter\RoleVoter with a custom OrganizationRoleHierarchyVoter.

@scheb
Copy link
Owner

scheb commented Aug 24, 2020

Yes, the voter could definitely be relevant. Here the bundle is checking if the path is accessible during 2fa and should throw an exception to redirect to the 2fa form:

if (!$this->twoFactorAccessDecider->isAccessible($request, $token)) {
$exception = new AccessDeniedException('User is in a two-factor authentication process.');
$exception->setSubject($request);
throw $exception;
}

which uses the application's AccessDecisionManager and respectively your voter:

if (null !== $attributes && $this->accessDecisionManager->decide($token, $attributes, $request)) {
return true;
}

To avoid any side effects, in your voter you could skip any special rules for a token of type TwoFactorTokenInterface.

@nmeirik
Copy link
Author

nmeirik commented Aug 24, 2020

Thanks so much for the tip! I'll look into this and update the issue once I have a little more time.

@nmeirik
Copy link
Author

nmeirik commented Aug 25, 2020

I have indeed confirmed that both $this->twoFactorAccessDecider->isAccessible($request, $token) and in turn $this->accessDecisionManager->decide($token, $attributes, $request) return true.

However, it is not clear to me what this means (or what exactly is going on here).

I'm attaching a dump of the AccessDecisionManagerInterface in case it's relevant:

Skjermbilde 2020-08-25 kl  11 01 37

Am I reading this dump correctly in that the third voter (OrganizationRoleHierarchyVoter) votes 1, which presumably is what causes the decide() method to return true?

To avoid any side effects, in your voter you could skip any special rules for a token of type TwoFactorTokenInterface.

It's not clear to me what this entails specifically. Here's the contents of the the OrganizationRoleHierarchyVoter voter for more context:

<?php

namespace App\Security;

use App\Multitenancy\TenantContext;
use NM\ProjectBundle\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\RoleVoter;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
use Symfony\Component\Security\Core\Role\Role;

class OrganizationRoleHierarchyVoter extends RoleVoter
{
    private $roleHierarchy;

    /**
     * @var TenantContext
     */
    private $tenantContext;

    public function __construct(RoleHierarchyInterface $roleHierarchy, TenantContext $tenantContext, string $prefix = 'ROLE_')
    {
        if (!method_exists($roleHierarchy, 'getReachableRoleNames')) {
            @trigger_error(sprintf('Not implementing the "%s::getReachableRoleNames()" method in "%s" is deprecated since Symfony 4.3.', RoleHierarchyInterface::class, \get_class($roleHierarchy)), E_USER_DEPRECATED);
        }

        $this->roleHierarchy = $roleHierarchy;
        $this->tenantContext = $tenantContext;

        parent::__construct($prefix);
    }

    /**
     * {@inheritdoc}
     */
    protected function extractRoles(TokenInterface $token)
    {
        if (method_exists($this->roleHierarchy, 'getReachableRoleNames')) {
            if (method_exists($token, 'getRoleNames')) {
                $roles = $this->getRoles($token);
            } else {
                @trigger_error(sprintf('Not implementing the "%s::getRoleNames()" method in "%s" is deprecated since Symfony 4.3.', TokenInterface::class, \get_class($token)), E_USER_DEPRECATED);

                $roles = array_map(function (Role $role) { return $role->getRole(); }, $token->getRoles(false));
            }

            return $this->roleHierarchy->getReachableRoleNames($roles);
        }

        return $this->roleHierarchy->getReachableRoles($token->getRoles(false));
    }

    private function getRoles(TokenInterface $token)
    {
        /** @var User $user */
        $user = $token->getUser();
        $roles = [];

        if ($user instanceof User) {
            foreach ($user->getOrganizationMemberships() as $membership) {
                if ($membership->getOrganization()->getId() === $this->tenantContext->getCurrentTenant()->getId()) {
                    $roles = $membership->getRoles();
                }
            }
        }

        return $roles;
    }
}

@scheb
Copy link
Owner

scheb commented Aug 25, 2020

The issue is, your OrganizationRoleHierarchyVoter class loads all the roles, even when the user is still in the intermediate state when 2fa is not completed yet.

This should fix the problem:

use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorTokenInterface;

protected function extractRoles(TokenInterface $token)
{
    if ($token instanceof TwoFactorTokenInterface) {
        return [];
    }

    // Your code here ...
}

@nmeirik
Copy link
Author

nmeirik commented Aug 25, 2020

@scheb That worked like a charm - thanks a lot!

Do you find it relevant to update the troubleshooting documentation, or do you consider my setup too specific to be generally relevant?

@scheb
Copy link
Owner

scheb commented Aug 25, 2020

Glad to hear it worked!

Yes, I was also thinking about extending the troubleshooting guide a bit. There was another use-case when someone was doing something with the roles and because of that 2fa didn't work as expected. I'd like to mention that in the guide, to make people aware this could be an issue.

How would you describe your use-case, ro rather what could we add to the troubleshooting guide that would have helped? You can just post it here, I'm gonna combine it with the other use-case then.

@nmeirik
Copy link
Author

nmeirik commented Aug 25, 2020

I think my use case would be best added somewhere near the "No" option of the second question under the "Two-factor authentication form is not shown after login" issue.

I'm not certain that I understand the problem sufficiently in order to formulate an addition, but instead of just saying "Unknown issue", it might be useful here to mention that if you have a somewhat custom security setup, you might run into issues where an existing voter grants access in a way that does not comply with the bundle. It might even be helpful to link to this issue for a specific example.

@scheb
Copy link
Owner

scheb commented Aug 25, 2020

Oke, I'll add a section on custom security setups. What this issues all have in common is, that the customization is doing something with the roles. Good idea to add the issues for reference, I'm going to do that.

Closing this issue now.

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

No branches or pull requests

2 participants