Авторизация в Symfony через социальные сети. Часть 1: авторизация через Google

05.04.2022 в 17:48
8251
+328

Сегодня многие общественные ресурсы заменяют собственную регистрацию на сайте на авторизацию через социальные сети. Причина? Во-первых, это удобно для пользователей. Во-вторых, вы все равно получите от пользователя достаточно информации, которую можете использовать: электронный адрес, имя, его никнейм, фотографию и много чего еще. Почти все то же самое мы можем требовать от пользователя при обычной регистрации, так почему бы не облегчить жизнь ему и себе?

Самый популярный и часто используемый протокол для такой авторизации - OAuth. Поскольку наше приложение является клиентом, мы должны сделать следующее:

  1. Редиректим пользователя на страницу авторизации (Google, Github, Yandex, Mail, etc);
  2. Там сервис (опять же, Google, Github и другие) запрашивают у пользователя подтверждения о выдаче прав нашему приложению;
  3. Получаем access_token, а вместе с ним доступ к тем ресурсам, которые мы запросили;
  4. Редиректим обратно на наше приложение.

Конечно, не забудем сохранить данные, которые к нам пришли, по которым мы и авторизуем пользователя, а также получим возможность установить ему фотографию, если получим, и имя.

Начало

Чтобы начать использовать OAuth, нужно зарегистрировать свое приложение в Google. Сделать это можно по следующей ссылке: https://console.developers.google.com/apis. Там вы должны выбрать пункт меню на боковой панели "Учетные данные", нажать "Создать учетные данные", выбрать из выпадающего списка "Идентификатор клиента OAuth" и выбрать чекбокс "Веб-приложение". После всего этого вы должны увидеть следующее:

Здесь вы должны указать название вашего приложения, redirect_uri и callback_uri. В первом поле указываете следующее: http://127.0.0.1:8000 (или просто копируете ваш урл), в callback_uri - http://127.0.0.1:8000/google/auth.

Далее нажмите "Создать". Вы получите clientId и client secret. Эти ключи нужны для идентификации подлинности вашего приложения. Сохраните их, скоро я покажу, как их использовать. Мы не будем с нуля писать авторизацию, вместо этого мы скачаем бандл, который уже умеет работать со множеством сервисов. Выполните в терминале в корне проекта следующую команду:

composer require knpuniversity/oauth2-client-bundle

Также скачаем следующий пакет:

composer require league/oauth2-google

После установки бандла у вас появится конфигурационный файл knpu_oauth2_client.yaml в папке config/packages. Через него вы будете настраивать ваши client_id, client_secret, версию API, роут для редиректа и многое другое. Для авторизации через Google сделаем следующие настройки:

knpu_oauth2_client:
    clients:
        google:
            type: google
            client_id: '%env(OAUTH_GOOGLE_CLIENT_ID)%'
            client_secret: '%env(OAUTH_GOOGLE_CLIENT_SECRET)%'
            redirect_route: google_auth
            redirect_params: {}

Теперь данные, которые вы сохранили, нужно сохранить в .env файл по следующим именам:

OAUTH_GOOGLE_CLIENT_ID=здесь ваш client id
OAUTH_GOOGLE_CLIENT_SECRET=здесь ваш секретный ключ.

Создание авторизации

Если вы хотите оставить возможность пользователю проходить обычную регистрацию, тогда нам нужно несколько изменить нашу сущность, которую мы создали ранее:

<?php

declare(strict_types=1);

namespace App\Entity;

use DateTime;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @ORM\Entity()
 */
final class User implements UserInterface
{
    public const GITHUB_OAUTH = 'Github';
    public const GOOGLE_OAUTH = 'Google';

    public const ROLE_USER = 'ROLE_USER';
    public const ROLE_ADMIN = 'ROLE_ADMIN';

    /**
     * @var int
     *
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="bigint")
     */
    private $id;

    /**
     * @var int
     *
     * @ORM\Column(type="string", nullable=true)
     */
    private $clientId;

    /**
     * @var string
     *
     * @ORM\Column(type="string", unique=true)
     */
    private $email;

