HWIOAuthBundle 2.2.0 and Symfony 6.4

Для начала о чем этот бандл - а он про авторизацию с помощью внешних систем (которым мы привыкли доверять), пример:

Итак, какие проблемы я буду решать данным бандлом:

  1. для начала я хочу доверить крупным компаниям проверку пользователей, а сам получать уже проверенных пользователей
  2. авторизацию я хочу отдать этим же крупным компаниям, а сам получать уже приватные данные этих пользователей
  3. в конце концов, я хочу получать обновления приватных данных всех авторизованных на моем сайте пользователей

Важно: тем кому не нужны размышления, а нужно сразу решение - переходите к параграфу "Реализация".

Поехали

1. Устанавливаем:

composer require hwi/oauth-bundle

Как и написано в документации - получаем ошибку:

  -  WARNING  hwi/oauth-bundle (>=2.0): From github.com/symfony/recipes-contrib:main
    The recipe for this package comes from the "contrib" repository, which is open to community contributions.
    Review the recipe at https://github.com/symfony/recipes-contrib/tree/main/hwi/oauth-bundle/2.0

    Do you want to execute this recipe?
    [y] Yes
    [n] No
    [a] Yes for all packages, only for the current installation session
    [p] Yes permanently, never ask again for this project
    (defaults to n): y
  - Configuring hwi/oauth-bundle (>=2.0): From github.com/symfony/recipes-contrib:main
Executing script cache:clear [OK]
Executing script assets:install public [OK]
Executing script importmap:install [OK]
              
 What's next? 
              

Some files have been created and/or updated to configure your new packages.
Please review, edit and commit them: these files are yours.

 hwi/oauth-bundle  instructions:

  * Configure your application:
    1. Add firewall by adding - main:
                                             anonymous: true
                                             oauth:
                                                 resource_owners:
                                                     my_github:     "/login/check-github"
                                                 login_path:        /login
                                                 use_forward:       false
                                                 failure_path:      /login

                                                 oauth_user_provider:
                                                     service: hwi_oauth.user.provider
 in your security firewalls section.

  * Read the documentation at https://github.com/hwi/HWIOAuthBundle/blob/master/docs/3-configuring_the_security_layer.md

2. Все хорошо, нас предупреждали, теперь согласно документации переходим к Step 2 и попробуем для начала указать параметры для авторизации с помощь Google, для этого в файле backend/config/packages/hwi_oauth.yaml указываем:

hwi_oauth:
    # https://github.com/hwi/HWIOAuthBundle/blob/master/docs/2-configuring_resource_owners.md
    resource_owners:
        google:
            type:                google
            client_id:           'GOOGLE_ID'
            client_secret:       'GOOGLE_SECRET'
            scope:               "email profile"

Напомню, что получить подключить сайт к Гуглу можно так: 

Go to your project on Google cloud console and select 'APIs & Services'.

On the credentials tab you can select 'create credentials' and then 'OAuth client ID'.

In the next screen choose 'Web application' as application type.

Add http://127.0.0.1:8000/login/check-google to 'Authorized redirect URIs' and click 'create'

You now get a 'Client ID' and 'Client secret'.

3. В в services объявляем провайдера, который реализует OAuthAwareUserProviderInterface это либо свой, либо нам предлагаю выбрать одного из двух уже реализованных:

  1. HWI\Bundle\OAuthBundle\Security\Core\User\OAuthUserProvider (имя службы: hwi_oauth.user.provider) - не сохраняет пользователей
  2. HWI\Bundle\OAuthBundle\Security\Core\User\EntityUserProvider (имя сервиса: hwi_oauth.user.provider.entity) - загружает пользователей из базы данных.

Использование hwi_oauth.user.provider.entity

Выбираю 2-ой провайдер, т.к. планирую хранить всех авторизованных пользователей в своей базе и их данные:

# config/services.yaml
services:
    hwi_oauth.user.provider.entity:
        class: HWI\Bundle\OAuthBundle\Security\Core\User\EntityUserProvider
        arguments:
            $class: App\Entity\User
            $properties:
                'google': 'google'

Важно: переменная $properties ожидает массив строк, где ключ — это имя владельца ресурса (определено в config/packages/security.yaml, см. ниже), а значение — это имя свойства сущности (т. е. App\Entity\User).

3.2. Настраиваем брандмауэр указывая следующее в файле config/packages/security.yaml

security:
    enable_authenticator_manager: true
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            pattern: ^/
            oauth:
                resource_owners:
                    google:             "/login/check-google"
                login_path: /login
                use_forward: false
                failure_path: /login

                oauth_user_provider:
                    service: hwi_oauth.user.provider.entity

    access_control:
        - { path: ^/login, roles: PUBLIC_ACCESS }
        - { path: ^/backoffice, roles: ROLE_USER }

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

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

This page isn’t working
127.0.0.1 redirected you too many times.
Try deleting your cookies.
ERR_TOO_MANY_REDIRECTS

В консоли это выглядит так:

