Encodera

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.

Оглавление

Токены для 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 нельзя отозвать до истечения срока.

Частая ошибка

Токен в базе в открытом виде — то же, что пароль в открытом виде: утечка базы отдаёт доступ ко всем учётным записям. Храните хеш, а сам токен показывайте клиенту один раз. И не передавайте токен в адресе запроса: адреса попадают в журналы сервера и историю браузера.

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.