HardТеория5 min

Аутентификация

Security Core, CSRF, PasswordHasher, аутентификаторы, паспорта, бейджи

Компоненты безопасности Symfony

Безопасность в Symfony построена на нескольких компонентах, работающих вместе.

Symfony 8.0: Компоненты Security Core, CSRF и PasswordHasher теперь явно перечислены в темах экзамена. Authenticators, Passports и Badges -- новые темы, которых не было в экзамене Sf7.4.

Компонент Назначение
Security Core Ядро: токены, аутентификация, авторизация
Security HTTP Firewall, аутентификаторы, entry points
Security CSRF Защита от CSRF-атак
PasswordHasher Хеширование паролей

Архитектура аутентификации

HTTP Request
    |
    v
Firewall (security.yaml)
    |
    v
Authenticator (implements AuthenticatorInterface)
    |
    v
Passport (credentials + user + badges)
    |
    v
AuthenticationManager (validates passport)
    |
    v
Token (authenticated user stored in TokenStorage)
    |
    v
Response

Authenticator (Аутентификатор)

Аутентификатор -- класс, который отвечает за процесс аутентификации. Каждый аутентификатор реализует AuthenticatorInterface.

<?php

declare(strict_types=1);

namespace App\Security;

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\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;

final class ApiTokenAuthenticator extends AbstractAuthenticator
{
    public function __construct(
        private readonly TokenRepository $tokenRepository,
    ) {
    }

    /**
     * Called on every request -- decide if this authenticator should be used
     */
    public function supports(Request $request): ?bool
    {
        return $request->headers->has('X-API-TOKEN');
    }

    /**
     * Create a Passport with user info and credentials
     */
    public function authenticate(Request $request): Passport
    {
        $apiToken = $request->headers->get('X-API-TOKEN');

        if (null === $apiToken) {
            throw new CustomUserMessageAuthenticationException(
                'No API token provided'
            );
        }

        return new Passport(
            new UserBadge($apiToken, function (string $token): UserInterface {
                $user = $this->tokenRepository->findUserByToken($token);

                if (null === $user) {
                    throw new CustomUserMessageAuthenticationException(
                        'Invalid API token'
                    );
                }

                return $user;
            }),
            new SelfValidatingPassport() // No password check needed
        );
    }

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

    public function onAuthenticationFailure(
        Request $request,
        AuthenticationException $exception,
    ): ?Response {
        return new JsonResponse([
            'error' => strtr(
                $exception->getMessageKey(),
                $exception->getMessageData()
            ),
        ], Response::HTTP_UNAUTHORIZED);
    }
}

Методы AuthenticatorInterface

Метод Описание
supports(Request) Нужно ли обрабатывать этот запрос?
authenticate(Request) Создать Passport с данными пользователя
onAuthenticationSuccess() Действие при успешной аутентификации
onAuthenticationFailure() Действие при ошибке

Passport (Паспорт)

Passport -- контейнер данных аутентификации. Содержит UserBadge, credentials и дополнительные badges.

<?php

declare(strict_types=1);

use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;

// Full passport with badges
$passport = new Passport(
    // UserBadge -- identifies the user
    new UserBadge($email),

    // PasswordCredentials -- password to verify
    new PasswordCredentials($password),

    // Additional badges
    [
        new CsrfTokenBadge('authenticate', $csrfToken),
        new RememberMeBadge(),
    ]
);

Типы Credentials

Класс Описание
PasswordCredentials Проверка пароля через PasswordHasher
CustomCredentials Пользовательская проверка (callback)

SelfValidatingPassport

Для случаев, когда проверка учётных данных не требуется (API-токены, OAuth).

<?php

declare(strict_types=1);

use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;

// No credentials to check
$passport = new SelfValidatingPassport(
    new UserBadge($apiToken, fn(string $token) => $this->findUser($token))
);

Badges (Бейджи)

Symfony 8.0: Badges -- новая тема экзамена. Badges добавляют дополнительные проверки и функционал к процессу аутентификации.

Badge Описание
UserBadge Идентификация пользователя (обязательный)
CsrfTokenBadge Проверка CSRF-токена
RememberMeBadge Поддержка "Запомнить меня"
PasswordUpgradeBadge Автоматическое обновление хеша пароля
<?php

declare(strict_types=1);

namespace App\Security;