curl -I http://127.0.0.1:8000/backoffice/
HTTP/1.1 302 Found
Server: nginx/1.26.2
Content-Type: text/html; charset=utf-8
Connection: keep-alive
Cache-Control: max-age=0, must-revalidate, private
Date: Sun, 20 Oct 2024 09:01:57 GMT
Location: http://127.0.0.1:8000/login
X-Debug-Token: e92089
X-Debug-Token-Link: http://127.0.0.1:8000/_profiler/e92089
Expires: Sun, 20 Oct 2024 09:01:57 GMT
Set-Cookie: sf_redirect=%7B%22token%22%3A%22e92089%22%2C%22route%22%3A%22app_backoffice_backoffice%22%2C%22method%22%3A%22HEAD%22%2C%22controller%22%3A%7B%22class%22%3A%22Symfony%5C%5CBundle%5C%5CFrameworkBundle%5C%5CController%5C%5CRedirectController%22%2C%22method%22%3A%22urlRedirectAction%22%2C%22file%22%3A%22%5C%2Fapp%5C%2Fbackend%5C%2Fvendor%5C%2Fsymfony%5C%2Fframework-bundle%5C%2FController%5C%2FRedirectController.php%22%2C%22line%22%3A110%7D%2C%22status_code%22%3A302%2C%22status_text%22%3A%22Found%22%7D; path=/; httponly; samesite=lax
Set-Cookie: PHPSESSID=01c62186bf5f42ea9014bfa0e820b04d; path=/; httponly; samesite=lax

Ага, значит редиректит нас на страницу /login это хорошо, а далее с /login нас опять редиректит на /login потому что в документации сейчас прописана неправильная информация, нужно это:

- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }

заменить на это:

- { path: ^/login, roles: PUBLIC_ACCESS }

Теперь на странице /login/ вижу ссылку авторизации:

<a href="/api/redirect?key=6abe37847469bf9b905ee5d1014bfe730484eef8&to=http%3A%2F%2F127.0.0.1%3A8000%2Fconnect%2Fgoogle">google</a>

При этом в профайлере у нас изменилась вкладка Access Decision:

Как видно AuthenticatedVoter выдал право PUBLIC_ACCESS.

Ура, далее перехожу по ссылке http://127.0.0.1:8000/connect/google и вижу привычный интерфейс Google-авторизации, который спрашивает, доверяю ли я сайту мои персональные данные. Я соглашаюсь и Google редиректит на ранее указанную страницу, которая выдает ошибку: No route found for "GET http://127.0.0.1:8000/login/check-google"

Оказывается (и это не документировано), нужно в файл hwi_oauth_routing.yaml добавить:

google_login:
    path: /login/check-google

Пробуем авторизоваться и получаем ошибку: Class 'App\Entity\User' does not exist (мы этот класс прописали выше).

Ага, оказывается HWIOAuthBundle надеется, что у нас есть класс пользователя, создаем его:

<?php

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[Assert\Email]
    #[Assert\NotBlank]
    #[ORM\Column(length: 255, unique: true, nullable: false)]
    private string $email;

    //string The hashed password
    #[ORM\Column(length: 255, unique: true, nullable: false)]
    private string $password;

    #[ORM\Column(type: 'json')]
    private array $roles = [];

    /**
     * @SerializedName("password")
     */
    #[ORM\Column(length: 255, unique: true, nullable: false)]
    private string $plainPassword = '';

    // @ORM\Column(name="google_id", type="string", length=255, nullable=true)
    #[ORM\Column(length: 255, nullable: true)]
    protected string $google;

    public function getGoogle(): string
    {
        return $this->google;
    }

    public function setGoogle(string $value): static
    {
        $this->google = $value;
        return $this;
    }

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

    /**
     * @see UserInterface
     */
    public function getRoles(): array
    {
        $roles = $this->roles;
        // guarantee every user at least has ROLE_USER
        $roles[] = 'ROLE_USER';

        return array_unique($roles);
    }

    public function addRole(string $role): self
    {
        $this->roles[] = $role;

        return $this;
    }

    public function setRoles(array $roles): self
    {
        $this->roles = $roles;

        return $this;
    }

    public function hasRole(string $role)
    {
        return in_array($role, $this->getRoles(), true);
    }

    /**
     * @see UserInterface
     * Создан, т.к. этого требует интерфейс UserInterface.
     * After a successful authentication, Symfony calls this method.
     * идея в следующем: удаляет конфиденциальные данные о пользователе. Это важно, если, в какой-то момент,
     * конфиденциальная информация (например, plain-text password хранится в этом объекте).
     */
    public function eraseCredentials()
    {
        // If you store any temporary, sensitive data on the user, clear it here
        $this->plainPassword = '';
    }

    public function getPlainPassword(): ?string
    {
        return $this->plainPassword;
    }

    public function setPlainPassword(string $plainPassword): self
    {
        $this->plainPassword = $plainPassword;

        return $this;
    }

    public function setEmail(string $email): self
    {
        $this->email = $email;

        return $this;
    }

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

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

    public function setPassword(string $password): self
    {
        $this->password = $password;

        return $this;
    }

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

    // данное имя метода применялось до symfony 5.4, но из интерфейса этот метод не убрали (уберут в symfony 6)
    public function getUsername(): string
    {
        return $this->email;
    }
}

Пытаемся авторизоваться и ура оказываемся на главной странице нашего сайта, но profiler показывает, что я не авторизован.

 Name                            Method     Scheme   Host   Path                       
------------------------------- ---------- -------- ------ ----------------------------
 _preview_error                  ANY        ANY      ANY    /_error/{code}.{_format}   
 hwi_oauth_service_redirect      GET        ANY      ANY    /connect/{service}         
 hwi_oauth_connect_service       GET|POST   ANY      ANY    /connect/service/{service} 
 hwi_oauth_connect_registration  GET|POST   ANY      ANY    /connect/registration/{key}
 hwi_oauth_connect               GET        ANY      ANY    /login/                    
 google_login                    ANY        ANY      ANY    /login/check-google 

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

