[WebShake] Курс по фреймворку Symfony 4 - Часть 2

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

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

Самый популярный и часто используемый протокол для такой авторизации - 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.

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

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

Создание приложения

Чтобы воспользоваться API гитхаба, нам так же, как и с Google, нужно зарегистрировать приложение и получить наш публичный id и секретный ключ, по которым Github будет нас идентифицировать и давать доступ нашему приложению к данным его пользователей. Для этого перейдите по следующей ссылке. В поле Homepage URL напишем адрес нашего локального приложения, то есть http://localhost:8000, а в Authorization callback URL - http://localhost:8000/github/auth.

Конфигурация

Как видите, протокол OAuth работает везде одинаково. После создания приложения вы получите Client ID и Client Secret. Сохраняем их в .env файле под следующими ключевыми словами:

OAUTH_GITHUB_CLIENT_ID=
OAUTH_GITHUB_CLIENT_SECRET=

А в файл config/packages/knpu_aouth2_client.yaml добавляем следующие настройки:

github:
      type: github
      client_id: '%env(OAUTH_GITHUB_CLIENT_ID)%'
      client_secret: '%env(OAUTH_GITHUB_CLIENT_SECRET)%'
      redirect_route: github_auth
      redirect_params: {}

Теперь наше приложение готово, осталось написать новый Guard, который будет обрабатывать соответствующий роут, куда будут приходить данные о пользователе, и сохранять данные в базу, а также аутентифировать его.

Реализация

