scheb / 2fa

Two-factor authentication for Symfony applications 🔐
MIT License
495 stars 72 forks source link

2fa_check always 401 Unauthorized (API PLatform + LexikJWTAuthenticationBundle) #193

Closed zeromodule closed 1 year ago

zeromodule commented 1 year ago

Bundle version: 6.8.0 Symfony version: 6.3.1 PHP version: 8.2.7 Using authenticators (enable_authenticator_manager: true): NO

Description

Hi, I'm using your bundle with API Platform and LexikJWTAuthenticationBundle. Application is API-only. I successfully receive the codes and correct response by sending username&password to the /auth endpoint, but when I try to send the code to /2fa_check, I always get 401 Unauthorized:

POST https://localhost/2fa_check
Accept: application/json
Content-Type: application/json

{
  "data": {"authCode": "7022"}
}

# response
{
  "code": 401,
  "message": "JWT Token not found"
}

Additional Context

security.yaml

security:
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
    firewalls:
        dev:
            pattern: ^/_(profiler|wdt)
            security: false
        main:
            stateless: false
            provider: app_user_provider
            two_factor:
                auth_form_path: 2fa_login
                check_path: 2fa_login_check
                prepare_on_login: true
                prepare_on_access_denied: true
                authentication_required_handler: App\Security\TwoFactorAuthenticationRequiredHandler
                success_handler: App\Security\TwoFactorAuthenticationSuccessHandler
                failure_handler: App\Security\TwoFactorAuthenticationFailureHandler
                auth_code_parameter_name: data.authCode
            json_login:
                check_path: auth # The name in routes.yaml is enough for mapping
                username_path: email
                password_path: password
                success_handler: App\Security\AuthenticationSuccessHandler
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
            jwt: ~

    # Easy way to control access for large sections of your site
    # Note: Only the *first* access control that matches will be used
    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: PUBLIC_ACCESS }
        # 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: ^/$, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI
        - { path: ^/docs, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI docs
        - { path: ^/auth, roles: PUBLIC_ACCESS }
        - { path: ^/, roles: IS_AUTHENTICATED_FULLY }

packages/scheb_2fa.yaml

scheb_two_factor:
    security_tokens:
        - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
        - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
        - Lexik\Bundle\JWTAuthenticationBundle\Security\Authentication\Token\JWTUserToken
        - Lexik\Bundle\JWTAuthenticationBundle\Security\Authenticator\Token\JWTPostAuthenticationToken
    email:
        enabled: true
        sender_email: no-reply@example.com
        digits: 4
        sender_name: John Doe  # Optional

routes/scheb_2fa.yaml

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

2fa_login_check:
    path: /2fa_check

packages/lexik_jwt_authentication.yaml

lexik_jwt_authentication:
    secret_key: '%env(resolve:JWT_SECRET_KEY)%'
    public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
    pass_phrase: '%env(JWT_PASSPHRASE)%'
    api_platform:
        check_path: /auth
        username_path: email
        password_path: password

App\Security\AuthenticationSuccessHandler

<?php
declare(strict_types=1);

namespace App\Security;

use Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler as LexicAuthenticationSuccessHandler;
use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorTokenInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;

class AuthenticationSuccessHandler implements AuthenticationSuccessHandlerInterface
{
    public function __construct(private LexicAuthenticationSuccessHandler $decorated)
    {
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
    {
        if ($token instanceof TwoFactorTokenInterface) {
            // Return the response to tell the client two-factor authentication is required.
            return new Response('{"login": "success", "two_factor_complete": false}');
        }

        return $this->decorated->onAuthenticationSuccess($request, $token);
    }
}

Other handlers in App\Security\ are implemented exactly as in https://symfony.com/bundles/SchebTwoFactorBundle/6.x/api.html

scheb commented 1 year ago

That "JWT Token not found" error message is not coming from the bundle, so you have to figure out where it's coming from.

I'd assume on the initial login there is a cookie with the JWT token returned and for some reason that cookie isn't present when the 2fa auth code is passed.

zeromodule commented 1 year ago

@scheb the problem is that there is no JWT token in the response on login, neither in the body nor in the headers. But it seems logical to me, because all we have in AuthenticationSuccessHandler is:

return new Response('{"login": "success", "two_factor_complete": false}');

JWT is generated in the decorated handler only. Shouldn't I add it somehow to the response?

scheb commented 1 year ago

Sounds reasonable. Then you'd likely need to have the decorated success handler generate a response. And when 2fa is required, that information somehow needs to be injected into the response so that your frontend understands that it needs to request 2fa code.

zeromodule commented 1 year ago

@scheb How bundle understands that 2FA is in progress when user is accessing 2fa_login_check route? Shouldn't this information be stored in JWT?

scheb commented 1 year ago

That information is stored in the security token, which is stored in the session. That's why the firewall needs to be stateful.

zeromodule commented 1 year ago

@scheb am I right that security token you're talking about must be already stored in the session at the moment of execution of AuthenticationSuccessHandler?

I checked session status at this moment and it's equals 1, which means PHP_SESSION_NONE. So it haven't been even started.

class AuthenticationSuccessHandler extends DefaultAuthenticationSuccessHandler
{
    public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
    {
        if ($token instanceof TwoFactorTokenInterface) {
            var_dump(\session_status()); // 1

            return new Response('{"login": "success", "two_factor_complete": false}');
        }

        return parent::onAuthenticationSuccess($request, $token);
    }
}
scheb commented 1 year ago

At that moment, the session should be definitely active. The session is managed by Symfony, that's likely why you don't get a result from native PHP functions. See https://symfony.com/doc/current/session.html about details on Symfony sessions.

zeromodule commented 1 year ago

@scheb Thanks a lot, you gave me the right idea. In the API Platform distribution, sessions were disabled by default (session section was commented in the framework.yaml). After turning them on everything worked. Also, regarding integration with LexikJWT, I just use lexik_jwt_authentication.handler.authentication_success as value for two_factor.success_handler, so the client only gets a JWT when two-factor authentication is complete.

Thanks again for the help and for the great bundle!

Below is my final config, it may be useful to someone else.

#security.yaml

security:
    enable_authenticator_manager: true

    # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'

    # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
    firewalls:
        dev:
            pattern: ^/_(profiler|wdt)
            security: false
        login:
            pattern: ^/auth
            stateless: false
            json_login:
                check_path: auth_login
                username_path: email
                password_path: password
                success_handler: App\Security\AuthenticationSuccessHandler
            two_factor:
                auth_form_path: 2fa_form    # The route name you have used in the routes.yaml
                check_path: 2fa_check  # The route name you have used in the routes.yaml
                prepare_on_login: true
                prepare_on_access_denied: true
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: App\Security\TwoFactorAuthenticationFailureHandler

        api:
            pattern: ^/
            provider: app_user_provider
            stateless: true
            jwt: ~

    # 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: ^/$, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI
        - { path: ^/docs, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI docs
        - { path: ^/auth/login, roles: PUBLIC_ACCESS }
        - { path: ^/auth/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS }
        - { path: ^/, roles: IS_AUTHENTICATED_FULLY }
#routes.yaml

controllers:
    resource:
        path: ../src/Controller/
        namespace: App\Controller
    type: attribute
auth_login:
    path: /auth/login
    methods: [ 'POST' ]
#packages/scheb_2fa.yaml

scheb_two_factor:
    security_tokens:
        - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
        - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
        - Lexik\Bundle\JWTAuthenticationBundle\Security\Authenticator\Token\JWTPostAuthenticationToken
        - Lexik\Bundle\JWTAuthenticationBundle\Security\Authentication\Token\JWTUserToken
    email:
        enabled: true
        sender_email: no-reply@example.com
        sender_name: John Doe  # Optional
        digits: 4
#routes/scheb_2fa.yaml

2fa_form:
    path: /auth/2fa_form
    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_check:
    path: /auth/2fa_check