Озарение

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

  1. OAuthUserProvider - всего лишь создает объект new \HWI\Bundle\OAuthBundle\Security\Core\User\OAuthUser() из которого можно достать username авторизованного пользователя.
  2. EntityUserProvider - всего лишь пытается найти в базе (в таблице пользователей) строку с параметром "название_oauth_сервиса" равного "username авторизованного пользователя", чтобы создать Ваш объект пользователя (например App\Entity\User). Все, более ничего данный провайдер не делает (данный провайдер не создает строк в базе, он рассчитывает, что такие пользователи уже есть в Вашей базе, как же глупо).

Кстати, оба провайдера получают на вход объект \HWI\Bundle\OAuthBundle\OAuth\Response\UserResponseInterface из которого можно достать username авторизованного пользователя и многое другое об этом пользователе.

Фактически это означает, что если мы хотим создавать в своей базе пользователей после их авторизации, то нам нужен свой провайдер. Создаем такой:

<?php
declare(strict_types=1);

namespace App\Service;

use App\Entity\User;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager;
use Doctrine\Persistence\ObjectRepository;
use HWI\Bundle\OAuthBundle\OAuth\Response\UserResponseInterface;
use HWI\Bundle\OAuthBundle\Security\Core\Exception\AccountNotLinkedException;
use HWI\Bundle\OAuthBundle\Security\Core\User\OAuthAwareUserProviderInterface;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

/**
 * Copied from \HWI\Bundle\OAuthBundle\Security\Core\User\EntityUserProvider and modified
 */
final class EntityUserProvider implements UserProviderInterface, OAuthAwareUserProviderInterface
{
    private ObjectManager $em;
    private string $class;
    private ?ObjectRepository $repository = null;

    /**
     * @var array<string, string>
     */
    private array $properties = [
        'identifier' => 'id',
    ];

    /**
     * @param string                $class      User entity class to load
     * @param array<string, string> $properties Mapping of resource owners to properties
     */
    public function __construct(ManagerRegistry $registry, string $class, array $properties, ?string $managerName = null)
    {
        $this->em = $registry->getManager($managerName);
        $this->class = $class;
        $this->properties = array_merge($this->properties, $properties);
    }

    public function loadUserByIdentifier(string $identifier): UserInterface
    {
        $user = $this->findUser(['username' => $identifier]);

        if (!$user) {
            throw $this->createUserNotFoundException($identifier, sprintf("User '%s' not found.", $identifier));
        }

        return $user;
    }

    /**
     * Symfony <5.4 BC layer.
     *
     * @param string $username
     *
     * @return UserInterface
     */
    public function loadUserByUsername($username)
    {
        return $this->loadUserByIdentifier($username);
    }

    public function loadUserByOAuthUserResponse(UserResponseInterface $response): ?UserInterface
    {
        $resourceOwnerName = $response->getResourceOwner()->getName();

        if (!isset($this->properties[$resourceOwnerName])) {
            throw new \RuntimeException(sprintf("No property defined for entity for resource owner '%s'.", $resourceOwnerName));
        }
        if (empty($this->properties[$resourceOwnerName])) {
            return null;
        }
        $username = method_exists($response, 'getUserIdentifier') ? $response->getUserIdentifier() : $response->getUsername();
        if (empty($username)) {
            return null;
        }
        if (null === $user = $this->findUser([$this->properties[$resourceOwnerName] => $username])) {
            $user = new User();
            if ($resourceOwnerName === 'google') {
                $user->setGoogle($username);
                $user->setEmail($response->getEmail());
                $user->setFirstName($response->getFirstName());
                $user->setLastName($response->getLastName());
                $user->setImage($response->getProfilePicture());
            } else {
                return null;
            }
            $user->setPassword($username);
            $this->em->persist($user);
            $this->em->flush();
        }

        return $user;
    }

    public function refreshUser(UserInterface $user): UserInterface
    {
        $accessor = PropertyAccess::createPropertyAccessor();
        $identifier = $this->properties['identifier'];
        if (!$accessor->isReadable($user, $identifier) || !$this->supportsClass($user::class)) {
            throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
        }

        $userId = $accessor->getValue($user, $identifier);

        $username = $user->getUserIdentifier();

        if (null === $user = $this->findUser([$identifier => $userId])) {
            throw $this->createUserNotFoundException($username, sprintf('User with ID "%d" could not be reloaded.', $userId));
        }

        return $user;
    }

    public function supportsClass($class): bool
    {
        return $class === $this->class || is_subclass_of($class, $this->class);
    }

    private function findUser(array $criteria): ?UserInterface
    {
        if (null === $this->repository) {
            $this->repository = $this->em->getRepository($this->class);
        }

        return $this->repository->findOneBy($criteria);
    }

    private function createUserNotFoundException(string $username, string $message): UserNotFoundException
    {
        $exception = new AccountNotLinkedException($message);
        $exception->setUserIdentifier($username);

        return $exception;
    }
}

Пробуем авторизоваться и в профайлере видим, что авторизация выполнена, ура.

Session

Как пишут в документации, в начале каждого запроса пользователь загружается из Session (если только ваш firewall не stateless). Провайдер «обновляет» пользователя (например, база данных снова запрашивается для получения свежих данных), чтобы убедиться, что вся информация о пользователе актуальна (и при необходимости пользователь деаутентифицируется/выходит из системы, если что-то изменилось). Подробнее об этом процессе см. в разделе Безопасность .

В подтверждении этого, при дебаге в момент посещения страниц сайта, можно заметить, что Symfony сохраняет объект пользователя в сессии, используя при этом \Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage.

Следующей консольной командой можно увидеть, что сессии включены: enabled: true

$ bin/console debug:config framework session

Current configuration for "framework.session"
=============================================

