Search code examples
phpauthenticationsymfonysymfony5

Custom Authenticator with API always return me Invalid credentials


I need to validate a user via external and sticky the session, but at the moment, I want to validate any user.

I'm following the Custom Authenticator doc: https://symfony.com/doc/current/security/custom_authenticator.html

I create a User and UserProvider without Doctrine:

\App\Security\User
\App\Security\UserProvider

And I create the custom authentificator: \App\Security\ApiAuthentificator.php and always return a valid SelfValidatingPassport:

<?php
// src/Security/ApiAuthenticator.php
namespace App\Security;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;

use Symfony\Component\HttpFoundation\RedirectResponse; 


use App\Security\UserProvider;

class ApiAuthenticator extends AbstractAuthenticator
{
    private $userProvider;

    public function __construct(UserProvider $userProvider)
    {
        $this->userProvider = $userProvider;
    }

    /**
     * Called on every request to decide if this authenticator should be
     * used for the request. Returning `false` will cause this authenticator
     * to be skipped.
     */
    public function supports(Request $request): ?bool
    {
        return 'login' === $request->attributes->get('_route')
            && $request->isMethod('POST');
    }

    public function authenticate(Request $request): PassportInterface
    {
        /*
        $apiToken = $request->headers->get('X-AUTH-TOKEN');
        if (null === $apiToken) {
            // The token header was empty, authentication fails with HTTP Status
            // Code 401 "Unauthorized"
            throw new CustomUserMessageAuthenticationException('No API token provided');
        }
        
        return new SelfValidatingPassport(new UserBadge($apiToken));
        */

        //
        // TODO: call to API
        // 
        
        $credentials = [
            'username' => $request->request->get('_username'),
            'password' => $request->request->get('_password')
        ];
        
        return new SelfValidatingPassport(new UserBadge($credentials['username']));
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        // on success, let the request continue
        return null;
        // return new RedirectResponse($this->urlGenerator->generate('admin'));
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $data = [
            // you may want to customize or obfuscate the message first
            'message' => strtr($exception->getMessageKey(), $exception->getMessageData())

            // or to translate this message
            // $this->translator->trans($exception->getMessageKey(), $exception->getMessageData())
        ];

        // return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
        return new Response('<html><body>' . $data['message'] . '</body></html>');
    }
}

But, when I do login, the Login process response Invalid Credentials, but it's not the onAuthenticationFailure function on ApiAuthenticator.

enter image description here

In the UserProvider, always return a User in the loadUserByIdentifier, and the refreshUser it's never called because the firewall is stateless: true:

<?php
// src/Security/UserProvider
namespace App\Security;

use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;

use App\Security\User;
use App\Utils\HelpCurl;


class UserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
    /**
     * Symfony calls this method if you use features like switch_user
     * or remember_me.
     *
     * If you're not using these features, you do not need to implement
     * this method.
     *
     * @throws UserNotFoundException if the user is not found
     */
    public function loadUserByIdentifier($identifier): UserInterface
    {
        // Load a User object from your data source or throw UserNotFoundException.
        // The $identifier argument may not actually be a username:
        // it is whatever value is being returned by the getUserIdentifier()
        // method in your User class.

        $user = new User();
        $user->setUsername($identifier);

        return $user;

        /*
        throw new \Exception('TODO: fill in loadUserByIdentifier() inside '.__FILE__);
        */
    }

    /**
     * @deprecated since Symfony 5.3, loadUserByIdentifier() is used instead
     */
    public function loadUserByUsername($username): UserInterface
    {
        return $this->loadUserByIdentifier($username);
    }

    /**
     * Refreshes the user after being reloaded from the session.
     *
     * When a user is logged in, at the beginning of each request, the
     * User object is loaded from the session and then this method is
     * called. Your job is to make sure the user's data is still fresh by,
     * for example, re-querying for fresh User data.
     *
     * If your firewall is "stateless: true" (for a pure API), this
     * method is not called.
     *
     * @return UserInterface
     */
    public function refreshUser(UserInterface $user)
    {
        if (!$user instanceof User) {
            throw new UnsupportedUserException(sprintf('Invalid user class "%s".', get_class($user)));
        }

        throw new \Exception('TODO: fill in refreshUser() inside '.__FILE__);

        /*
        // Return a User object after making sure its data is "fresh".
        // Or throw a UsernameNotFoundException if the user no longer exists.
        throw new \Exception('TODO: fill in refreshUser() inside '.__FILE__);
        */
    }

    /**
     * Tells Symfony to use this provider for this User class.
     */
    public function supportsClass($class)
    {
        return User::class === $class || is_subclass_of($class, User::class);
    }

    /**
     * Upgrades the hashed password of a user, typically for using a better hash algorithm.
     */
    public function upgradePassword(UserInterface $user, string $newHashedPassword): void
    {
        // TODO: when hashed passwords are in use, this method should:
        // 1. persist the new password in the user storage
        // 2. update the $user object with $user->setPassword($newHashedPassword);
    }
}

