knpuniversity / oauth2-client-bundle

Easily talk to an OAuth2 server for social functionality in Symfony
https://symfonycasts.com
MIT License
786 stars 146 forks source link

Google authentication fails with form_login tag #164

Closed InternalServerError closed 5 years ago

InternalServerError commented 5 years ago

Hi !

WOrking with Symfony4.1.x, FOSUser and Doctrine-ODM.

I have an issue with an hybrid auth. Indeed, I have two different authentication methods : Google SSO and standard login form. Unfortunately, both sent on same homepage : / When I have "form_login" tag set in security.yaml, SSO "works" but do not want to redirect me on homepage, I see I am authenticated but always redirected on login page...

This is my security.yaml :

security:
    encoders:
        FOS\UserBundle\Model\UserInterface: bcrypt
    role_hierarchy:
        ROLE_USER: []
        ROLE_CLIENT: [ROLE_USER]
        ROLE_ADMIN: ROLE_USER
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        fos_userbundle:
            id: fos_user.user_provider.username_email
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        api_login:
            pattern:  ^/api/login
            stateless: true
            anonymous: true
            form_login:
                provider: fos_userbundle
                check_path:               /api/login_check
                success_handler:          lexik_jwt_authentication.handler.authentication_success
                failure_handler:          lexik_jwt_authentication.handler.authentication_failure
                require_previous_session: false
        main:
            pattern: ^/(?!api)
            anonymous: ~
            form_login:
                csrf_token_generator: security.csrf.token_manager
                default_target_path: index
                always_use_default_target_path: true
### IF form_login TAG IS DELETED/COMMENTED SSO WORKS WELL BUT NOT THE STANDARD LOGIN FORM, ELSE SSO CONNECT ME BUT ALWAYS REDIRECT ON LOGIN PAGE.
            logout:
                path: /logout
                target: /login
            remember_me:
                secret: '%env(APP_SECRET)%'
            guard:
                authenticators:
                    - App\Security\GuardAuthenticator\GoogleAuthenticator
                    - App\Security\GuardAuthenticator\LoginFormAuthenticator
                entry_point: - App\Security\GuardAuthenticator\GoogleAuthenticator
        api:
            pattern: ^/api/(?!doc|doc\.json)$
            stateless: true
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator

    access_control:
         - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/api/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/api/doc, roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/api/token/refresh, roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/connect/google, roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/import, roles: IS_AUTHENTICATED_ANONYMOUSLY, ips: [ '%env(IP_DOCKER_PARSEUR)%' ] }
         - { path: ^/, roles: ROLE_USER }
         - { path: ^/api, roles: ROLE_USER }

GoogleAuthenticator :

<?php
declare(strict_types=1);

namespace App\Security\GuardAuthenticator;

use App\Document\User;
use Doctrine\ODM\MongoDB\DocumentManager;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Security\Authenticator\SocialAuthenticator;
use League\OAuth2\Client\Provider\GoogleUser;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use KnpU\OAuth2ClientBundle\Client\Provider\GoogleClient;

class GoogleAuthenticator extends SocialAuthenticator
{
    /**
     * @var ClientRegistry
     */
    protected $client;
    /**
     * @var DocumentManager
     */
    protected $documentManager;
    /**
     * @var RouterInterface
     */
    protected $router;

    public function __construct(
        ClientRegistry $client,
        DocumentManager $documentManager,
        RouterInterface $router
    ) {
        $this->client = $client;
        $this->documentManager = $documentManager;
        $this->router = $router;
    }

    /**
     * @param Request $request The request that resulted in an AuthenticationException
     * @param AuthenticationException $authException The exception that started the authentication process
     *
     * @return Response
     */
    public function start(
        Request $request,
        AuthenticationException $authException = null
    ) {
        return new RedirectResponse($this->router->generate('index'));
    }

    /**
     * @param Request $request
     *
     * @return bool
     */
    public function supports(Request $request): bool
    {
        return $request->attributes->get('_route') === 'connect_google_check';
    }

    /**
     * @param Request $request
     *
     * @return mixed Any non-null value
     *
     * @throws \UnexpectedValueException If null is returned
     */
    public function getCredentials(Request $request)
    {
        return $this->fetchAccessToken($this->getGoogleClient());
    }

    /**
     * @param mixed $credentials
     * @param UserProviderInterface $userProvider
     *
     * @throws AuthenticationException
     *
     * @return UserInterface|null
     */
    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        /** @var GoogleUser $googleUser */
        $googleUser = $this->getGoogleClient()->fetchUserFromToken($credentials);
        $userEmail = $googleUser->getEmail();

        $user = $this->documentManager
            ->getRepository(User::class)
            ->findOneBy(['email' => $userEmail]);

        if ($user) {
            return $user;
        }