handler_id: null
cookie_secure: auto
cookie_samesite: lax
enabled: true
storage_factory_id: session.storage.factory.native
cookie_httponly: true
gc_probability: 1
metadata_update_threshold: 0
save_path: /app/var/cache/dev/sessions

А следующей командой можно увидеть все разнообразие мира сессий существующего в Symfony:

$ bin/console debug:container session

 Select one of the following services to display its information:
  [0 ] argument_resolver.session
  [1 ] session.factory
  [2 ] session.storage.factory.native
  [3 ] session.storage.factory.php_bridge
  [4 ] session.storage.factory.mock_file
  [5 ] session.handler.native
  [6 ] session.handler.native_file
  [7 ] session.abstract_handler
  [8 ] session_listener
  [9 ] session.marshaller
  [10] data_collector.request.session_collector
  [11] doctrine.orm.listeners.pdo_session_handler_schema_listener
  [12] security.authentication.session_strategy
  [13] security.authentication.session_strategy_noop
  [14] security.logout.listener.session
  [15] security.listener.session
  [16] security.listener.session.main
  [17] hwi_oauth.storage.session
  [18] SessionHandlerInterface
  [19] session.storage.factory
  [20] session.handler
  [21] Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface
  [22] security.authentication.session_strategy.main
 > 

Все вроде бы ничего, но серьезный сайт:

  • либо хранит сессии в каком-нибудь storag-e например Memcache, Aerospike, etc
  • либо не хранит сессии вовсе, используя JWT (мы пойдем этим путем)

А что если отключить Session

Попробую отключить sessions в файле framework.yaml

# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
    # Enables session support. Note that the session will ONLY be started if you read or write from it.
    # Remove or comment this section to explicitly disable session support.
    session:
        enabled: false
when@test:
    framework:
        test: true
# закомментировал, иначе при тестировании Symfony выбрасывает 
# UnexpectedSessionUsageException('Session was used while the request was declared stateless.');
#        session:
#            storage_factory_id: session.storage.factory.mock_file
# еще вариант избавиться от ошибки при тестировании использовать export APP_DEBUG=0

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

session.factory
session.storage.factory.native
session.storage.factory.php_bridge
session.storage.factory.mock_file
session.handler.native
session.handler.native_file
session.abstract_handler
session_listener
session.marshaller
doctrine.orm.listeners.pdo_session_handler_schema_listener
security.listener.session.main
SessionHandlerInterface
session.storage.factory
session.handler

Пробую авторизоваться и ловим ошибку: "Session has not been set" которую выбрасывает \Symfony\Component\HttpFoundation\Request::getSession и оказывается, не смотря на то, что мы выключили сессии, Symfony все равно думает, что можно использовать Sessions. Нахожу в документации еще один флаг выключающий сессии для авторизации:

# config/packages/security.yaml
security:
    firewalls:
        main:
            stateless: true

Но увы ловлю "Session has not been set" и пожалуй все, что может помочь, это добавить в Request атрибут _stateless и тогда в \Symfony\Component\Security\Http\Authentication\DefaultAuthenticationSuccessHandler::determineTargetUrl сработает условие !$request->attributes->getBoolean('_stateless') но делать такой костыль через RequestListener мне кажется неправильным, как и подменять SessionStorage на хранение данных в Cookie пользователя (вообще бредово и опасно).

Мыслим дальше, сессии отключены, что же у нас есть:

  1. \HWI\Bundle\OAuthBundle\HWIOAuthEvents - множество интересных событий, но без объекта Response
  2. \HWI\Bundle\OAuthBundle\OAuth\RequestDataStorage\SessionStorage - я решил не использовать PHP-сессии
  3. \Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface - не хочется включать сессии и делать свой сторедж
  4. \Symfony\Component\Security\Http\Firewall\AuthenticatorManagerListener - вызывается при каждом посещении страниц
  5. \Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface - вроде как можно сделать свой сторедж
  6. \Symfony\Component\Security\Core\AuthenticationEvents::AUTHENTICATION_SUCCESS - можно подписаться на событие
  7. \Symfony\Component\Security\Http\Event\LoginSuccessEvent - подписаться тут и выставлять куку
  8. \Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface - или свой аутентификатор

Заметка: не смотря на то, что событий и способов больше, но по факту только LoginSuccessEvent имеет Response.

Думаю, с помощью 8 и 9 можно реализовать создание Cookie в которой сохраню ID пользователя, но не просто данные, а подсолив (на это меня подтолкнула кем-то сконфигурированная работа LexikJWTAuthenticationBundle совместно HWIOAuthBundle).

Реализация

Итак, план такой:

  1. HWI\Bundle\OAuthBundle\Security\Http\Authenticator\OAuthAuthenticator - аутентифицирует пользователей во внешних системах
  2. App\Service\SecurityUserProvider - добавляет в базу аутентифицированных пользователей (если такого пользователя нет в б.д.)
  3. App\Service\SecurityUserAuthenticator - создает куку авторизованного пользователя ИЛИ из ранее выставленной куки создает объект Symfony\Component\Security\Http\Authenticator\Passport\Passport чтобы AuthenticatorManager авторизовал пользователя.

Настраиваю конфиги и пишу код, в итоге вот что у меня вышло:

1. config/packages/hwi_oauth.yaml

hwi_oauth:
    # https://github.com/hwi/HWIOAuthBundle/blob/master/docs/2-configuring_resource_owners.md
    resource_owners:
        google:
            type:                google
            client_id:           'GOOGLE_ID'
            client_secret:       'GOOGLE_SECRET'
            scope:               "email profile"

2. config/routes/hwi_oauth_routing.yaml