Any ideas? What is missing?

I paste the security and the User:

<?php
// src/Security/User
namespace App\Security;

use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

class User implements UserInterface
{
    private $username;

    private $roles = [];

    /**
     * @deprecated since Symfony 5.3, use getUserIdentifier instead
     */
    public function getUsername(): string
    {
        return (string) $this->username;
    }

    public function setUsername(string $username): self
    {
        $this->username = $username;

        return $this;
    }

    /**
     * A visual identifier that represents this user.
     *
     * @see UserInterface
     */
    public function getUserIdentifier(): string
    {
        return (string) $this->username;
    }

    /**
     * @see UserInterface
     */
    public function getRoles(): array
    {
        $roles = $this->roles;
        // guarantee every user at least has ROLE_USER
        $roles[] = 'ROLE_CUSTOMER';

        return array_unique($roles);
    }

    public function setRoles(array $roles): self
    {
        $this->roles = $roles;

        return $this;
    }

    /**
     * This method can be removed in Symfony 6.0 - is not needed for apps that do not check user passwords.
     *
     * @see PasswordAuthenticatedUserInterface
     */
    public function getPassword(): ?string
    {
        return null;
    }

    /**
     * This method can be removed in Symfony 6.0 - is not needed for apps that do not check user passwords.
     *
     * @see UserInterface
     */
    public function getSalt(): ?string
    {
        return null;
    }

    /**
     * @see UserInterface
     */
    public function eraseCredentials()
    {
        // If you store any temporary, sensitive data on the user, clear it here
        // $this->plainPassword = null;
    }
}

security:
    # https://symfony.com/doc/current/security/authenticator_manager.html
    enable_authenticator_manager: true
    # https://symfony.com/doc/current/security.html#c-hashing-passwords
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        # used to reload user from session & other features (e.g. switch_user)
        app_user_provider:
            id: App\Security\UserProvider
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            # lazy: true
            stateless: true
            # provider: users_in_memory

            # activate different ways to authenticate
            # https://symfony.com/doc/current/security.html#firewalls-authentication

            # https://symfony.com/doc/current/security/impersonating_user.html
            # switch_user: true
            custom_authenticators:
                - App\Security\ApiAuthenticator
            form_login:
                login_path: login
                check_path: login
            logout:
                path: app_logout

    # 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: ^/admin, roles: ROLE_ADMIN }
        # - { path: ^/profile, roles: ROLE_USER }