        return null;
    }

    /**
     * @param Request $request
     * @param AuthenticationException $exception
     *
     * @return JsonResponse
     */
    public function onAuthenticationFailure(
        Request $request,
        AuthenticationException $exception
    ): JsonResponse {
        return new JsonResponse(
            [
                'message' => strtr(
                    $exception->getMessageKey(),
                    $exception->getMessageData())
            ],
            JsonResponse::HTTP_FORBIDDEN
        );
    }

    /**
     * @param Request $request
     * @param TokenInterface $token
     * @param string $providerKey The provider (i.e. firewall) key
     *
     * @return Response|null
     */
    public function onAuthenticationSuccess(
        Request $request,
        TokenInterface $token,
        $providerKey
    ) {
        $accessToken = $this->fetchAccessToken($this->getGoogleClient());
        $googleUser = $this->getGoogleClient()->fetchUserFromToken($accessToken);
        $user = $this->documentManager
            ->getRepository(User::class)
            ->findOneBy(['email' => $googleUser->getEmail()]);

        $user->setApiToken($accessToken->getValues()['id_token']);
        $this->documentManager->persist($user);
        $this->documentManager->flush();

        return null;
    }

    /**
     * @return GoogleClient
     */
    private function getGoogleClient(): GoogleClient
    {
        return $this->client->getClient('google');
    }

    public function supportsRememberMe()
    {
        return false;
    }
}

LoginFormAuthenticator :

<?php
declare(strict_types=1);

namespace App\Security\GuardAuthenticator;

use App\Document\User;
use App\Manager\RefreshTokenManager;
use Doctrine\ODM\MongoDB\DocumentManager;
use FOS\UserBundle\Model\UserManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;

class LoginFormAuthenticator extends AbstractGuardAuthenticator
{
    /**
     * @var UserPasswordEncoderInterface
     */
    protected $userPasswordEncoder;
    /**
     * @var UserManagerInterface
     */
    protected $userManager;
    /**
     * @var RouterInterface
     */
    protected $router;
    /**
     * @var TokenStorageInterface
     */
    protected $tokenStorage;
    /**
     * @var JWTTokenManagerInterface
     */
    protected $jwtTokenManager;
    /**
     * @var DocumentManager
     */
    protected $documentManager;
    /**
     * @var RefreshTokenManager
     */
    protected $refreshTokenManager;

    public function __construct(
        UserPasswordEncoderInterface $userPasswordEncoder,
        UserManagerInterface $userManager,
        RouterInterface $router,
        TokenStorageInterface $tokenStorage,
        JWTTokenManagerInterface $jwtTokenManager,
        DocumentManager $documentManager,
        RefreshTokenManager $refreshTokenManager
    ) {
        $this->userPasswordEncoder = $userPasswordEncoder;
        $this->userManager = $userManager;
        $this->router = $router;
        $this->tokenStorage = $tokenStorage;
        $this->jwtTokenManager = $jwtTokenManager;
        $this->documentManager = $documentManager;
        $this->refreshTokenManager = $refreshTokenManager;
    }

    /**
     * @param Request $request
     *
     * @return bool
     */
    public function supports(Request $request): bool {
        $route = $request->attributes->get('_route');
        if (
            (
                $route === 'fos_user_security_check'
                || $route === 'fos_user_security_login'
            )
            && (
                !$this->tokenStorage->getToken()
                || !$this->tokenStorage->getToken()->getUser()
                    instanceof UserInterface
            )
            && $request->isMethod(Request::METHOD_POST)
        ) {
            return true;
        }

        return false;
    }

    /**
     * @param Request $request
     *
     * @return array|RedirectResponse
     */
    public function getCredentials(Request $request)
    {
        return [
            'username' => $request->request->get('_username'),
            'password' => $request->request->get('_password'),
        ];
    }

    /**
     * @param mixed $credentials
     * @param UserProviderInterface $userProvider
     *
     * @return null|UserInterface
     */
    public function getUser(
        $credentials,
        UserProviderInterface $userProvider
    ): ?UserInterface {
        return $this->userManager
            ->findUserByUsernameOrEmail($credentials['username']);
    }

    /**
     * @param mixed $credentials
     * @param UserInterface $user
     *
     * @return bool
     */
    public function checkCredentials($credentials, UserInterface $user): bool
    {
        return $this->userPasswordEncoder
            ->isPasswordValid($user, $credentials['password']);
    }

    /**
     * @param Request $request
     * @param TokenInterface $token
     * @param string $providerKey
     *
     * @return null|JsonResponse
     */
    public function onAuthenticationSuccess(
        Request $request,
        TokenInterface $token,
        $providerKey
    ): ?JsonResponse {
        /** @var User $user */
        $user = $token->getUser();
        $apiToken = $this->jwtTokenManager->create($user);
        $this->refreshTokenManager->setRefreshToken($user);

        $user->setApiToken($apiToken);
        $this->documentManager->persist($user);
        $this->documentManager->flush();

        return null;
    }