hwi_oauth_redirect:
    resource: "@HWIOAuthBundle/Resources/config/routing/redirect.php"
    prefix:   /connect

hwi_oauth_connect:
    resource: "@HWIOAuthBundle/Resources/config/routing/connect.php"
    prefix:   /connect

hwi_oauth_login:
    resource: "@HWIOAuthBundle/Resources/config/routing/login.php"
    prefix:   /login

google_login:
    path: /login/check-google

3. config/services.yaml

services:
    hwi_oauth.user.provider.entity:
        class: App\Service\SecurityUserProvider

    App\Service\SecurityUserAuthenticator:
        tags:
            - { name: kernel.event_listener, event: Symfony\Component\Security\Http\Event\LoginSuccessEvent, method: onLoginSuccess }

4. config/packages/security.yaml

security:
    enable_authenticator_manager: true
    # удивительно, объявление провайдера ниже нужно, чтобы Symfony мог находить пользователя в базе (почему
    # не используется oauth_user_provider я не знаю)
    providers:
        # the name of your user provider can be anything
        your_custom_user_provider:
            id: hwi_oauth.user.provider.entity
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            custom_authenticators:
                - App\Service\SecurityUserAuthenticator
            pattern: ^/
            oauth:
                resource_owners:
                    google:             "/login/check-google"
                login_path: /login
                use_forward: false
                failure_path: /login
                oauth_user_provider:
                    service: hwi_oauth.user.provider.entity

    access_control:
         - { path: ^/login, roles: PUBLIC_ACCESS }
         - { path: ^/backoffice, roles: ROLE_USER }

5. config/packages/framework.yaml

framework:
    # todo пока отключаю, ведь токены хранятся в PHP-сессии, хранящиеся в контейнерах, но балансировщик распределяет запросы
    # https://stackoverflow.com/questions/47249651/how-to-disable-csrf-in-symfony
    form:
        csrf_protection:
            enabled: false
    secret: '%env(APP_SECRET)%'
    #csrf_protection: true
    annotations: false
    http_method_override: false
    handle_all_throwables: true
    # https://symfony.com/blog/new-in-symfony-4-3-automatic-search-engine-protection
    disallow_search_engine_index: false

    session:
        handler_id: null
        cookie_secure: auto
        cookie_samesite: lax
        # отключаем сессии:
        enabled: false

    php_errors:
        log: true

6. backend/src/Service/SecurityUserAuthenticator.php

<?php
declare(strict_types=1);

namespace App\Service;

use App\Entity\User;
use stdClass;
use Symfony\Component\HttpFoundation\Cookie;
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\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;

// Copied from https://symfony.com/doc/6.4/security/custom_authenticator.html and modified
class SecurityUserAuthenticator extends AbstractAuthenticator
{
    public const COOKIE_NAME = 'oauth_user';
    public const COOKIE_SALT = 'salt';
    public const UNDEFINED_USER_ID = 'UNDEFINED_USER_ID';

    public function supports(Request $request): ?bool
    {
        // Если этого не делать, то в \Symfony\Component\Security\Http\Authentication\DefaultAuthenticationSuccessHandler::determineTargetUrl
        // сработает проверка и \Symfony\Component\HttpFoundation\Request::getSession выбросит SessionNotFoundException('Session has not been set.')
        $request->attributes->set('_stateless', true);
        return $this->getUserIdFromCookie($request) !== self::UNDEFINED_USER_ID;
    }

    public function authenticate(Request $request): Passport
    {
        $userIdentifier = $this->getUserIdFromCookie($request);
        return new SelfValidatingPassport(new UserBadge($userIdentifier));
    }

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

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        // я не хочу показывать что-либо пользователю, если он вдруг оказался неавторизован
        return null;
    }

    public function onLoginSuccess(LoginSuccessEvent $event): void
    {
        // не стал разбираться по какой причине в событии может не быть Response-объекта, но это не мешает, потому что
        // метод вызывается дважды - первый раз с Response-объектом, второй - без Response-объекта
        if (is_null($event->getResponse())) {
            return;
        }
        /** @var User $user */
        $user = $event->getAuthenticatedToken()->getUser();
        $value = new stdClass();
        $value->id = $user->getId();
        $value->hash = $this->getHash($value);
        $event->getResponse()->headers->setCookie(Cookie::create(self::COOKIE_NAME, $this->jsonEncode($value)));
    }
    public function getHash($value): string
    {
        return sha1($this->jsonEncode($value) . self::COOKIE_SALT);
    }

    public function getUserIdFromCookie(Request $request): string
    {
        $json = $request->cookies->get(self::COOKIE_NAME, '{}');
        $object = $this->jsonDecode($json);
        if (isset($object->id, $object->hash)) {
            $value = new stdClass();
            $value->id = $object->id;
            if ($this->getHash($value) === $object->hash) {
                return (string) $object->id;
            }
        }

        return self::UNDEFINED_USER_ID;
    }

    public function jsonEncode(mixed $array): string
    {
        return json_encode($array, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR );
    }

    public function jsonDecode($json, $assoc = false, $depth = 512, $options = 0)
    {
        $data = \json_decode($json, $assoc, $depth, $options);
        if (JSON_ERROR_NONE !== json_last_error()) {
            $message = json_last_error_msg();
            throw new \InvalidArgumentException('json_decode error: ' . ($message === false ? 'Unknown error' : $message));
        }
        return $data;
    }
}

7. src/Service/SecurityUserProvider.php

<?php
declare(strict_types=1);

namespace App\Service;