    /**
     * @var int
     *
     * @ORM\Column(type="string")
     */
    private $username;

    /**
     * @var string
     *
     * @ORM\Column(type="string")
     */
    private $oauthType;

    /**
     * @var DateTimeInterface
     *
     * @ORM\Column(type="datetime")
     */
    private $lastLogin;

    /**
     * @var string
     *
     * @ORM\Column(type="string", nullable=true)
     */
    private $password;

    /**
     * @var string
     *
     * @ORM\Column(type="string", nullable=true)
     */
    private $plainPassword;

    /**
     * @var array
     *
     * @ORM\Column(type="json_array")
     */
    private $roles = [];

    /**
     * @param $clientId
     * @param string $email
     * @param string $username
     * @param string $oauthType
     * @param array $roles
     */
    public function __construct(
        $clientId,
        string $email,
        string $username,
        string $oauthType,
        array $roles
    ) {
        $this->clientId = $clientId;
        $this->email = $email;
        $this->username = $username;
        $this->oauthType = $oauthType;
        $this->lastLogin = new DateTime('now');
        $this->roles = $roles;
    }

    /**
     * @param int $clientId
     * @param string $email
     * @param string $username
     *
     * @return User
     */
    public static function fromGithubRequest(
        int $clientId,
        string $email,
        string $username
    ): User
    {
        return new self(
            $clientId,
            $email,
            $username,
            self::GITHUB_OAUTH,
            [self::ROLE_USER]
        );
    }

    /**
     * @param string $clientId
     * @param string $email
     * @param string $username
     *
     * @return User
     */
    public static function fromGoogleRequest(
        string $clientId,
        string $email,
        string $username
    ): User
    {
        return new self(
            $clientId,
            $email,
            $username,
            self::GOOGLE_OAUTH,
            [self::ROLE_USER]
        );
    }

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

    /**
     * @return int
     */
    public function getClientId(): int
    {
        return $this->clientId;
    }

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

    /**
     * @return string
     */
    public function getOauthType(): string
    {
        return $this->oauthType;
    }

    /**
     * @return DateTimeInterface
     */
    public function getLastLogin(): DateTimeInterface
    {
        return $this->lastLogin;
    }

    /**
     * @return array
     */
    public function getRoles(): array
    {
        return $this->roles;
    }

    /**
     * @return string
     */
    public function getPassword(): ?string
    {
        return $this->password;
    }

    /**
     * @return null|string
     */
    public function getSalt(): ?string
    {
        return null;
    }

    /**
     * @return string
     */
    public function getUsername(): string
    {
        return $this->email;
    }

    public function eraseCredentials(): void
    {
        $this->plainPassword = null;
    }
}

Как видите, у нас нет сеттеров. Вместо этого мы сделали 2 именованных конструктора - для запроса на авторизацию от Google и Github. Во-первых, это удобно тем, что мы никогда не забудем передать нужные нам параметры, а во-вторых - сохранение некоторых свойств можно инкапсулировать в конструкторе (как, например, сохранение соц. сети, через которую вошел пользователь - oauthType).

Чтобы реализовать кастомную авторизацию через социальные сети, нам нужно или имплементировать AuthenticatorInterface, или отнаследоваться от AbstractGuardAuthenticator. Однако поскольку мы установили бандл oauth2-client-bundle, нам нужно отнаследоваться от него (он все равно так же наследуется от AbstractGuardAuthenticator). Вот как он будет выглядеть:

<?php

declare(strict_types=1);

namespace App\Security;

use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Client\OAuth2Client;
use KnpU\OAuth2ClientBundle\Security\Authenticator\SocialAuthenticator;
use League\OAuth2\Client\Provider\GoogleUser;
use League\OAuth2\Client\Token\AccessToken;
use Symfony\Component\HttpFoundation\RedirectResponse;
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\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

class OAuthGoogleAuthenticator extends SocialAuthenticator
{
    /**
     * @var ClientRegistry
     */
    private $clientRegistry;
    /**
     * @var EntityManagerInterface
     */
    private $em;
    /**
     * @var UserRepository
     */
    private $userRepository;

