scheb / 2fa

Two-factor authentication for Symfony applications 🔐
MIT License
501 stars 74 forks source link

Check route redirects back to login form upon submit #241

Closed adrolter closed 2 months ago

adrolter commented 2 months ago

Bundle version: 7.5.0 Symfony version: 7.1.2 PHP version: 8.3.9

Description

I'm attempting to setup 2FA via email after a username/password-based form login. I can get to the 2FA code form and see that I'm partially authenticated with a TwoFactorToken, and refreshing the page reliably loads the form again and again without loss of the token, but any submission of the form (with either the correct or incorrect code) drops the token and leads to a redirect back to the start of the process (my login form at /login).

I followed the "Not logged in after completing two-factor authentication" part of the troubleshooting guide:

  1. Disabling the two_factor section of security.yaml results in a functioning login process without 2FA.

  2. Inspecting the security token when the 2FA form page loads reveals a TwoFactorToken which encapsulates a UsernamePasswordToken that does not have an authenticated property (according to my inspection with Xdebug):
    image image

  3. Upon submission to /2fa_check, I'm redirected back to the start. Here are the Authenticator debug messages from that request which ends up in that 302 back to /login:
    image

Additional Context

packages/scheb_2fa.yaml:

scheb_two_factor:
    email:
        enabled: true
        sender_email: admin@foo.com
        sender_name: Foo
        template: '2fa_email.html.twig'
    security_tokens:
        - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
        - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken

routes/scheb_2fa.yaml:

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

2fa_login_check:
    path: /2fa_check

I have multiple firewalls, but I'm only trying to use the bundle on main at the moment...

packages/security.yaml:

security:
    session_fixation_strategy: none
    providers:
        users_in_memory: { memory: null }
        abstract:
            id: Foo\App\Security\Repository\AbstractUserRepository
        employee:
            id: Foo\App\Security\Repository\EmployeeUserRepository
        customer:
            id: Foo\App\Security\Repository\CustomerUserRepository
        vendor:
            id: Foo\App\Security\Repository\VendorUserRepository
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        customer:
            lazy: true
            host: '%app.customer_domain_regex%'
            provider: customer
            custom_authenticators:
                - Foo\App\Security\Authentication\CustomerTokenAuthenticator
                - Foo\App\Security\Authentication\CustomerSessionAuthenticator
            entry_point: Foo\App\Security\Authentication\CustomerUserAuthenticationEntryPoint
        vendor:
            lazy: true
            host: '%app.vendor_domain_regex%'
            provider: vendor
            custom_authenticators:
                - Foo\App\Security\Authentication\VendorTokenAuthenticator
                - Foo\App\Security\Authentication\VendorSessionAuthenticator
            entry_point: Foo\App\Security\Authentication\VendorUserAuthenticationEntryPoint
            form_login:
                login_path: foo.vendor_portal.form_login
                check_path: foo.vendor_portal.form_login
                username_parameter: form[username]
                password_parameter: form[password]
                default_target_path: foo.vendor_portal.default
        api:
            lazy: true
            host: '%app.api_domain_regex%'
            provider: abstract
            stateless: true
            custom_authenticators:
                - Foo\App\Api\Authenticator\UserAppTokenCookieAuthenticator
                - Foo\App\Api\Authenticator\UserAppTokenHeaderAuthenticator
        # "main" MUST be the last defined (default) firewall!
        main:
            pattern: ^.*
            lazy: true
            provider: employee
            form_login:
                login_path: foo.intranet.login.form
                check_path: foo.intranet.login.form
                default_target_path: foo.intranet.default
            two_factor:
                auth_form_path: 2fa_login
                check_path: 2fa_login_check
                provider: employee
            logout:
                path: /logout

    access_control:
        - { path: ^/logout$, role: 'PUBLIC_ACCESS' }
        - { path: ^/login$, role: 'PUBLIC_ACCESS' }
        - { path: ^/2fa, role: 'IS_AUTHENTICATED_2FA_IN_PROGRESS' }
        # ...

I have a bit of a funky session setup at the moment due to legacy code, but I tried disabling all that and reverting to default session settings to see if that would fix 2FA, but it didnt...

packages/framework.yaml:

framework:
    # ...
    csrf_protection: false
    # ...
    session:
        storage_factory_id: session.storage.factory.php_bridge
        handler_id: ~
        cookie_secure: auto
        cookie_samesite: lax
    # ...

Any ideas or more troubleshooting steps would be very welcome. Thank you!

scheb commented 2 months ago

Honestly, no idea 🤷. The setup looks to good to me.

Would be interesting where that AccessDeniedException is coming from and how the security looked like in that moment.

yblatti commented 2 months ago

Did you have a look at your Symfony profiler's Events panel ?

Do you have any Security related listeners that could interfere ? Things that would listent to CheckPassportEvent::class, AuthenticationTokenCreatedEvent::class, LoginSuccessEvent::class, AuthenticationEvents::* and so on...

adrolter commented 2 months ago

Thank you for the replies! I hadn't had time to poke at this for a couple of weeks, but after taking another look it was indeed a problem with my legacy (PHP Bridge) sessions, as I had suspected.

I was calling \session_start() too late...often in the supports() of my custom authenticators, which was after Firewall\ContextListener->authenticate(). This was fine so long as my custom authenticators were the only ones in the mix, but obviously couldn't work with this bundle because it reads from TokenStorage before any of that happens.

To resolve it I made a kernel.request listener with higher priority than the firewall's, in which I can initialize my legacy sessions.