use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use HWI\Bundle\OAuthBundle\OAuth\Response\UserResponseInterface;
use HWI\Bundle\OAuthBundle\Security\Core\Exception\AccountNotLinkedException;
use HWI\Bundle\OAuthBundle\Security\Core\User\OAuthAwareUserProviderInterface;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

// Copied from \HWI\Bundle\OAuthBundle\Security\Core\User\EntityUserProvider and modified
final class SecurityUserProvider implements UserProviderInterface, OAuthAwareUserProviderInterface
{
    private array $properties = [
        'identifier' => 'id',
        'google' => 'google',
    ];

    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly UserRepository $userRepository,
    ) {
    }

    // используется при авторизации (в момент посещения любой страницы сайта)
    public function loadUserByIdentifier(string $identifier): UserInterface
    {
        $user = $this->findUser(['id' => $identifier]);

        if (!$user) {
            throw $this->createUserNotFoundException($identifier, sprintf("User '%s' not found.", $identifier));
        }

        return $user;
    }

    /**
     * Symfony <5.4 BC layer.
     *
     * @param string $username
     *
     * @return UserInterface
     */
    public function loadUserByUsername($username)
    {
        return $this->loadUserByIdentifier($username);
    }

    public function loadUserByOAuthUserResponse(UserResponseInterface $response): ?UserInterface
    {
        $resourceOwnerName = $response->getResourceOwner()->getName();

        if (!isset($this->properties[$resourceOwnerName])) {
            throw new \RuntimeException(sprintf("No property defined for entity for resource owner '%s'.", $resourceOwnerName));
        }
        if (empty($this->properties[$resourceOwnerName])) {
            return null;
        }
        $username = method_exists($response, 'getUserIdentifier') ? $response->getUserIdentifier() : $response->getUsername();
        if (empty($username)) {
            return null;
        }
        if (null === $user = $this->findUser([$this->properties[$resourceOwnerName] => $username])) {
            $user = new User();
            if ($resourceOwnerName === 'google') {
                $user->setGoogle($username);
                $user->setEmail($response->getEmail());
                $user->setFirstName($response->getFirstName());
                $user->setLastName($response->getLastName());
                $user->setImage($response->getProfilePicture());
            } else {
                return null;
            }
            $user->setPassword($username);
            $this->entityManager->persist($user);
            $this->entityManager->flush();
        }

        return $user;
    }

    public function refreshUser(UserInterface $user): UserInterface
    {
        $accessor = PropertyAccess::createPropertyAccessor();
        $identifier = $this->properties['identifier'];
        if (!$accessor->isReadable($user, $identifier) || !$this->supportsClass($user::class)) {
            throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
        }

        $userId = $accessor->getValue($user, $identifier);

        $username = $user->getUserIdentifier();

        if (null === $user = $this->findUser([$identifier => $userId])) {
            throw $this->createUserNotFoundException($username, sprintf('User with ID "%d" could not be reloaded.', $userId));
        }

        return $user;
    }

    private function findUser(array $criteria): ?UserInterface
    {
        return $this->userRepository->findOneBy($criteria);
    }

    public function supportsClass($class): bool
    {
        return $class === User::class || is_subclass_of($class, User::class);
    }

    private function createUserNotFoundException(string $username, string $message): UserNotFoundException
    {
        $exception = new AccountNotLinkedException($message);
        $exception->setUserIdentifier($username);

        return $exception;
    }
}

Готово.

P.s.

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

На всякий случай вот дамп моих security-настроек: bin/console config:dump-reference security

