0

I have a Flutter client which uses firebase for user account creation. The user can publish tickets which arrives on a web administration panel built with Symfony 6 and API Platform.

So I need 2 authenticators :

  • 1 original Symfony Authenticator for the admin to connect using a form and manage tickets.
  • 1 Authenticator JWT which will check the Firebase credentials, return the JWT, then allow publication. So I secure my API routes.

I'm stuck on this last point. I'm using the Firebase Bundle SDK for Symfony. I'm recovering my Firebase users fine. I wrote a FirebaseUserProvider and a FirebaseAuthenticator.

Of course, the FirebaseUser entity has no connection with the Doctrine ORM.

I think I'm not very far, but I'm stuck. "401: Invalid Credentials" on all my login submissions via Postman. Can I achieve this without the intervention of my SQL database with Doctrine ? I wish I didn't have to "replicate firebase users to my sql database".

Here are my important files.

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:
        firebase_user_provider:
            id: App\Security\FirebaseUserProvider
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
        jwt:
            lexik_jwt: ~
    firewalls:
        dev:
            pattern: (_(profiler|wdt)|css|images|js)/
            security: false

        # APILogin
        login:
            pattern: ^/api/login$
            stateless: true
            custom_authenticators:
                - App\Security\FirebaseAuthenticator
            provider: firebase_user_provider
            json_login:
                check_path: /api/login
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
        api:
            pattern: ^/api
            stateless: true
            provider: jwt
            jwt: ~

        # AppFormLogin
        main:
            lazy: true
            stateless: true
            provider: app_user_provider
            custom_authenticator: App\Security\LoginFormAuthenticator
            logout:
                path: app_logout

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

            # https://symfony.com/doc/current/security/impersonating_user.html
            # switch_user: true

    # 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: ^/api/login, roles: PUBLIC_ACCESS }
#        - { path: ^/api,       roles: IS_AUTHENTICATED_FULLY }
class FirebaseAuthenticator extends AbstractAuthenticator
{
    private FirebaseUserProvider $firebaseUserProvider;

    public function __construct(FirebaseUserProvider $firebaseUserProvider)
    {
        $this->firebaseUserProvider = $firebaseUserProvider;
    }

    /**
     * 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 $request->isMethod('POST');
    }

    /**
     * @throws JsonException
     */
    public function authenticate(Request $request): Passport
    {
        $credentials = [
            'username' => json_decode($request->getContent(), false, 512, JSON_THROW_ON_ERROR)->username,
            'password' => json_decode($request->getContent(), false, 512, JSON_THROW_ON_ERROR)->password
        ];
        return new SelfValidatingPassport(new UserBadge($credentials['username']));
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $data = [
            'message' => strtr($exception->getMessageKey(), $exception->getMessageData())
        ];
        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }
}
class FirebaseUserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
    private Auth $auth;

    /**
     * @param Auth $auth
     */
    public function __construct(Auth $auth)
    {
        $this->auth = $auth;
    }

    /**
     * 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.
     *
     * @param string $identifier
     * @return UserInterface
     * @throws AuthException
     * @throws FirebaseException
     */
    public function loadUserByIdentifier(string $identifier): UserInterface
    {
        $user = $this->auth->getUserByEmail($identifier);
        return new FirebaseUser(
            $user->uid ?? '',
            $user->email ?? '',
            $user->passwordHash ?? '',
            $user->displayName ?? ''
        );
    }

    /**
     * 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.
     *
     * @param UserInterface $user
     * @return UserInterface
     */
    public function refreshUser(UserInterface $user): UserInterface
    {
        if (!$user instanceof FirebaseUser) {
            throw new UnsupportedUserException(sprintf('Invalid user class "%s".', get_class($user)));
        }
        // Return a User object after making sure its data is "fresh".
        // Or throw a UserNotFoundException if the user no longer exists.
        throw new RuntimeException('TODO: fill in refreshUser() inside '.__FILE__);
    }

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

    /**
    * Upgrades the hashed password of a user, typically for using a better hash algorithm.
    */
    public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
    {
        if (!$user instanceof FirebaseUser) {
            throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', \get_class($user)));
        }
        $user->setPasswordHash($newHashedPassword);

    // 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);
    }
}
class FirebaseUser implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ApiProperty(
        identifier: true
    )]
    private ?string $uid;
    #[ApiProperty(
        description: 'Email de l\'utilisateur provenant de Firebase'
    )]
    private ?string $email;
    #[ApiProperty(
        description: 'Mot de passe de l\'utilisateur Firebase'
    )]
    private ?string $passwordHash;
    #[ApiProperty(
        description: 'Nom de l\'utilisateur provenant de Firebase'
    )]
    private ?string $displayName;

    /**
     * @param string|null $uid
     * @param string|null $email
     * @param string|null $passwordHash
     * @param string|null $displayName
     */
    public function __construct(?string $uid, ?string $email, ?string $passwordHash, ?string $displayName)
    {
        $this->uid = $uid;
        $this->email = $email;
        $this->passwordHash = $passwordHash;
        $this->displayName = $displayName;
    }

    /**
     * @return string|null
     */
    public function getUid(): ?string
    {
        return $this->uid;
    }

    /**
     * @param string|null $uid
     */
    public function setUid(?string $uid): void
    {
        $this->uid = $uid;
    }

    /**
     * @return string|null
     */
    public function getEmail(): ?string
    {
        return $this->email;
    }

    /**
     * @param string|null $email
     */
    public function setEmail(?string $email): void
    {
        $this->email = $email;
    }

    /**
     * @return string|null
     */
    public function getPasswordHash(): ?string
    {
        return $this->passwordHash;
    }

    /**
     * @param string|null $passwordHash
     */
    public function setPasswordHash(?string $passwordHash): void
    {
        $this->passwordHash = $passwordHash;
    }

    /**
     * @return string|null
     */
    public function getDisplayName(): ?string
    {
        return $this->displayName;
    }

    /**
     * @param string|null $displayName
     */
    public function setDisplayName(?string $displayName): void
    {
        $this->displayName = $displayName;
    }

    /**
     * @return string[]
     */
    public function getRoles(): array
    {
        return ['ROLE_USER'];
    }

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

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

    public function getPassword(): ?string
    {
        // passwordHash provide firebase
        return $this->passwordHash;
    }
}
Sheitak
  • 71
  • 9

1 Answers1

0

I'm coming back after a few days. Although it would be interesting to know how to authenticate via API Platform in JWT with users coming from Firebase, I am sharing my thoughts here because I have changed my situation.

Actually, I didn't analyze it enough. Although I like ApiPlatform, I realized that I have to remove it and make my Symfony application a very classic BackOffice connected to the Firebase SDK. So I have 2 apps (Symfony/Flutter) linking to 1 backend (Firebase, Auth+CloudFirestore), logical.

So there is no need for an additional Authentication with an additional API which will only make things heavier and complicate my objective. Sometimes it's good to get things done.

Always taker for a vision on the authenticator without doctrine. Strength to you.

Sheitak
  • 71
  • 9