Авторизация в Symfony через социальные сети. Часть 1: авторизация через Google
Сегодня многие общественные ресурсы заменяют собственную регистрацию на сайте на авторизацию через социальные сети. Причина? Во-первых, это удобно для пользователей. Во-вторых, вы все равно получите от пользователя достаточно информации, которую можете использовать: электронный адрес, имя, его никнейм, фотографию и много чего еще. Почти все то же самое мы можем требовать от пользователя при обычной регистрации, так почему бы не облегчить жизнь ему и себе?
Самый популярный и часто используемый протокол для такой авторизации - OAuth. Поскольку наше приложение является клиентом, мы должны сделать следующее:
- Редиректим пользователя на страницу авторизации (Google, Github, Yandex, Mail, etc);
- Там сервис (опять же, Google, Github и другие) запрашивают у пользователя подтверждения о выдаче прав нашему приложению;
- Получаем access_token, а вместе с ним доступ к тем ресурсам, которые мы запросили;
- Редиректим обратно на наше приложение.
Конечно, не забудем сохранить данные, которые к нам пришли, по которым мы и авторизуем пользователя, а также получим возможность установить ему фотографию, если получим, и имя.
Начало
Чтобы начать использовать 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(). Разберем код поэтапно:
- Достаем пользователя по access_token, который вернул метод getCredentials().
$googleUser = $this->getGoogleClient()
->fetchUserFromToken($credentials);
- Достаем его client_id.
$clientId = $googleUser->getId();
- Проверяем, существует ли такой пользователь в базе. Если да, то пользователь уже авторизовывался через Google и можно его вернуть из базы.
$existingUser = $this->userRepository->findOneBy(['clientId' => $clientId]);
if ($existingUser) {
return $existingUser;
}
- Если нет, продолжаем выполнение кода дальше и проверяем, есть ли пользователь с таким email в базе:
$email = $googleUser->getEmail();
- Если пользователь есть, то, скорее всего, она был зарегистрирован через обычную форму, тогда просто сохраняем его client_id на будущее:
/** @var User $user */
$user = $this->userRepository
->findOneBy(['email' => $email]);
if ($user) {
$user->setClientId($googleUser->getId());
}
- Если же пользователя нет и по 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.
Комментарии