Solution

  • I solved it.

    First. The problem was that the login followed his own authentication and not the one ApiAuthenticator said. Despite also entering ApiAuthenticator.

    Disabling form_login in security.yaml, the user is authenticated correctly via ApiAuthenticator.

    Second. To make a sticky session, logically, you have to disable stateless or stateless: false.

    Now I have a user authenticated with a sticky session and I just have to do the validation using api.

    Thanks!

    security:
        # https://symfony.com/doc/current/security/authenticator_manager.html
        enable_authenticator_manager: true
        # https://symfony.com/doc/current/security.html#c-hashing-passwords
        password_hashers:
            Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
        # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
        providers:
            # used to reload user from session & other features (e.g. switch_user)
            app_user_provider:
                id: App\Security\UserProvider
        firewalls:
            dev:
                pattern: ^/(_(profiler|wdt)|css|images|js)/
                security: false
            main:
                # lazy: true
                # stateless: false
                # provider: users_in_memory
    
                # activate different ways to authenticate
                # https://symfony.com/doc/current/security.html#firewalls-authentication
    
                # https://symfony.com/doc/current/security/impersonating_user.html
                # switch_user: true
                custom_authenticators:
                    - App\Security\ApiAuthenticator
                #form_login:
                    #login_path: login
                    #check_path: login
                logout:
                    path: app_logout
    
        # 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: ^/admin, roles: ROLE_CUSTOMER }
            # - { path: ^/profile, roles: ROLE_USER }
    
    
    <?php
    // src/Security/ApiAuthenticator.php
    namespace App\Security;
    
    use Symfony\Component\HttpFoundation\JsonResponse;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
    use Symfony\Component\Security\Core\Exception\AuthenticationException;
    use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
    use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
    use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
    use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
    use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
    use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
    use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
    
    use Symfony\Component\HttpFoundation\RedirectResponse; 
    
    
    use App\Security\UserProvider;
    
    class ApiAuthenticator extends AbstractAuthenticator
    {
        private $userProvider;
    
        public function __construct(UserProvider $userProvider)
        {
            $this->userProvider = $userProvider;
        }
    
        /**
         * Called on every request to decide if this authenticator should be
         * used for the request. Returning `false` will cause this authenticator
         * to be skipped.
         */
        public function supports(Request $request): ?bool
        {
            return 'login' === $request->attributes->get('_route')
                && $request->isMethod('POST');
        }
    
        public function authenticate(Request $request): PassportInterface
        {
            /*
            $apiToken = $request->headers->get('X-AUTH-TOKEN');
            if (null === $apiToken) {
                // The token header was empty, authentication fails with HTTP Status
                // Code 401 "Unauthorized"
                throw new CustomUserMessageAuthenticationException('No API token provided');
            }
            
            return new SelfValidatingPassport(new UserBadge($apiToken));
            */
    
            //
            // TODO: call to API
            // 
            
            $credentials = [
                'username' => $request->request->get('_username'),
                'password' => $request->request->get('_password')
            ]; 
     
            return new SelfValidatingPassport(new UserBadge($credentials['username']));
        }
    
        public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
        {
            // on success, let the request continue
            return null;
            // return new RedirectResponse($this->urlGenerator->generate('admin'));
        }
    
        public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
        {
            $data = [
                // you may want to customize or obfuscate the message first
                'message' => strtr($exception->getMessageKey(), $exception->getMessageData())
    
                // or to translate this message
                // $this->translator->trans($exception->getMessageKey(), $exception->getMessageData())
            ];
    
            // return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
            return new Response('<html><body>' . $data['message'] . '</body></html>');
        }
    }
    
    <?php
    // src/Security/UserProvider
    namespace App\Security;
    
    use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
    use Symfony\Component\Security\Core\Exception\UserNotFoundException;
    use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
    use Symfony\Component\Security\Core\User\UserInterface;
    use Symfony\Component\Security\Core\User\UserProviderInterface;
    
    use Symfony\Component\HttpFoundation\RedirectResponse;
    use Symfony\Component\HttpFoundation\Request;
    
    use App\Security\User;
    use App\Utils\HelpCurl;
    
    
    class UserProvider implements UserProviderInterface, PasswordUpgraderInterface
    {
        /**
         * Symfony calls this method if you use features like switch_user
         * or remember_me.
         *
         * If you're not using these features, you do not need to implement
         * this method.
         *
         * @throws UserNotFoundException if the user is not found
         */
        public function loadUserByIdentifier($identifier): User
        {
            // Load a User object from your data source or throw UserNotFoundException.
            // The $identifier argument may not actually be a username:
            // it is whatever value is being returned by the getUserIdentifier()
            // method in your User class.
    
            $user = new User();
            $user->setUsername($identifier);
            $user->setRoles(array('ROLE_ADMIN','ROLE_GESTOR'));
            return $user;
    
            /*
            throw new \Exception('TODO: fill in loadUserByIdentifier() inside '.__FILE__);
            */
        }
    
        /**
         * @deprecated since Symfony 5.3, loadUserByIdentifier() is used instead
         */
        public function loadUserByUsername($username): User
        {
            return $this->loadUserByIdentifier($username);
        }
    
        /**
         * Refreshes the user after being reloaded from the session.
         *
         * When a user is logged in, at the beginning of each request, the
         * User object is loaded from the session and then this method is
         * called. Your job is to make sure the user's data is still fresh by,
         * for example, re-querying for fresh User data.
         *
         * If your firewall is "stateless: true" (for a pure API), this
         * method is not called.
         *
         * @return UserInterface
         */
        public function refreshUser(UserInterface $user)
        {
            if (!$user instanceof User) {
                throw new UnsupportedUserException(sprintf('Invalid user class "%s".', get_class($user)));
            }
    
            return $user;
    
    
            // throw new \Exception('TODO: fill in refreshUser() inside '.__FILE__);
    
            /*
            // Return a User object after making sure its data is "fresh".
            // Or throw a UsernameNotFoundException if the user no longer exists.
            throw new \Exception('TODO: fill in refreshUser() inside '.__FILE__);
            */
        }
    
        /**
         * Tells Symfony to use this provider for this User class.
         */
        public function supportsClass($class)
        {
            return User::class === $class || is_subclass_of($class, User::class);
        }
    
        /**
         * Upgrades the hashed password of a user, typically for using a better hash algorithm.
         */
        public function upgradePassword(UserInterface $user, string $newHashedPassword): void
        {
            // TODO: when hashed passwords are in use, this method should:
            // 1. persist the new password in the user storage
            // 2. update the $user object with $user->setPassword($newHashedPassword);
        }
    }