scheb / two-factor-bundle

[ABANDONED] Two-factor authentication for Symfony 2 & 3 applications 🔐. Please use the newer versions from https://github.com/scheb/2fa.
https://github.com/scheb/2fa
MIT License
385 stars 111 forks source link

Not prompted for Google Authenticator code on login #306

Closed patrickdamery closed 3 years ago

patrickdamery commented 3 years ago

Bundle version: 4.18.4 Symfony version: 3.4.35 PHP version: 7.2.24

Description I'm trying to setup 2 factor authentication with Google Authenticator on a web app I'm working on.

I've setup the User entity with the TwoFactorInterface and I have successfully generated the QR code and saved the secret to the database. However when I try logging in, I'm never prompted for the Google Authenticator Code. I've gone through the troubleshooting steps and I'm having trouble in step 4: getActiveTwoFactorProviders() is never called.

I've checked my bundle's configuration and I don't have anything whitelisted and have disabled trusted devices:

scheb_two_factor:
    trusted_device:
        enabled: false

    google:
        enabled: true
        server_name: www.example.com      # Server name used in QR code
        issuer: Example           # Issuer name used in QR code
        digits: 6                      # Number of digits in authentication code
        window: 1
        template: security/2fa_form.html.twig

    ip_whitelist: ~

    security_tokens:
        - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
        #- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken

Additional Context I've also had a look at my Login authenticator and can't see anything that would suggest that the roles are loaded by replacing the security token after login, although I haven't actually found where the Role is loaded. As far as I can tell it's a pretty standard implementation of AbstractFormLoginAuthenticator (I didn't write this project just adding 2fa to it).

<?php

class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{

    use TargetPathTrait;

    private $em;
    private $router;
    private $passwordEncoder;
    private $csrfTokenManager;

    public function __construct(
        EntityManagerInterface $em,
        RouterInterface $router,
        UserPasswordEncoderInterface $passwordEncoder,
        CsrfTokenManagerInterface $csrfTokenManager
    ) {
        $this->em = $em;
        $this->router = $router;
        $this->passwordEncoder = $passwordEncoder;
        $this->csrfTokenManager = $csrfTokenManager;
    }

    public function getCredentials(Request $request)
    {
        $username = $request->request->get('_username');
        $password = $request->request->get('_password');
        $csrfToken = $request->request->get('_csrf_token');
        if (false === $this->csrfTokenManager->isTokenValid(new CsrfToken('authenticate', $csrfToken))) {
            throw new InvalidCsrfTokenException('Invalid CSRF token.');
        }

        if ($request->getSession()) {
            $request->getSession()->set(Security::LAST_USERNAME, $username);
        }

        return [
            'username' => $username,
            'password' => $password,
        ];
    }

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        $username = $credentials['username'];
        return $this->em->getRepository('AppBundle:User')
            ->findOneBy(['username' => $username]);
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        /** @var User $user */
        $password = $credentials['password'];
        if ($this->passwordEncoder->isPasswordValid($user, $password) && !$user->getDeletedAt()) {
            return true;
        }
        return false;
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        $targetPath = null;
        // if the user hit a secure page and start() was called, this was
        // the URL they were on, and probably where you want to redirect to
        $targetPath = $this->getTargetPath($request->getSession(), $providerKey);

        if (!$targetPath) {
            $targetPath = $this->router->generate('dashboard');
        }

        return new RedirectResponse($targetPath);
    }

    protected function getLoginUrl()
    {
        return $this->router->generate('fos_user_security_login');
    }

    public function supports(Request $request)
    {
        if ($request->getPathInfo() != '/login_check' || $request->getMethod() != 'POST') {
            return false;
        }

        return true;
    }
}
scheb commented 3 years ago

Since you obviously reached step "3) On login, do you reach the end (return statement) of method Scheb\TwoFactorBundle\Security\Authentication\Provider\AuthenticationProviderDecorator::authenticate()" from the troubleshooting guide: What is the class of $token that you see here in line 94:

https://github.com/scheb/two-factor-bundle/blob/d85ddf71ab35acdd23a3b39efc82ce4a905756b5/Security/Authentication/Provider/AuthenticationProviderDecorator.php#L94-L96

That class must be listed in security_tokens in the configuration.

patrickdamery commented 3 years ago

Hey Scheb, thanks for your reply! I've added my token to security_tokens and now I'm getting as far as step 5. The method is returning an array containing the string 'google'. The user does have a googleAuthenticatorSecret defined and the method isGoogleAuthenticatorSecretEnabled() in the User model does return true.

However I'm still not being prompted for the Google Authenticator code. Do you have any idea as to what could be the problem now?

scheb commented 3 years ago

What's the security token that you see in your session after login? Is it a TwoFactorToken or something else? You should see that in the debug toolbar.

And could you please post your security.yaml, thanks.

patrickdamery commented 3 years ago

It is a TwoFactorToken.