security:
    access_denied_url:    null # Example: /foo/error403
    session_fixation_strategy: migrate # One of "none"; "migrate"; "invalidate"
    hide_user_not_found:  true
    erase_credentials:    true
    enable_authenticator_manager: true # Deprecated (Since symfony/security-bundle 6.2: The "enable_authenticator_manager" option at "security" is deprecated.)
    access_decision_manager:
        strategy:             ~ # One of "affirmative"; "consensus"; "unanimous"; "priority"
        service:              ~
        strategy_service:     ~
        allow_if_all_abstain: false
        allow_if_equal_granted_denied: true
    password_hashers:

        # Examples:
        # App\Entity\User1:    auto
        # App\Entity\User2:    { algorithm: auto, time_cost: 8, cost: 13 }

        # Prototype
        class:
            algorithm:            ~
            migrate_from:         []

            # Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms.
            hash_algorithm:       sha512
            key_length:           40
            ignore_case:          false
            encode_as_base64:     true
            iterations:           5000
            cost:                 null
            memory_cost:          null
            time_cost:            null
            id:                   ~
    providers:

        # Examples:
        # my_memory_provider:  { memory: { users: { foo: { password: foo, roles: ROLE_USER }, bar: { password: bar, roles: '[ROLE_USER, ROLE_ADMIN]' } } } }
        # my_entity_provider:  { entity: { class: 'SecurityBundle:User', property: username } }

        # Prototype
        name:
            id:                   ~
            chain:
                providers:            []
            entity:

                # The full entity class name of your user class.
                class:                ~ # Required
                property:             null
                manager_name:         null
            memory:
                users:

                    # Prototype
                    identifier:
                        password:             null
                        roles:                []
            ldap:
                service:              ldap # Required
                base_dn:              ~ # Required
                search_dn:            null
                search_password:      null
                extra_fields:         []
                default_roles:        []
                uid_key:              sAMAccountName
                filter:               '({uid_key}={username})'
                password_attribute:   null
    firewalls:            # Required

        # Prototype
        name:
            pattern:              ~
            host:                 ~
            methods:              []
            security:             true

            # The UserChecker to use when authenticating users in this firewall.
            user_checker:         security.user_checker
            request_matcher:      ~
            access_denied_url:    ~
            access_denied_handler: ~

            # An enabled authenticator name or a service id that implements "Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface"
            entry_point:          ~
            provider:             ~
            stateless:            false
            lazy:                 false
            context:              ~
            logout:
                enable_csrf:          null
                csrf_token_id:        logout
                csrf_parameter:       _csrf_token
                csrf_token_generator: ~ # Deprecated (Since symfony/security-bundle 6.3: The "csrf_token_generator" option is deprecated. Use "csrf_token_manager" instead.)
                csrf_token_manager:   ~
                path:                 /logout
                target:               /
                invalidate_session:   true
                clear_site_data:      []
                delete_cookies:

                    # Prototype
                    name:
                        path:                 null
                        domain:               null
                        secure:               false
                        samesite:             null
                        partitioned:          false
            switch_user:
                provider:             ~
                parameter:            _switch_user
                role:                 ROLE_ALLOWED_TO_SWITCH
                target_route:         null

            # A list of badges that must be present on the authenticated passport.
            required_badges:      []

            # An array of service ids for all of your "authenticators"
            custom_authenticators: []
            login_throttling:

                # A service id implementing "Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface".
                limiter:              ~
                max_attempts:         5
                interval:             '1 minute'

                # The service ID of the lock factory used by the login rate limiter (or null to disable locking)
                lock_factory:         null
            oauth:
                provider:             ~
                remember_me:          true
                success_handler:      ~
                failure_handler:      ~
                check_path:           /login_check
                use_forward:          false
                require_previous_session: false # Deprecated (Since symfony/security-bundle 6.4: Option "require_previous_session" at "security.firewalls..oauth" is deprecated, it will be removed in version 7.0. Setting it has no effect anymore.)
                login_path:           ~ # Required
                always_use_default_target_path: false
                default_target_path:  /
                target_path_parameter: _target_path
                use_referer:          false
                failure_path:         null
                failure_forward:      false
                failure_path_parameter: _failure_path
                oauth_user_provider:  # Required
                    orm:
                        class:                ~ # Required
                        manager_name:         null
                        properties:           # Required

                            # Prototype
                            name:                 ~
                    service:              ~
                    oauth:                ~
                resource_owners:      # Required

                    # Prototype
                    name:                 ~
            x509:
                provider:             ~
                user:                 SSL_CLIENT_S_DN_Email
                credentials:          SSL_CLIENT_S_DN
                user_identifier:      emailAddress
            remote_user:
                provider:             ~
                user:                 REMOTE_USER
            login_link:

                # Route that will validate the login link - e.g. "app_login_link_verify".
                check_route:          ~ # Required

                # If true, only HTTP POST requests to "check_route" will be handled by the authenticator.
                check_post_only:      false

                # An array of properties on your User that are used to sign the link. If any of these change, all existing links will become invalid.
                signature_properties: [] # Required

                    # Examples:
                    # - email
                    # - password

                # The lifetime of the login link in seconds.
                lifetime:             600

                # Max number of times a login link can be used - null means unlimited within lifetime.
                max_uses:             null

                # Cache service id used to expired links of max_uses is set.
                used_link_cache:      ~

                # A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface.
                success_handler:      ~

                # A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface.
                failure_handler:      ~

                # The user provider to load users from.
                provider:             ~
                always_use_default_target_path: false
                default_target_path:  /
                login_path:           /login
                target_path_parameter: _target_path
                use_referer:          false
                failure_path:         null
                failure_forward:      false
                failure_path_parameter: _failure_path
            form_login:
                provider:             ~
                remember_me:          true
                success_handler:      ~
                failure_handler:      ~
                check_path:           /login_check
                use_forward:          false
                require_previous_session: false # Deprecated (Since symfony/security-bundle 6.4: Option "require_previous_session" at "security.firewalls..form_login" is deprecated, it will be removed in version 7.0. Setting it has no effect anymore.)
                login_path:           /login
                username_parameter:   _username
                password_parameter:   _password
                csrf_parameter:       _csrf_token
                csrf_token_id:        authenticate
                enable_csrf:          false
                post_only:            true
                form_only:            false
                always_use_default_target_path: false
                default_target_path:  /
                target_path_parameter: _target_path
                use_referer:          false
                failure_path:         null
                failure_forward:      false
                failure_path_parameter: _failure_path
                csrf_token_generator: ~
            form_login_ldap:
                provider:             ~
                remember_me:          true
                success_handler:      ~
                failure_handler:      ~
                check_path:           /login_check
                use_forward:          false
                require_previous_session: false # Deprecated (Since symfony/security-bundle 6.4: Option "require_previous_session" at "security.firewalls..form_login_ldap" is deprecated, it will be removed in version 7.0. Setting it has no effect anymore.)
                login_path:           /login
                username_parameter:   _username
                password_parameter:   _password
                csrf_parameter:       _csrf_token
                csrf_token_id:        authenticate
                enable_csrf:          false
                post_only:            true
                form_only:            false
                always_use_default_target_path: false
                default_target_path:  /
                target_path_parameter: _target_path
                use_referer:          false
                failure_path:         null
                failure_forward:      false
                failure_path_parameter: _failure_path
                csrf_token_generator: ~
                service:              ldap
                dn_string:            '{username}'
                query_string:         ~
                search_dn:            ''
                search_password:      ''
            json_login:
                provider:             ~
                remember_me:          true
                success_handler:      ~
                failure_handler:      ~
                check_path:           /login_check
                use_forward:          false
                require_previous_session: false # Deprecated (Since symfony/security-bundle 6.4: Option "require_previous_session" at "security.firewalls..json_login" is deprecated, it will be removed in version 7.0. Setting it has no effect anymore.)
                login_path:           /login
                username_path:        username
                password_path:        password
            json_login_ldap:
                provider:             ~
                remember_me:          true
                success_handler:      ~
                failure_handler:      ~
                check_path:           /login_check
                use_forward:          false
                require_previous_session: false # Deprecated (Since symfony/security-bundle 6.4: Option "require_previous_session" at "security.firewalls..json_login_ldap" is deprecated, it will be removed in version 7.0. Setting it has no effect anymore.)
                login_path:           /login
                username_path:        username
                password_path:        password
                service:              ldap
                dn_string:            '{username}'
                query_string:         ~
                search_dn:            ''
                search_password:      ''
            access_token:
                provider:             ~
                remember_me:          true
                success_handler:      ~
                failure_handler:      ~
                realm:                null
                token_extractors:

                    # Default:
                    - security.access_token_extractor.header
                token_handler:        # Required

                    # Example:
                    # id:                  App\Security\CustomTokenHandler
                    id:                   ~
                    oidc_user_info:

                        # Base URI of the userinfo endpoint on the OIDC server.
                        base_uri:             ~ # Required

                        # Claim which contains the user identifier (e.g. sub, email, etc.).
                        claim:                sub

                        # HttpClient service id to use to call the OIDC server.
                        client:               ~
                    oidc:

                        # Claim which contains the user identifier (e.g.: sub, email..).
                        claim:                sub

                        # Audience set in the token, for validation purpose.
                        audience:             ~ # Required

                        # Issuers allowed to generate the token, for validation purpose.
                        issuers:              [] # Required

                        # Algorithm used to sign the token.
                        algorithm:            ~ # Required

                        # JSON-encoded JWK used to sign the token (must contain a "kty" key).
                        key:                  ~ # Required
            http_basic:
                provider:             ~
                realm:                'Secured Area'
            http_basic_ldap:
                provider:             ~
                realm:                'Secured Area'
                service:              ldap
                dn_string:            '{username}'
                query_string:         ~
                search_dn:            ''
                search_password:      ''
            remember_me:
                secret:               '%kernel.secret%'
                service:              ~
                user_providers:       []
                catch_exceptions:     true

                # An array of properties on your User that are used to sign the remember-me cookie. If any of these change, all existing cookies will become invalid.
                signature_properties:

                    # Default:
                    - password

                    # Examples:
                    # - email
                    # - password
                token_provider:

                    # The service ID of a custom rememberme token provider.
                    service:              ~
                    doctrine:
                        enabled:              false
                        connection:           null

                # The service ID of a custom rememberme token verifier.
                token_verifier:       ~
                name:                 REMEMBERME
                lifetime:             31536000
                path:                 /
                domain:               null
                secure:               false # One of true; false; "auto"
                httponly:             true
                samesite:             null # One of null; "lax"; "strict"; "none"
                always_remember_me:   false
                remember_me_parameter: _remember_me
    access_control:

        # Prototype
        -
            request_matcher:      null
            requires_channel:     null

            # use the urldecoded format
            path:                 null # Example: '^/path to resource/'
            host:                 null
            port:                 null
            ips:                  []
            attributes:

                # Prototype
                key:                  ~
            route:                null
            methods:              []
            allow_if:             null
            roles:                []
    role_hierarchy:

        # Prototype
        id:                   []

