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

Итак, какие проблемы я буду решать данным бандлом:
Важно: тем кому не нужны размышления, а нужно сразу решение - переходите к параграфу "Реализация".
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.md2. Все хорошо, нас предупреждали, теперь согласно документации переходим к 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-googleto 'Authorized redirect URIs' and click 'create'You now get a 'Client ID' and 'Client secret'.
3. В в services объявляем провайдера, который реализует OAuthAwareUserProviderInterface это либо свой, либо нам предлагаю выбрать одного из двух уже реализованных:
HWI\Bundle\OAuthBundle\Security\Core\User\OAuthUserProvider (имя службы: hwi_oauth.user.provider) - не сохраняет пользователей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 Ну что сказать, ошибок нет, авторизации нет, что внутри там происходит непонятно, поэтому начинаю ковырять код.
Оказывается и об этом никто нигде не пишет - ни один из провайдеров не является коробочным решением, т.е. нельзя просто выбрать один, чтобы в базе начали появляться авторизованные пользователи, это так не работает. А что же делают провайдеры:
OAuthUserProvider - всего лишь создает объект new \HWI\Bundle\OAuthBundle\Security\Core\User\OAuthUser() из которого можно достать username авторизованного пользователя.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 (если только ваш 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
>
Все вроде бы ничего, но серьезный сайт:
Попробую отключить 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 пользователя (вообще бредово и опасно).
Мыслим дальше, сессии отключены, что же у нас есть:
Заметка: не смотря на то, что событий и способов больше, но по факту только LoginSuccessEvent имеет Response.
Думаю, с помощью 8 и 9 можно реализовать создание Cookie в которой сохраню ID пользователя, но не просто данные, а подсолив (на это меня подтолкнула кем-то сконфигурированная работа LexikJWTAuthenticationBundle совместно HWIOAuthBundle).
Итак, план такой:
Настраиваю конфиги и пишу код, в итоге вот что у меня вышло:
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-google3. 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: true6. 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;
}
}Готово.
Возможно стоило посмотреть в сторону токенов доступа, но уж как вышло, так вышло, в будущем может переделаю.
На всякий случай вот дамп моих 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
Комментарии
Комментариев пока нет.