    /**
     * @param ClientRegistry $clientRegistry
     * @param EntityManagerInterface $em
     * @param UserRepository $userRepository
     */
    public function __construct(
        ClientRegistry $clientRegistry,
        EntityManagerInterface $em,
        UserRepository $userRepository
    )
    {
        $this->clientRegistry = $clientRegistry;
        $this->em = $em;
        $this->userRepository = $userRepository;
    }

    /**
     * @param Request $request
     * @param AuthenticationException|null $authException
     *
     * @return RedirectResponse|Response
     */
    public function start(Request $request, AuthenticationException $authException = null)
    {
        return new RedirectResponse(
            '/connect/',
            Response::HTTP_TEMPORARY_REDIRECT
        );
    }

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

    /**
     * @param Request $request
     *
     * @return AccessToken|mixed
     */
    public function getCredentials(Request $request)
    {
        return $this->fetchAccessToken($this->getGoogleClient());
    }

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

        $email = $googleUser->getEmail();

        /** @var User $existingUser */
        $existingUser = $this->userRepository
            ->findOneBy(['clientId' => $googleUser->getId()]);

        if ($existingUser) {
            return $existingUser;
        }

        /** @var User $user */
        $user = $this->userRepository
            ->findOneBy(['email' => $email]);

        if (!$user) {
            $user = User::fromGoogleRequest(
                $googleUser->getId(),
                $email,
                $googleUser->getName()
            );

            $this->em->persist($user);
            $this->em->flush();
        }

        return $user;
    }

    /**
     * @param Request $request
     * @param AuthenticationException $exception
     *
     * @return null|Response|void
     */
    public function onAuthenticationFailure(
        Request $request,
        AuthenticationException $exception
    ): ?Response
    {
        return null;
    }

    /**
     * @param Request $request
     * @param TokenInterface $token
     * @param string $providerKey
     *
     * @return null|Response
     */
    public function onAuthenticationSuccess(
        Request $request,
        TokenInterface $token,
        $providerKey
    ): ?Response
    {
        return null;
    }

    /**
     * @return OAuth2Client
     */
    public function getGoogleClient(): OAuth2Client
    {
        return $this->clientRegistry->getClient('google');
    }

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

Итак, что мы здесь видим. Метод start() вызывается, когда пользователю требуется авторизация при запросе к запрещенным ресурсам. В данном случае он редиректит на страницу с логином, где пользователь сможет выбрать, как ему авторизоваться. Выполнение нашего класса аутентификации продолжается, только если метод supports() возвращает true. Другими словами, если мы попали на наш роут. Метод getCredentials() возвращает в данном случае access_token, по которому мы определяем права пользователя. Теперь мы подошли к самому важному методу - getUser(). Разберем код поэтапно:

  1. Достаем пользователя по access_token, который вернул метод getCredentials().
$googleUser = $this->getGoogleClient()
            ->fetchUserFromToken($credentials);
  1. Достаем его client_id.
$clientId = $googleUser->getId();
  1. Проверяем, существует ли такой пользователь в базе. Если да, то пользователь уже авторизовывался через Google и можно его вернуть из базы.
$existingUser = $this->userRepository->findOneBy(['clientId' => $clientId]);
   if ($existingUser) {
      return $existingUser;
   }
  1. Если нет, продолжаем выполнение кода дальше и проверяем, есть ли пользователь с таким email в базе:
$email = $googleUser->getEmail();
  1. Если пользователь есть, то, скорее всего, она был зарегистрирован через обычную форму, тогда просто сохраняем его client_id на будущее:
/** @var User $user */
   $user = $this->userRepository
      ->findOneBy(['email' => $email]);
        if ($user) {
          $user->setClientId($googleUser->getId());
    }
  1. Если же пользователя нет и по email, создаем его, сохраняем и возвращаем:
   $user = User::fromGoogleRequest(
             $clientId,
             $email,
             $googleUser->getName()
            );

    $this->em->persist($user);
    $this->em->flush();

    return $user;