По примеру из прошлого урока добавим 2 таких же экшена для Github в наш OAuthController.

   /**
     * @Route("/connect/github", name="connect_github_start")
     *
     * @param ClientRegistry $clientRegistry
     *
     * @return RedirectResponse
     */
    public function redirectToGithubConnect(ClientRegistry $clientRegistry)
    {
        return $clientRegistry
            ->getClient('github')
            ->redirect([
                'user', 'public_repo'
            ]);
    }

    /**
     * @Route("/github/auth", name="github_auth")
     *
     * @return RedirectResponse|Response
     */
    public function authenticateGithubUser()
    {
        if (!$this->getUser()) {
            return new Response('User nof found', 404);
        }

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

В параметрах метода redirect указываем в виде массива скоупы, к которым хотим получать доступ при авторизации нашего пользователя, а именно - данные о самом пользователе и данные его публичных репозиториев.

Теперь напишем наш Guard по такому же приему, как и для Google.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Http\Security\Guard\Authenticators;

use App\Domain\User\Event\CreatedUserEvent;
use App\Domain\User\Model\Entity\User;
use App\Domain\User\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Client\OAuth2Client;
use KnpU\OAuth2ClientBundle\Security\Authenticator\SocialAuthenticator;
use League\OAuth2\Client\Provider\GithubResourceOwner;
use League\OAuth2\Client\Token\AccessToken;
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 Symfony\Component\EventDispatcher\EventDispatcherInterface;

final class OAuthGithubAuthenticator extends SocialAuthenticator
{
    /**
     * @var ClientRegistry
     */
    private $clientRegistry;

    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * @var UserRepository
     */
    private $userRepository;

    /**
     * @var RouterInterface
     */
    private $router;

    /**
     * @var EventDispatcherInterface
     */
    private $eventDispatcher;

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

    /**
     * @param Request $request
     * @param AuthenticationException|null $authException
     *
     * @return RedirectResponse|Response
     */
    public function start(
        Request $request,
        AuthenticationException $authException = null
    ): Response
    {
        return new RedirectResponse($this->router->generate('login'));
    }

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

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

    /**
     * @param mixed $credentials
     * @param UserProviderInterface $userProvider
     *
     * @return User|null|UserInterface
     *
     * @throws Exception
     */
    public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface
    {
        /** @var GithubResourceOwner $githubUser */
        $githubUser = $this->getGithubClient()
            ->fetchUserFromToken($credentials);

        $clientId = $githubUser->getId();

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

        if ($existingUser) {
            return $existingUser;
        }

        $githubUserData = $githubUser->toArray();

        $user = User::fromGithubRequest(
            (string) $clientId,
            $githubUserData['email'] ?? $githubUserData['login'],
            $githubUserData['name']
        );

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

        return $user;
    }

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

    /**
     * @param Request $request
     * @param TokenInterface $token
     * @param string $providerKey
     *
     * @return null|Response
     */
    public function onAuthenticationSuccess(
        Request $request,
        TokenInterface $token,
        $providerKey
    ): ?Response
    {
        return new RedirectResponse($this->router->generate('proglib_app'));
    }

    /**
     * @return OAuth2Client
     */
    private function getGithubClient(): OAuth2Client
    {
        return $this->clientRegistry->getClient('github');
    }
}

Как вы могли заметить, тут есть небольшая разница с прошлым Guard’ом, а именно - методы onAuthenticationFailure и onAuthenticationSuccess теперь возвращают не null, а конкретный респонс. Для чего это было сделано? Дело в том, что если вам понадобится реализовать функциональность remember_me и хранить данные об авторизации пользователя не только в сессии, но и в куках, вам нужно вернуть конкретный респонс из этих методов, как просит Symfony.

Теперь нам осталось настроить файл config/packages/security.yaml:

security:
    providers:
        user_provider:
            entity:
                class: App\Domain\User\Model\Entity\User
                property: email
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            remember_me:
                secret:   '%kernel.secret%'
                lifetime: 31536000
                always_remember_me: true
            anonymous: true
            guard:
                entry_point: App\Security\OAuthGoogleAuthenticator
                authenticators:
                    - App\Security\OAuthGoogleAuthenticator
                    - App\Security\OAuthGithubAuthenticator
            logout:
                path: logout

Помимо того, что мы добавили новый Guard в наш список, мы также добавили такую настройку как entry_point. Дело в том, что когда у нас в системе есть несколько разных аутентификаторов, занимающихся авторизацией по апи, через социальные сети или с простой формы, нам нужно определить один из них как главный, прописав его в entry_point. Также мы добавили функциональность remember_me, благодаря которой пользователь будет аутентифицирован целый год.

Теперь у вас можем выскочить ошибка при авторизации через гугл, так как методы onAuthenticationFailure и onAuthenticationSuccess по-прежнему возвращают null. Исправим это:

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

    /**
     * @param Request $request
     * @param TokenInterface $token
     * @param string $providerKey
     *
     * @return null|Response
     */
    public function onAuthenticationSuccess(
        Request $request,
        TokenInterface $token,
        $providerKey
    ): ?Response
    {
        return new RedirectResponse($this->router->generate('blog_posts'));
    }

На этом все. В следующих статьях мы начнем глубже знакомиться с Doctrine и отношениями между сущностями.

Знакомство с созданием консольных команд: cron.

Знакомство с командами

Когда вы только познакомились с фреймворком Symfony (или это был Laravel?), вы могли заметить, как много всего можно сделать в проекте, используя консольные команды. Вы можете создать базу, сущность, контроллер, форму, вотер (о которых в ближайших уроках будет рассказано), целый CRUD, можете обновить таблицы, загрузить фикстуры, запустить встроенный веб-сервер, очистить кэш, продебажить контейнер, любой другой компонент фреймворка и многое другое. И это даже не все, что может Symfony. Но самое интересное - это то, что фреймворк позволяет расширять пул консольных команд вашими собственными. Давайте напишем собственную команду, а также запустим крон, который будет выполнять ее каждые 2 минуты.

Чтобы создать консольную команду в Symfony, вам нужно всего лишь создать собственный класс, который будет наследовать класс Command из Symfony\Component\Console\Command. Также вам нужно определить метод execute(). Это именно тот метод, который будет запускаться после вызова вашей команды. А чтобы команду зарегистрировать, вам нужно реализовать метод configure(), где указать имя, описание (опционально) и аргументы (опционально). Итак, простая реализация будет выглядеть следующим образом:

<?php

declare(strict_types=1);

namespace App\Application\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class UserCommand extends Command
{
    protected function configure()
    {
        $this
            ->setName('user:create')
            ->setDescription('Create a test user.');
    }

    public function execute(InputInterface $input, OutputInterface $output)
    {
    }
}

Если вы выполните в консоли команду php bin/console, то увидите следующее:

В самом низу вы можете увидеть нашу команду и описание к ней. Давайте сделаем нашу команду умнее:

<?php

declare(strict_types=1);

namespace App\Application\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class UserCommand extends Command
{
    protected function configure()
    {
        $this
            ->setName('user:create')
            ->setDescription('Create a test user.')
            ->addArgument('email', InputArgument::REQUIRED)
            ->addArgument('password', InputArgument::REQUIRED)
            ->addArgument('name', InputArgument::OPTIONAL);
    }

    public function execute(InputInterface $input, OutputInterface $output)
    {
        $email = $input->getArgument('email');
        $password = $input->getArgument('password');
        $name = $input->getArgument('name');

        $output->writeln(
            sprintf('Электронный адрес - %s, Пароль - %s, Имя - %s', $email, $password, $name ?? 'default')
        );
    }
}

Чтобы наша команда начала принимать какие-то аргументы, вы должны это определить в методе конфигурации, добавив вызов методов addArgument(), куда передав название аргумента и вторым параметром тип принимаемого аргументы - обязательный, опциональный или массив. В методе execute() мы достаем эти параметры и показываем их пользователю обратно. Чтобы посмотреть на результат выполнения этой команды, выполните в консоли следующее:

php bin/console user:create youremail@gmail.com password123, yourname 

Именно так, по порядку, вы передаете аргументы нашей команде. Однако незнакомому с нашей командой человеку может быть непонятно, в каком порядке передавать аргументы, или же вы сами забудете порядок. В этом случае можно делать подсказки пользователю, когда и что вводить. Например, теперь наш код будет таким:

<?php

declare(strict_types=1);

namespace App\Application\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;

class UserCommand extends Command
{
    protected function configure()
    {
        $this
            ->setName('user:create')
            ->setDescription('Create a test user.');
    }

    public function execute(InputInterface $input, OutputInterface $output)
    {
        $questionHelper = $this->getHelper('question');

        $email = $questionHelper->ask($input, $output, new Question('<info>Email: </info>'));
        $password = $questionHelper->ask($input, $output, new Question('<info>Password: </info>'));
        $name = $questionHelper->ask($input, $output, new Question('<info>Name: </info>'));

        $output->writeln(
            sprintf('<comment>Имя пользователя - %s, электронный адрес - %s, пароль - %s</comment>',
                $name, $email, $password
            )
        );

    }
}

С помощью метода getHelper() мы достали один из таких хелперов, а именно - question. С помощью команды ask он позволяет спрашивать у пользователя данные. Вы могли заметить, что мы использовали разметку и , благодаря им код будет подсвечиваться в консоли, как пример ниже:

Cron

С помощью композера скачайте следующий пакет:

composer require mybuilder/cronos-bundle

Когда бандл скачается, в папке config создайте файл cronos.yaml и сохраните там следующие настройки:

my_builder_cronos:
  exporter:
    key: unique-key
    mailto: your@email.com
    path: /bin:/usr/local/bin
    executor: /usr/bin/php
    console: /var/www/html/webshake/bin/console
    shell: /bin/bash

Что нас здесь интересует, так это директивы executor и console, в которых мы указываем путь до нашего php, которым вы обычно запускаете консольные команды Symfony, и путь до консоли текущего проекта. То есть при запуске крона команда будет выглядеть следующим образом:

/usr/bin/php /var/www/html/webshake/bin/console user:create

Чтобы Symfony увидела настройки крона, импортируйте их в файл services.yaml в самом начале:

Чтобы ваша текущая команда стала крон-командой, вам всего лишь нужно определить это в аннотации над классом:

/**
 * @Cron(minute="/2", noLogs=true)
 */
class UserCommand extends Command

Это означает, что команда будет запускаться каждые 2 минуты. Подробнее про время в кроне читайте в официальной или любой другой документации. Теперь запустите команду:

php bin/console cronos:dump

Вы должны получить примерно следующее:

Однако это не значит, что теперь крон заработал. Чтобы этот пакет запускал наши команды в установленное время, нам нужно иметь рабочий crontab. Если на данный момент у вас нет ни одного кронтаба, создайте его командой crontab -e и добавьте туда совершенно любой крон. Чтобы проверить работу крона, создадим простой менеджер, который просто будет записывать в какую-нибудь таблицу (назовем ее events) сообщение о том, что команда выполнилась:

<?php

declare(strict_types=1);

namespace App\Application\Command;

use App\Application\Manager\AbstractManager;

class UserManager extends AbstractManager
{
    public function recordEvent(string $username, string $data)
    {
        $sql = '
            INSERT INTO
                events (username, data, isRead)
            VALUES
                (:username, :data, 0)
        ';

        $this->getConnection()
            ->prepare($sql)
            ->execute([
                'username' => $username,
                'data' => $data
            ]);
    }
}

Теперь прокинем этот менеджер через конструктор в команду и в метод execute() вызовем recordEvent:

<?php

declare(strict_types=1);

namespace App\Application\Command;

use MyBuilder\Bundle\CronosBundle\Annotation\Cron;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * @Cron(minute="/2", noLogs=true)
 */
class UserCommand extends Command
{
    /**
     * @var UserManager
     */
    private $userManager;

    public function __construct(UserManager $userManager)
    {
        parent::__construct();
        $this->userManager = $userManager;
    }

    protected function configure()
    {
        $this
            ->setName('user:create')
            ->setDescription('Create a test user.');
    }

    public function execute(InputInterface $input, OutputInterface $output)
    {
        $this->userManager->recordEvent(
            'User',
            'Событие произошло'
        );
    }
}

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

php bin/console cronos:replace

Вы должны увидеть примерно следующее:

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

Выводы

Итак, на этом уроке мы познакомились и с консольными командами, и с тем, как с их помощью выполнять отложенные задачи. Вы можете с помощью это команды доставать письма из базы и делать по ним рассылку, очищать спам и многое другое.

Работа с доктриной. Связь один ко многим: форма комментариев на сайте. Часть 1

На прошлых уроках мы написали первую сущность, поработали с формой, стандартной авторизацией и авторизацией через соц сети. Однако в реальных приложениях все намного сложнее: есть многочисленные связи между сущностями, которые надо правильно обрабатывать. К счастью, Symfony в качестве инструмента с базой данных использует Doctrine, которая очень удобна как раз для решения вопросов отношений. Сегодня мы с вами познакомимся с первым типом отношений - ManyToOne/OnyToMany.

Основы ассоциаций в Doctrine

Мы уже делали сущность Post на одном из первых уроков, поэтому мы не будем ничего переписывать, а прямо в ней сделаем отношение с другой сущностью, Comment. Для начала давайте создадим ее.

<?php

declare(strict_types=1);

namespace App\Entity;

use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity()
 */
class Comment
{

    /**
     * @ORM\Id()
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(type="text", nullable=false)
     */
    private $comment;

    /**
     * @var Post
     *
     * @ORM\ManyToOne(targetEntity=Post::class, inversedBy="comments")
     * @ORM\JoinColumn(referencedColumnName="id")
     */
    private $post;

    /**
     * @var User
     *
     * @ORM\ManyToOne(targetEntity=User::class, inversedBy="comments")
     * @ORM\JoinColumn(referencedColumnName="id")
     */
    private $user;

    /**
     * @var DateTimeImmutable
     *
     * @ORM\Column(type="date_immutable")
     */
    private $createdAt;

    /**
     * @var DateTimeImmutable
     *
     * @ORM\Column(type="date_immutable")
     */
    private $updatedAt;

    private function __construct()
    {
        $this->createdAt = new DateTimeImmutable();
        $this->updatedAt = new DateTimeImmutable();
    }

    /**
     * @param string $content
     * @param User $user
     * @param Post $post
     *
     * @return Comment
     */
    public static function create(string $content, User $user, Post $post): Comment
    {
        $comment = new self();
        $comment->comment = $content;
        $comment->post = $post;
        $comment->user = $user;

        return $comment;
    }

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

    /**
     * @return User
     */
    public function getUser(): User
    {
        return $this->user;
    }

    /**
     * @return Post
     */
    public function getPost(): Post
    {
        return $this->post;
    }

    /**
     * @param Post $post
     */
    public function setPost(Post $post)
    {
        $this->post = $post;
    }

    /**
     * @param User $user
     */
    public function setUser(User $user)
    {
        $this->user = $user;
    }
}

Давайте сначала поразмышляем, как правильно определить отношение между сущностями. У нас есть комментарии и есть публикация. У публикации может быть много комментариев, но комментарий может принадлежать только одной сущности. Отсюда и возникает, что мы должны определить к таблице Post и User отношение ManyToOne, потому как только одному посту и одному пользователю могут принадлежать много комментариев, но не наоборот.

Аннотация ManyToOne принимает несколько аргументов, один из них - targetEntity - является ссылкой (можно указывать как Entity::class, так и полный неймспейс до сущности) на класс для связи, другой inversedBy, где указывается поле для связи. В нашем случае к сущностям User и Post мы позже добавим поле comments. Важно заметить, что связь с двух сторон можно не указывать, потому как доктрина умная и додумает сама, как соединить (а именно по id), но если вам понадобится получить комментарии для всех постов, то удобно достать их через публикацию, хоть это и не очень быстро.

Кстати, @JoinColumn() можно не указывать, доктрина все равно создаст нам такие же поля, как и с ней, однако если вы хотите изменить название поля или референс, то надо использовать данную аннотацию.

Теперь давайте укажем связи в сущностях Post и User, делается это следующим образом:

// Post.php

   /**
     * @var Comment[]
     *
     * @ORM\OneToMany(targetEntity=Comment::class, mappedBy="post")
     */
    private $comments;

// User.php

   /**
     * @var Comment[]
     *
     * @ORM\OneToMany(targetEntity=Comment::class, mappedBy="user")
     */
    private $comments;

При этом в конструкторе классов Post и User надо определить поле comments как коллекцию объектов:

public function __construct()
{
   $this->comments = new ArrayCollection();
}

Вы могли заметить небольшие отличия в аннотациях ManyToOne и OneToMany, а именно на то, что у них разные атрибуты - inversedBy и mappedBy. inversedBy указывается в аннотации ManyToOne и указывает на родителя связи, mappedBy же указывают на обратную сторону двунаправленной связи.

При этом не надо указывать @ORM\Column() для полей связи, так как тип связующего поля возьмется по id, и если вы укажете, то аннотация @ManyToOne или @OneToMany просто не выполнится. Теперь можете запускать миграции:

php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate

Если вы посмотрите в базу, то увидите таблицу comment со следующими полями:

id | post_id | user_id | comment | created_at | updated_at

Все сработало ровно так, как нам и нужно было.

Добавление комментария

Для добавления комментария к посту мы будем работать с коллекциями. Для этого нам сначала нужно добавить следующие методы в сущность Post:

public function addComment(Comment $comment): void
{
     $comment->setPost($this);

     if (!$this->comments->contains($comment)) {
          $this->comments->add($comment);
      }
}
public function removeComment(Comment $comment): void
{
    $this->comments->removeElement($comment);
}

У ArrayCollection достаточно простой API, советую познакомиться с ним по ссылке.
В первом методе мы сеттим Post и проверяем, содержится ли уже в коллекции комментарий с таким id, если нет, добавляем, если да, ничего не делаем. Ну и удаление работает так же просто: вызываем метод коллекции removeElement, куда передаем сущность Comment. Теперь давайте создадим форму:

class CommentType extends AbstractType
{
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
       $builder
            ->add('comment', TextareaType::class, [
                'label' => 'Новый комментарий',
            ])
        ;
    }
    /**
     * {@inheritdoc}
     */
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Comment::class,
        ]);
    }

