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

В этом руководстве рассматриваются 10 расширенных шаблонов использования Symfony 7.4.

Аутентификация API без сохранения состояния с помощью AuthenticationEntryPointInterface

Современным приложениям часто требуется API без сохранения состояния наряду с интерфейсом с сохранением состояния. Начиная с Symfony 6.2+, AuthenticationEntryPointInterface является предпочтительным способом обработки токенов API (JWT, Opaque и т. д.) без накладных расходов старых аутентификаторов Guard.

 Сценарий:Вам необходимо обеспечить безопасность маршрутов под /apiиспользуя Носительтокен, проверяя его по базе данных или внешнему сервису, полностью минуя сеансы.

 Создайте аутентификатор:

 namespace App\Security;

use App\Repository\ApiTokenRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;

class ApiTokenAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
{
    public function __construct(private readonly ApiTokenRepository $apiTokenRepository)
    {
    }

    public function supports(Request $request): ?bool
    {
        return $request->headers->has('Authorization') && str_starts_with($request->headers->get('Authorization'), 'Bearer ');
    }

    public function authenticate(Request $request): Passport
    {
        $apiToken = substr($request->headers->get('Authorization'), 7);
        if (null === $apiToken) {
            throw new CustomUserMessageAuthenticationException('No API token provided');
        }

        $token = $this->apiTokenRepository->findOneBy(['token' => $apiToken]);
        if (null === $token || !$token->isValid()) {
            throw new CustomUserMessageAuthenticationException('Invalid API Token');
        }

        return new SelfValidatingPassport(new UserBadge($token->getOwner()->getUserIdentifier()));
    }

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

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $data = ['message' => strtr($exception->getMessageKey(), $exception->getMessageData())];

        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }

    public function start(Request $request, AuthenticationException $authException = null): Response
    {
        return new JsonResponse(['message' => 'Authentication Required'], Response::HTTP_UNAUTHORIZED);
    }
}
 

*Настройте security.yaml:
*

         api:
            pattern: ^/api
            stateless: true
            custom_authenticators:
                - App\Security\ApiTokenAuthenticator
            entry_point: App\Security\ApiTokenAuthenticator
 

 Проверка:

Выполните запрос cURL. Вы должны получить 401 без заголовка и 200 с ним.

 curl -I -H "Authorization: Bearer YOUR_VALID_TOKEN" http://localhost:8080/api/profile
 

Беспарольная аутентификация «Magic Link»

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

 Настройте аутентификатор:
Включите собственный аутентификатор login_link.

             login_link:
                check_route: login_check
                signature_properties: ['id', 'email']
                lifetime: 600
 

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

 namespace App\Controller;

use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
use Symfony\Component\Security\Http\LoginLink\LoginLinkNotification;

class SecurityController extends AbstractController
{

    #[Route('/login/link', name: 'login_link_start', methods: ['GET', 'POST'])]
    public function requestLink(
        Request $request,
        LoginLinkHandlerInterface $loginLinkHandler,
        NotifierInterface $notifier,
        UserRepository $userRepository
    ): Response {
        if ($request->isMethod('POST')) {
            $email = $request->request->get('email');
            $user = $userRepository->findOneBy(['email' => $email]);

            if ($user) {
                $loginLinkDetails = $loginLinkHandler->createLoginLink($user);

                $notification = (new LoginLinkNotification(
                    $loginLinkDetails,
                    'Welcome back! Click to login.'
                ));
                $notifier->send($notification, new Recipient($user->getEmail()));

                // For demonstration, we'll dump the link to the profiler instead of sending an email.
                // In a real app, you'd remove this.
                $this->addFlash('success', 'Login link sent! Check the profiler for the link.');
                $this->addFlash('login_link', $loginLinkDetails->getUrl());
            }

            return $this->render('security/link_sent.html.twig');
        }

        return $this->render('security/request_login_link.html.twig');
    }

    #[Route('/login/check', name: 'login_check')]
    public function check(): void
    {
        throw new \LogicException('This controller should not be reached!');
    }
}
 

 Проверка:

Отправьте форму. Проверьте транспорт вашей почтовой программы (например, мессенджер или локальные журналы). Нажмите на созданную ссылку. Вы должны быть мгновенно аутентифицированы.

Динамическая иерархия ролей из базы данных

Стандартные роли Symfony определены статически в файлеsecurity.yaml. Корпоративным приложениям часто требуются динамические роли, настраиваемые через интерфейс администратора.

Мы украшаем Security.role_hierarchyуслуга.

 Создайте декоратор

 namespace App\Security;

use App\Repository\RoleRepository;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\Security\Core\Role\RoleHierarchy;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;

#[AsDecorator('security.role_hierarchy')]
class DatabaseRoleHierarchy implements RoleHierarchyInterface
{
    private RoleHierarchyInterface $mergedHierarchy;