Остальные методы должны быть понятны по их названиям. Теперь напишем наш UserProvider:

<?php

declare(strict_types=1);

namespace App\Security;

use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\NonUniqueResultException;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

class UserProvider implements UserProviderInterface
{
    /**
     * @var UserRepository
     */
    private $userRepository;

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

    /**
     * @param string $username
     *
     * @return mixed|UserInterface
     *
     * @throws NonUniqueResultException
     */
    public function loadUserByUsername($username)
    {
        return $this->userRepository->loadUserByUsername($username);
    }

    /**
     * @param UserInterface $user
     *
     * @return UserInterface
     */
    public function refreshUser(UserInterface $user): UserInterface
    {
        if (!$user instanceof User) {
            throw  new UnsupportedUserException(
                sprintf('Instances of "%s" are not supported. ', get_class($user))
            );
        }

        return $user;
    }

    /**
     * @param string $class
     *
     * @return bool
     */
    public function supportsClass($class): bool
    {
        return $class === 'App\Entity\User';
    }
}

Чтобы не пересказывать вам документацию и назначение провайдеров, можете почитать следующую статью по ссылке.

Добавим в наш UserRepository следующий метод:

public function loadUserByUsername(string $email)
    {
        return $this->createQueryBuilder('u')
            ->where('u.email = :email')
            ->setParameter('email', $email)
            ->getQuery()
            ->getOneOrNullResult();
    }

Осталось написать контроллер и сконфигурировать файл config/packages/security.yaml. Начнем с первого:

<?php

declare(strict_types=1);

namespace App\Controller;

use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class OAuthController extends AbstractController
{
    /**
     * @param ClientRegistry $clientRegistry
     *
     * @return RedirectResponse
     *
     * @Route("/connect/google", name="connect_google_start")
     */
    public function redirectToGoogleConnect(ClientRegistry $clientRegistry)
    {
        return $clientRegistry
            ->getClient('google')
            ->redirect([
                'email', 'profile'
            ]);
    }

    /**
     * @Route("/google/auth", name="google_auth")
     *
     * @return JsonResponse|RedirectResponse
     */
    public function connectGoogleCheck()
    {
        if (!$this->getUser()) {
            return new JsonResponse(['status' => false, 'message' => "User not found!"]);
        } else {
            return $this->redirectToRoute('blog_posts');
        }
    }
}

Метод redirectToGoogleConnect() сначала получает клиента, который редиректит на страницу, указанную вами в настройках Google API, то есть на /google/auth, также этот метод (redirect) принимает массив скоупов. Скоупы - это информация, которую вы хотите получить от приложения. В нашем случае мы хотим получить доступ к электронному адрес и профилю, откуда мы можем взять имя, userpic и многое другое. Дальше нас редиректит на action connectGoogleCheck(), который пробует получить пользователя по тому методу, который мы с вами ранее написали в OAuthGoogleAuthenticator (да, Symfony неявно знает, как достать именно ваш кастомный Authenticator). Если не удалось, вы можете вернуть свою ошибку или поступить так, как вам нужно. Если удалось, возвращаем на страницу с постами.

Осталось настроить файл конфигурации. Для этого откройте config/packages/security.yaml и напишите в нем следующее:

security:
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        user_provider:
            entity: {class: App\Entity\User, property: email }
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            anonymous: true
            guard:
                authenticators:
                    - App\Security\OAuthGoogleAuthenticator
            logout:
                path: logout

Мы настроили провайдер, который будет доставать пользователя по email, указанные в качестве значения к ключу property. А также указали наш собственный guard. Теперь вы можете добавить ссылку и сделать красивую кнопку, по которой запустится весь процесс авторизации:

<a href="{{ path('connect_google_start') }}">Войти через Google</a><br>

P.S.

Не забудьте обновить вашу таблицу следующими командами:

php bin/console doctrine:schema:up -f

Теперь у вас рабочая авторизация через Google. В следующей статье мы сделаем то же самое с Github.

loader
05.04.2022 в 17:48
8251
+328
Логические задачи с собеседований