    /**
     * @param Request $request
     * @param AuthenticationException $authenticationException
     *
     * @return JsonResponse
     */
    public function onAuthenticationFailure(
        Request $request,
        AuthenticationException $authenticationException
    ): JsonResponse {
        return new JsonResponse(
            [
                'message' => strtr(
                    $authenticationException->getMessageKey(),
                    $authenticationException->getMessageData()
                )
            ],
            JsonResponse::HTTP_UNAUTHORIZED
        );
    }

    /**
     * @return bool
     */
    public function supportsRememberMe(): bool
    {
        return false;
    }

    /**
     * @param Request $request The request that resulted in an AuthenticationException
     * @param AuthenticationException $authException The exception that started the authentication process
     *
     * @return JsonResponse
     */
    public function start(
        Request $request,
        AuthenticationException $authException = null
    ): JsonResponse {
        return new JsonResponse(
            ['message' => 'Invalid credentials'],
            JsonResponse::HTTP_UNAUTHORIZED
        );
    }
}

knpu_oauth2_client.yaml :

knpu_oauth2_client:
  clients:
    google:
      # must be "google" - it activates that type!
      type: google
      # add and configure client_id and client_secret in parameters.yml
      client_id: "%env(GOOGLE_CLIENT_ID)%"
      client_secret: "%env(GOOGLE_CLIENT_SECRET)%"
      # a route name you'll create
      redirect_route: connect_google_check
      redirect_params: {}
      # Optional value for sending access_type parameter. More detail: https://developers.google.com/identity/protocols/OpenIDConnect#authenticationuriparameters
      access_type: 'offline'
      # Optional value for sending hd parameter. More detail: https://developers.google.com/identity/protocols/OpenIDConnect#hd-param
      # hosted_domain: ''
      # Optional value for additional fields to be requested from the user profile. If set, these values will be included with the defaults. More details: https://developers.google.com/+/web/api/rest/latest/people
      # user_fields: {}
      # Optional value if you don't want or need to enable Google+ API access.
      # use_oidc_mode: false
      # whether to check OAuth2 "state": defaults to true
      use_state: true

GoogleController.php :

<?php
declare(strict_types=1);

namespace App\Controller;

use App\Logger\PlanningLogger;
use Doctrine\ODM\MongoDB\DocumentManager;
use FOS\RestBundle\Controller\Annotations as Rest;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

class GoogleController extends AbstractController
{
    /**
     * @var ClientRegistry
     */
    protected $clientRegistry;
    /**
     * @var TokenStorageInterface
     */
    protected $tokenStorage;

    public function __construct(
        DocumentManager $documentManager,
        LoggerInterface $logger,
        PlanningLogger $planningLogger,
        ClientRegistry $clientRegistry,
        TokenStorageInterface $tokenStorage
    ) {
        parent::__construct($documentManager, $logger, $planningLogger);
        $this->clientRegistry = $clientRegistry;
        $this->tokenStorage = $tokenStorage;
    }

    /**
     * @Rest\Get("/connect/google", name="connect_google")
     *
     * @return RedirectResponse
     */
    public function connect()
    {
        return $this->clientRegistry->getClient('google')->redirect();
    }

    /**
     * @Rest\Get("/connect/google_check", name="connect_google_check")
     *
     * @param Request $request
     *
     * @return RedirectResponse
     */
    public function connectCheckAction(Request $request)
    {
        if (
            $this->tokenStorage->getToken() && $this->tokenStorage->getToken()->getUser()
        ) {
            return $this->redirectToRoute('index', $request->query->all());
        }

        return $this->redirectToRoute('fos_user_security_login');
    }
}

Do you have any idea to fix it ? It drives me crazy. Implementing Google SSO might be really easy.

Thanks by advance,

Regards,

weaverryan commented 5 years ago

Hi @InternalServerError!

Can you explain more? I'm not sure what you mean by this:

Unfortunately, both sent on same homepage : / When I have "form_login" tag set in security.yaml, SSO "works" but do not want to redirect me on homepage, I see I am authenticated but always redirected on login page...

What do you mean by "sent on same homepage"? And also what do you mean by "but do not want to redirect me on homepage" and "I see I am authenticated but always redirected on login page". These issues are very complex, and I know it can be difficult to explain - but I'll need to fully understand if I can help. A repository where I can see the behavior is the best.

Cheers!

darius-v commented 5 years ago

https://stackoverflow.com/questions/40566647/symfony-3-fb-login-after-oauth-login-flow-i-go-back-at-login-page-because-the

maybe its the same problem as here. Answer helps. Just can you comment if this is correct solution? And then add to the code example in readme.

weaverryan commented 5 years ago

Hey @darius-v!

The answer on that post is NOT the correct solution. That's a hack around a problem with the user being deserialized. Fix that, don't implement the fix. I'll comment on that SO :).