И мой конфиг бандла: bin/console config:dump-reference hwi_oauth

hwi_oauth:
    firewall_names:       [] # Deprecated (Since hwi/oauth-bundle 2.0: option "hwi_oauth.firewall_names" is deprecated. Firewall names are collected automatically.)
    target_path_parameter: null
    target_path_domains_whitelist: []
    use_referer:          false
    failed_use_referer:   false
    failed_auth_path:     hwi_oauth_connect
    grant_rule:           IS_AUTHENTICATED_REMEMBERED
    connect:
        confirmation:         true
        account_connector:    ~
        registration_form_handler: ~
        registration_form:    ~
    resource_owners:      # Required

        # Prototype
        name:
            base_url:             ~
            access_token_url:     ~
            authorization_url:    ~
            request_token_url:    ~
            revoke_token_url:     ~
            infos_url:            ~
            client_id:            ~
            client_secret:        ~
            realm:                ~
            scope:                ~
            user_response_class:  ~
            service:              ~
            class:                ~
            type:                 ~
            use_authorization_to_get_token: ~
            paths:

                # Prototype
                name:                 ~
            options:

                # Prototype
                name:                 ~

Дизайн

По-умолчанию страница авторизации выглядит слишком просто, надо бы сделать ее в рамках дизайна сайта, для этого переопределим шаблон vendor/hwi/oauth-bundle/src/Resources/views/Connect/login.html.twig создав файл templates/bundles/HWIOAuthBundle/Connect/login.html.twig с таким содержимым:

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

{% block body %}
    {% if error is defined and error %}
        <span>{{ error }}</span>
    {% endif %}
    {% for owner in hwi_oauth_resource_owners() %}
        <a href="{{ hwi_oauth_login_url(owner) }}">{{ owner | trans({}, 'HWIOAuthBundle') }}</a> <br />
    {% endfor %}
{% endblock %}

p.s. адрес страницы, куда попадает пользователь после успешной авторизации указывают с помощью default_target_path

Источники: 1 - 2 - 3 - 4 - 5 - 6 - 7 - 8 - 9


11.09.2021 07:08

Комментарии

Комментариев пока нет.


Copyrights © 2007 - 2026 YaPro.Ru