Кажется, все готово, чтобы можно было под постом добавить комментарий. Для этого сначала напишем экшен контроллера PostsController, а потом отрендерим шаблон:

      /**
        * @Route("/post/{slug}", methods={"POST"}, name="comment_new")
        *
        */
       public function commentNew(Post $post, Request $request)
       {
            $comment = new Comment();
            $comment->setUser($this->getUser());
            $post->addComment($comment);

            $form = $this->createForm(CommentType::class, $comment);

            $form->handleRequest($request);

            if ($form->isSubmitted() && $form->isValid()) {
                $em = $this->getDoctrine()->getManager();
                $em->persist($comment);
                $em->flush();

               return $this->redirectToRoute('post_show', ['slug' => $post->getSlug()]);
            }

             return $this->render('post/show.html.twig', [
               'post' => $post,
               'form' => $form->createView()
            ]);
       } 

Как вы могли заметить, мы по одному роуту и показываем публикацию, и создаем комментарий. Примерно так может выглядеть итоговый шаблон:

{% extends 'base.html.twig' %}

{% block title %}{{ post.title }}{% endblock %}

{% block body %}
<br>
<div class="container col-sm-12">
    <h4>{{ post.title }}</h4>
    <p>{{ post.body }}</p><hr>
    <span>{{ post.getCreatedAt() | date("m/d/Y") }}</span><br><hr>
    <div class="container">
        {{ form_start(form) }}
            {{ form_row(form.comment) }}
         <button type="submit" class="btn btn-primary">Отправить</button>
        {{ form_end(form) }}
    </div>
    {%  for comment in post.comments %}
        <p>{{ comment.comment }}</p><hr>
    {% endfor %}
</div>
{% endblock %}

И вот теперь нам понадобилась связь в сущности Post, так как через post.comments мы достали все комментарии, принадлежащие конкретному посту. Так же вы можете достать все комментария юзера через user.comments в шаблоне или $this->getUser()->getComments() в коде.

Итого

На этом пока все. Еще больше концепций и правил по работе с отношениями мы рассмотрим в следующих уроках.

P.S.

Если у вас будут проблемы с последней версией Symfony или с пониманием работы отношений, пишите вопросы в комментариях.

4 симпатии