security:

    encoders:
        AppBundle\Entity\User: bcrypt

    # https://symfony.com/doc/current/security.html#b-configuring-how-users-are-loaded
    providers:
        fos_userbundle:
            id: fos_user.user_provider.username
        api_key_user_provider:
            id: AppBundle\Security\ApiKeyUserProvider

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

        main:
            pattern: ^(?!/api|/login$|/2fa|/2fa_check|/resetting)\S+
            logout_on_user_change: true
            stateless: false
            anonymous: ~
            form_login:
                default_target_path: dashboard

            logout: ~

            remember_me:
                secret: '%secret%'

            guard:
                authenticators:
                    - app.security.login_form_authenticator

            two_factor:
                csrf_token_generator: security.csrf.token_manager
                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:
        - { path: ^/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/resetting, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS }
scheb commented 3 years ago

That firewall pattern looks interesting. Could it be that the path you're ending up after login doesn't match the firewall pattern?

patrickdamery commented 3 years ago

The path I end up in after login in is the dashboard route, which does match the route specified in the firewall for form_login.

I updated the LoginFormAuthenticator to explicitly redirect the user to 2fa_login_check route (even though this should depend on if user has 2fa enabled) and that seems to have helped but now I'm getting an error saying that Symfony can't find the controller for the path /2fa_check

Am I missing some config options for this route? I copied this from the installation instructions:

2fa_login:
    path: /2fa
    defaults:
        _controller: "scheb_two_factor.form_controller:form"

2fa_login_check:
    path: /2fa_check
scheb commented 3 years ago

The check path is only accessiable via POST, it is there to validate the 2fa code. You want to force the redirect to the 2fa_login route to display the 2fa form.

The bundle automatically redirects to the 2fa form whenever you try to access a path that is somehow "secured". Paths that are not within the firewall (don't match the pattern) and paths that are explicitly configured for anonymous access (IS_AUTHENTICATED_ANONYMOUSLY) don't do that redirect.

patrickdamery commented 3 years ago

Alright, so I'm now displaying the 2fa_login form, but when I try to submit the code it's calling my default login authenticator and failing because it's setup to use username and password and not the auth_code for authentication. Is this normal? I would've expected the form to submit to a different authenticator to verify the validity of the auth_code.

scheb commented 3 years ago

No that's not normal. I have no idea why your normal authenticator triggers. The requirements for that authenticator shouldn't be fulfilled.

Are you really posting the 2fa code against /2fa_check?

patrickdamery commented 3 years ago

I am, this is my request header:

POST /2fa_check HTTP/1.1
Host: localhost:8000
Connection: keep-alive
Content-Length: 73
Pragma: no-cache
Cache-Control: no-cache
sec-ch-ua: "Google Chrome";v="89", "Chromium";v="89", ";Not A Brand";v="99"
sec-ch-ua-mobile: ?0
Upgrade-Insecure-Requests: 1
Origin: http://localhost:8000
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://localhost:8000/2fa
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Cookie: PHPSESSID=1rsbeqc2t7a9q8vtion1r5cmf0

POST data:

_auth_code: 191684
_csrf_token: pekDTEJjnHvtCocMtAOFNbBVeJxB9669aBIRGBq9Gdg

Response Header:

HTTP/1.1 302 Found
Cache-Control: max-age=0, must-revalidate, private
Content-Type: text/html; charset=UTF-8
Date: Fri, 16 Apr 2021 11:09:33 GMT
Date: Fri, 16 Apr 2021 11:09:33 GMT
Expires: Fri, 16 Apr 2021 11:09:33 GMT
Host: localhost:8000
Location: /login
Set-Cookie: sf_redirect=%7B%22token%22%3A%224f84b2%22%2C%22route%22%3A%222fa_login_check%22%2C%22method%22%3A%22POST%22%2C%22controller%22%3A%22n%5C%2Fa%22%2C%22status_code%22%3A302%2C%22status_text%22%3A%22Found%22%7D; path=/; httponly
X-Debug-Token: 4f84b2
X-Debug-Token-Link: http://localhost:8000/_profiler/4f84b2
X-Powered-By: PHP/7.2.24-0ubuntu0.18.04.7
Content-Length: 268

The redirect of course happens due to my default login authenticator getting called. Do I maybe need to define explicitly what controller the route 2fa_check should use?

scheb commented 3 years ago

No, the /2fa_check path doesn't need a controller. It's handled completely by the firewall.

You should check where that redirect is coming from. What is causing the application to redirect back to the login form.

I suspect this is caused by your weird firewall pattern. I don't really understand what you're trying to achieve with that pattern. According to my understanding of the regex, this pattern would exclude the /2fa_check path from the firewall. If that's the case, that's definitly wrong. Both the /2fa and the /2fa_check path must be included in the firewall's pattern, otherwise the authentication process doesn't work.

patrickdamery commented 3 years ago

Yeah that was a mistake, I've removed them from the firewall but the issue still persists.

The redirect is coming from LoginFormAuthenticator because of course it tries to authenticate the user with the username and password but it's not available because it's getting the post data from the 2fa form, so it only gets auth_code. So it then redirects the user to the login page and displays an error message.

The question is, why is LoginFormAuthenticator being called in the first place? Do I have to specify another authenticator on the firewall?

scheb commented 3 years ago

The question is, why is LoginFormAuthenticator being called in the first place? Do I have to specify another authenticator on the firewall?

I can't tell you that. To my understanding, an authenticator is only called when its supports() method returns true.

stale[bot] commented 3 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.