Токены для API
Мобильное приложение или чужой сервис не входят через форму и не хранят сессию. Они присылают в каждом запросе токен — длинную случайную строку. В Symfony проверку токена включает настройка access_token.
Отдельный firewall
API и сайт защищают по-разному, поэтому для адресов /api заводят свой раздел:
# config/packages/security.yaml
security:
firewalls:
api:
pattern: ^/api
stateless: true
access_token:
token_handler: App\Security\AccessTokenHandler
main:
# обычный вход через форму
access_control:
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: ROLE_USER }stateless: true отключает сессию: каждый запрос проверяется заново.
Сущность токена
#[ORM\Entity(repositoryClass: AccessTokenRepository::class)]
class AccessToken
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 64, unique: true)]
private string $hash;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private User $user;
#[ORM\Column]
private \DateTimeImmutable $expiresAt;
public function isValid(): bool
{
return $this->expiresAt > new \DateTimeImmutable();
}
// конструктор и методы get
}В базе хранится не сам токен, а его хеш.
Обработчик токена
namespace App\Security;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class AccessTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(private AccessTokenRepository $tokens) {}
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
$token = $this->tokens->findOneBy(['hash' => hash('sha256', $accessToken)]);
if (!$token || !$token->isValid()) {
throw new BadCredentialsException('Invalid token');
}
return new UserBadge($token->getUser()->getUserIdentifier());
}
}Symfony сам достаёт токен из заголовка Authorization: Bearer … и передаёт его в метод.
Выдача токена
#[Route('/api/login', name: 'api_login', methods: ['POST'])]
public function login(
#[MapRequestPayload] LoginInput $input,
UserRepository $users,
UserPasswordHasherInterface $hasher,
EntityManagerInterface $em,
): JsonResponse {
$user = $users->findOneBy(['email' => $input->email]);
if (!$user || !$hasher->isPasswordValid($user, $input->password)) {
return $this->json(['error' => 'Неверная почта или пароль'], 401);
}
$plain = bin2hex(random_bytes(32));
$em->persist(new AccessToken(hash('sha256', $plain), $user, new \DateTimeImmutable('+30 days')));
$em->flush();
return $this->json(['token' => $plain]);
}Текущий пользователь
use Symfony\Component\Security\Http\Attribute\CurrentUser;
#[Route('/api/me', name: 'api_me')]
public function me(#[CurrentUser] User $user): JsonResponse
{
return $this->json(['email' => $user->getEmail()]);
}Запрос клиента
curl https://example.com/api/me -H "Authorization: Bearer 3f9a1c..."Без токена или с просроченным токеном ответ — код 401.
JWT
Другой распространённый вид токена — JWT: данные о пользователе хранятся в самом токене и подписаны ключом, поэтому обращаться к базе при каждом запросе не нужно. Его добавляет пакет lexik/jwt-authentication-bundle. Обратная сторона: выданный JWT нельзя отозвать до истечения срока.
Частая ошибка
Токен в базе в открытом виде — то же, что пароль в открытом виде: утечка базы отдаёт доступ ко всем учётным записям. Храните хеш, а сам токен показывайте клиенту один раз. И не передавайте токен в адресе запроса: адреса попадают в журналы сервера и историю браузера.