use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
use Symfony\Component\Security\Http\Util\TargetPathTrait;

final class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
    use TargetPathTrait;

    public function __construct(
        private readonly UrlGeneratorInterface $urlGenerator,
    ) {
    }

    public function authenticate(Request $request): Passport
    {
        $email = $request->getPayload()->getString('email');
        $password = $request->getPayload()->getString('password');
        $csrfToken = $request->getPayload()->getString('_csrf_token');

        $request->getSession()->set(
            SecurityRequestAttributes::LAST_USERNAME,
            $email,
        );

        return new Passport(
            new UserBadge($email),
            new PasswordCredentials($password),
            [
                new CsrfTokenBadge('authenticate', $csrfToken),
                new RememberMeBadge(),
                new PasswordUpgradeBadge($password),
            ]
        );
    }

    public function onAuthenticationSuccess(
        Request $request,
        TokenInterface $token,
        string $firewallName,
    ): ?Response {
        // Redirect to the page user tried to access before login
        if ($targetPath = $this->getTargetPath(
            $request->getSession(),
            $firewallName
        )) {
            return new RedirectResponse($targetPath);
        }

        return new RedirectResponse($this->urlGenerator->generate('app_home'));
    }

    protected function getLoginUrl(Request $request): string
    {
        return $this->urlGenerator->generate('app_login');
    }
}

CSRF-защита

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Csrf\CsrfToken;

final class DeleteController extends AbstractController
{
    public function delete(Request $request): Response
    {
        $token = $request->getPayload()->getString('_token');

        // Validate CSRF token
        if (!$this->isCsrfTokenValid('delete-item', $token)) {
            throw $this->createAccessDeniedException('Invalid CSRF token');
        }

        // Proceed with deletion
        return $this->redirectToRoute('app_list');
    }
}

В Twig-шаблоне:

<form method="post" action="{{ path('app_delete', {id: item.id}) }}">
    <input type="hidden" name="_token" value="{{ csrf_token('delete-item') }}">
    <button type="submit">Delete</button>
</form>

PasswordHasher

<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

final class UserRegistrationService
{
    public function __construct(
        private readonly UserPasswordHasherInterface $passwordHasher,
        private readonly EntityManagerInterface $entityManager,
    ) {
    }

    public function register(string $email, string $plainPassword): User
    {
        $user = new User();
        $user->setEmail($email);

        // Hash the password
        $hashedPassword = $this->passwordHasher->hashPassword(
            $user,
            $plainPassword,
        );
        $user->setPassword($hashedPassword);

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

        return $user;
    }

    public function verifyPassword(User $user, string $plainPassword): bool
    {
        return $this->passwordHasher->isPasswordValid($user, $plainPassword);
    }
}

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

# config/packages/security.yaml
security:
    password_hashers:
        # Auto-select the best algorithm
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'

        # Or specify explicitly
        App\Entity\User:
            algorithm: bcrypt
            cost: 13

Подвох экзамена: Значение 'auto' для алгоритма хеширования выберет лучший доступный алгоритм (bcrypt на момент Sf8). В тестовом окружении рекомендуется использовать plaintext или sodium с низкой стоимостью для скорости тестов.

UserInterface

<?php

declare(strict_types=1);

namespace App\Entity;

use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

final class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    private ?int $id = null;
    private string $email = '';
    private string $password = '';
    /** @var list<string> */
    private array $roles = [];

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

    /** @return list<string> */
    public function getRoles(): array
    {
        $roles = $this->roles;
        // Every user has ROLE_USER
        $roles[] = 'ROLE_USER';

        return array_unique($roles);
    }

    public function getPassword(): ?string
    {
        return $this->password;
    }

    public function eraseCredentials(): void
    {
        // Clear temporary sensitive data
        // $this->plainPassword = null;
    }
}

Итоги

  • Аутентификация в Sf8 построена на Authenticators + Passport + Badges
  • supports() определяет, нужен ли данный аутентификатор
  • authenticate() создаёт Passport с данными пользователя
  • Passport содержит UserBadge + Credentials + дополнительные badges
  • CsrfTokenBadge, RememberMeBadge, PasswordUpgradeBadge -- стандартные бейджи
  • PasswordHasher хеширует и проверяет пароли
  • UserInterface требует getUserIdentifier(), getRoles(), eraseCredentials()