    public function __construct(
        // The decorated service is not used, but is required for the decorator pattern
        RoleHierarchyInterface $inner, 
        array $staticHierarchy,
        private readonly RoleRepository $roleRepository
    ) {
        $dbHierarchy = $this->roleRepository->findAllHierarchy();

        // array_merge_recursive can have unexpected results with numeric keys, but it's fine for role strings.
        $merged = array_merge_recursive($staticHierarchy, $dbHierarchy);

        $this->mergedHierarchy = new RoleHierarchy($merged);
    }

    public function getReachableRoleNames(array $roles): array
    {
        return $this->mergedHierarchy->getReachableRoleNames($roles);
    }
}
 

 Проверка:

Назначение пользовательской роли ROLE_SUPER_MANAGERпользователю в БД. Гарантировать ROLE_SUPER_MANAGERнаследует ROLE_ADMINв вашей таблице базы данных. Войти и сбросить $user->getRoles(). Вы должны увидеть, что унаследованные роли появляются автоматически.

Сложная бизнес-логика с избирателями и атрибутами

Не помещайте логику авторизации в контроллеры. Используйте избирателей. В Symfony 7.4 мы можем комбинировать #[Разрешено]с конкретными темами и атрибутами для чистого кода.

Пользователь может редактировать сообщение, только если он является автором ИЛИ если он является редактором и сообщение находится в статусе «Опубликовано» (но не черновик).

 Избиратель:

 namespace App\Security\Voter;

use App\Entity\Post;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class PostVoter extends Voter
{
    public const string EDIT = 'POST_EDIT';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return $attribute === self::EDIT && $subject instanceof Post;
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if (!$user instanceof User) {
            return false;
        }

        /** @var Post $post */
        $post = $subject;

        // Rule 1: Author can always edit
        if ($post->getAuthor() === $user) {
            return true;
        }

        // Rule 2: Editors can only edit if Published
        if (in_array('ROLE_EDITOR', $user->getRoles()) && $post->isPublished()) {
            return true;
        }

        return false;
    }
}
 

 Контроллер:

 namespace App\Controller;

use App\Entity\Post;
use App\Security\Voter\PostVoter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

class PostController extends AbstractController
{
    #[Route('/post/{id}/edit', name: 'post_edit')]
    #[IsGranted(PostVoter::EDIT, subject: 'post')]
    public function edit(Post $post): Response
    {
        return $this->render('post/edit.html.twig', [
            'post' => $post,
        ]);
    }
}
 

 Проверка:

Попробуйте получить доступ к пути редактирования черновика сообщения, не являясь автором. Вы получите сообщение 403 «Доступ запрещен».

Ограничение скорости попыток входа в систему
Защита от грубой силы имеет решающее значение. Symfony интегрирует RateLimiter непосредственно в брандмауэр.

 Настроить ограничитель скорости:

 # config/packages/security.yaml
security:
    firewalls:
        main:
            login_throttling:
                max_attempts: 3
 

 Проверка:

Попытайтесь войти в систему с неправильным паролем 4 раза подряд. Четвертая попытка приведет к броску 429 Слишком много запросовошибка, не позволяющая проверке даже попасть в базу данных.

Программный вход (автоматический вход после регистрации)

После регистрации пользователя заставлять его снова вводить пароль — это плохой UX. Вы можете войти в систему программно с помощью помощника по безопасности.

 namespace App\Controller;

use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;

class RegistrationController extends AbstractController
{
    #[Route('/register', name: 'app_register', methods: ['GET', 'POST'])]
    public function register(
        Request $request,
        UserPasswordHasherInterface $passwordHasher,
        EntityManagerInterface $entityManager,
        Security $security
    ): Response
    {
        if ($request->isMethod('POST')) {
            $email = $request->request->get('email');
            $password = $request->request->get('password');

            $user = new User();
            $user->setEmail($email);
            $user->setPassword($passwordHasher->hashPassword($user, $password));

            $entityManager->persist($user);
            $entityManager->flush();

            return $security->login($user,'security.authenticator.form_login.main');
        }

        return $this->render('registration/register.html.twig');
    }
}
 

 Проверка:

Зарегистрируйте нового пользователя через форму. Обратите внимание, что вы сразу же перенаправляетесь на панель мониторинга, и профилировщик показывает, что вы прошли проверку подлинности.

Блокировка заблокированных пользователей (проверка пользователей)

Если вы заблокируете пользователя в базе данных, он все равно может войти в систему, если его сеанс активен. ЮзерЧекерзапускается при каждом запросе активных сеансов.

 namespace App\Security;

use App\Entity\User;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;

class UserEnabledChecker implements UserCheckerInterface
{
    public function checkPreAuth(UserInterface $user): void
    {
        if (!$user instanceof User) {
            return;
        }

        if ($user->isDeleted()) {
            throw new CustomUserMessageAccountStatusException('Your account has been deleted.');
        }
    }

    public function checkPostAuth(UserInterface $user): void
    {
        if (!$user instanceof User) {
            return;
        }

        if ($user->isSuspended()) {
            throw new CustomUserMessageAccountStatusException('Your account is suspended.');
        }
    }
}
 

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

Symfony автоматически помечает классы, реализующие ПользовательскийЧекерИнтерфейс. Однако вы должны явно связать его в брандмауэре.

 security:
    firewalls:
        main:
            lazy: true
            provider: app_user_provider
            user_checker: App\Security\UserEnabledChecker
 

 Проверка:

Войдите в систему как действительный пользователь. Вручную изменить их is_suspendedпометить истинныйв базе данных. Обновите страницу. Вы должны немедленно выйти из системы и быть перенаправлены на страницу входа с сообщением об ошибке.

Олицетворение (сменить пользователя)

Разрешить администраторам «становиться» другими пользователями для устранения проблем.

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

 security:
    firewalls:
        main:
            switch_user: true 

    role_hierarchy:
        ROLE_ADMIN: [ROLE_ALLOWED_TO_SWITCH]
 

 Ограничить целевых пользователей (необязательно, но рекомендуется):

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

 namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Event\SwitchUserEvent;

#[AsEventListener(event: SwitchUserEvent::class, method: 'onSwitchUser')]
class SwitchUserListener
{
    public function onSwitchUser(SwitchUserEvent $event): void
    {
        $targetUser = $event->getTargetUser();

        if ($targetUser !== null && in_array('ROLE_SUPER_ADMIN', $targetUser->getRoles())) {
            throw new AccessDeniedException('Cannot impersonate Super Admins.');
        }
    }
}
 

 Проверка:

Будучи администратором, посетите ?_switch_user=someuser@example.com. На панели инструментов должно отображаться «Олицетворение». Посещать ?_switch_user=_exitвернуться.

Комплексный контроль доступа с помощью выражений

Иногда ROLE_ADMINнедостаточно в access_control. Вам нужна логика типа «Админы с IP 10.0.0.1» или «Пользователи с определенным заголовком».

 security: 
    access_control:
        - { path: ^/api, roles: ROLE_USER }
        - { path: ^/admin/sensitive, allow_if: "is_granted('ROLE_ADMIN') and request.getClientIp() in ['127.0.0.1', '::1']" }
 

 Проверка:

Попробуйте получить доступ /админ/чувствительныйс другого IP (или имитировать IP в тесте). Он должен запретить доступ, даже если у вас есть ROLE_ADMIN.

Ведение журнала аудита безопасности через события

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

 namespace App\EventSubscriber;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\SecurityEvents;

readonly class SecurityAuditSubscriber implements EventSubscriberInterface
{
    public function __construct(private LoggerInterface $securityLogger) {}

    public static function getSubscribedEvents(): array
    {
        return [
            SecurityEvents::INTERACTIVE_LOGIN => 'onLoginSuccess',
            LoginFailureEvent::class => 'onLoginFailure',
        ];
    }

    public function onLoginSuccess(InteractiveLoginEvent $event): void
    {
        $user = $event->getAuthenticationToken()->getUser();
        $this->securityLogger->info('User Login Success', [
            'username' => $user->getUserIdentifier(),
            'ip' => $event->getRequest()->getClientIp(),
        ]);
    }

    public function onLoginFailure(LoginFailureEvent $event): void
    {
        $this->securityLogger->warning('User Login Failure', [
            'ip' => $event->getRequest()->getClientIp(),
            'error' => $event->getException()->getMessage(),
            'passport' => $event->getPassport()?->getUser()->getUserIdentifier(),
        ]);
    }
}
 

 Проверка:

Сохраните файл журнала (tail -f var/log/dev.log) и выполните вход. Вы увидите структурированную запись журнала JSON для события аутентификации.

Заключение

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

В Symfony 7.4 безопасность больше не ограничивается предотвращением несанкционированного доступа к URL-адресу. Речь идет о создании беспрепятственного пользовательского опыта — будь то с помощью Magic Links, которые уменьшают трение, олицетворения, которое расширяет возможности групп поддержки, или ограничителей скорости, которые незаметно защищают вашу инфраструктуру.

Выходя за рамки стандартного form_login и используя такие инструменты, как Voters и пользовательские UserCheckers, вы переносите логику безопасности из своих контроллеров в выделенные, тестируемые классы. Это соответствует основной философии современной разработки PHP: разделение. Ваши контроллеры остаются тонкими, ваша бизнес-логика остается чистой, а ваши политики безопасности становятся активами многократного использования, а не жестко запрограммированными условными проверками.

При реализации этих шаблонов помните, что безопасность — это не функция, которую вы добавляете в конце; это архитектурный стандарт, который вы внедряете с самого начала.

 Исходный код:Вы можете найти полную реализацию и следить за ходом проекта на GitHub: [https://github.com/mattleads/SecurityPatterns]

Давайте соединимся!

Если вы нашли это полезным или у вас есть вопросы по поводу реализации, я буду рад услышать ваше мнение. Давайте оставаться на связи и поддерживать разговор на этих платформах: