17

Rozdział 17 z 21

API admina, uwierzytelnianie i rate limiting

Opublikowany

Domykamy API o operacje zapisu dla administratora, chronione uwierzytelnianiem tokenem (access_token + ApiTokenHandler) oraz rate limitingiem (symfony/rate-limiter). Omówimy nagłówki X-RateLimit-* i odpowiedzi 401/429.

Czego się nauczysz

  • BlogAdminApiController — POST/PATCH/DELETE
  • Uwierzytelnianie tokenem Bearer (access_token)
  • Rate limiting (symfony/rate-limiter, X-RateLimit-*)
  • Bezpieczeństwo w dokumentacji OpenAPI

Rozdział 17: API admina, uwierzytelnianie i rate limiting

Domykamy Część IV. Publiczne API (Rozdział 16) rozszerzamy o operacje zapisu dla administratora (POST/PATCH/DELETE), chronione uwierzytelnianiem tokenem i rate limitingiem.

Stan wejściowy: publiczne REST API (Rozdział 16), encja User z polem apiToken (Rozdział 2). Tu dokładamy firewall API, kontroler admina i ograniczanie liczby żądań.


API admina

Operacje zapisu żyją w osobnym kontrolerze pod /api/admin/blog, chronionym ROLE_ADMIN na poziomie klasy. Dane przyjmujemy jako JSON w body (nie formularz), zapisujemy encję i zwracamy DTO:

// src/Api/Controller/BlogAdminApiController.php
#[Route('/api/admin/blog', name: 'api_admin_blog_')]
#[IsGranted('ROLE_ADMIN')]
class BlogAdminApiController extends AbstractController
{
    #[Route('/articles', name: 'article_create', methods: ['POST'])]
    public function articleCreate(Request $request, EntityManagerInterface $em, ValidatorInterface $validator): JsonResponse
    {
        $body = json_decode($request->getContent(), true) ?? [];

        $article = new BlogArticle();
        $user = $this->getUser();
        $article
            ->setTitle($body['title'] ?? '')
            ->setContent($body['content'] ?? '')
            ->setStatus($body['status'] ?? BlogArticle::STATUS_DRAFT)
            ->setAuthor($user instanceof User ? $user : null)
            ->setUpdatedAt(new \DateTimeImmutable());

        $slug = $body['slug'] ?? null;
        $article->setSlug(is_string($slug) && '' !== $slug ? $slug : (new Slugify())->slugify($article->getTitle()));

        // (opcjonalnie categoryId, tagIds, publishedAt z body) …

        $em->persist($article);
        $em->flush();

        return $this->json(BlogArticleDto::fromEntity($article), 201);
    }
}
  • json_decode($request->getContent()) — czytamy surowe JSON body (klient API nie wysyła formularza).
  • #[IsGranted('ROLE_ADMIN')] na klasie — cały kontroler wymaga admina.
  • Zwracamy DTO (jak w Rozdziale 16) — spójny kontrakt; 201 Created po utworzeniu.
  • Analogicznie PATCH /articles/{id} (aktualizacja) i DELETE /articles/{id} (usunięcie), plus CRUD kategorii i tagów.

Uwierzytelnianie tokenem

Publiczne GET-y zostają otwarte, ale operacje admina wymagają tożsamości. Dodajemy firewall dla ^/api z mechanizmem access_token — klient podaje token w nagłówku Authorization: Bearer <token>:

# config/packages/security.yaml
security:
    firewalls:
        api:
            pattern: ^/api
            lazy: true
            provider: app_user_provider
            context: main                 # współdziel sesję z firewallem 'main'
            access_token:
                token_handler: App\Security\ApiTokenHandler
        main:
            # … logowanie webowe z Rozdziału 2 …

ApiTokenHandler zamienia token na użytkownika — szuka go po kolumnie User::$apiToken (dodanej w Rozdziale 2):

// src/Security/ApiTokenHandler.php
final readonly class ApiTokenHandler implements AccessTokenHandlerInterface
{
    public function __construct(private UserRepository $users) {}

    public function getUserBadgeFrom(string $accessToken): UserBadge
    {
        $user = $this->users->findOneBy(['apiToken' => $accessToken]);

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

        return new UserBadge($user->getUserIdentifier());
    }
}
  • Dwa sposoby uwierzytelnienia (dual-auth): context: main sprawia, że firewall api dzieli sesję z webowym main. Zalogowany admin w przeglądarce korzysta z API przez sesję, a klient zewnętrzny — przez token Bearer.
  • Brak/zły token → 401 (a nie przekierowanie na /login) — bo access_token jest bezstanowym punktem wejścia API.
  • Publiczne GET-y działają dalej bez tokenu (anonimowo); dopiero endpointy z ROLE_ADMIN wymuszają uwierzytelnienie.

Skąd token? Zapisujemy go w User::$apiToken (np. komendą konsolową generującą losowy ciąg). W dev możesz ustawić stały token w fixtures. Nigdy nie commituj prawdziwych tokenów produkcyjnych.


Rate limiting

Żeby API nie dało się „zalać”, ograniczamy liczbę żądań. Definiujemy limiter (okno przesuwne, 100/min):

# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        api:
            policy: 'sliding_window'
            limit: 100
            interval: '1 minute'

Subscriber liczy żądania per użytkownik (gdy jest token/sesja) albo per IP (dla anonimowych), dokłada nagłówki X-RateLimit-* i zwraca 429 po przekroczeniu limitu:

// src/EventListener/ApiRateLimiterSubscriber.php
public function onRequest(RequestEvent $event): void
{
    if (!$event->isMainRequest() || !str_starts_with($event->getRequest()->getPathInfo(), '/api')) {
        return;
    }

    $user = $this->tokenStorage->getToken()?->getUser();
    $key  = null !== $user ? 'user-' . $user->getUserIdentifier() : 'ip-' . $event->getRequest()->getClientIp();

    $limit = $this->apiLimiter->create($key)->consume();
    $event->getRequest()->attributes->set(self::ATTRIBUTE, $limit);

    if (!$limit->isAccepted()) {
        throw new TooManyRequestsHttpException(
            max(1, $limit->getRetryAfter()->getTimestamp() - time()),
            'API rate limit exceeded.',
        );
    }
}

public function onResponse(ResponseEvent $event): void
{
    $limit = $event->getRequest()->attributes->get(self::ATTRIBUTE);
    if ($limit instanceof RateLimit) {
        $event->getResponse()->headers->add([
            'X-RateLimit-Limit'     => (string) $limit->getLimit(),
            'X-RateLimit-Remaining' => (string) $limit->getRemainingTokens(),
            'X-RateLimit-Reset'     => (string) $limit->getRetryAfter()->getTimestamp(),
        ]);
    }
}
  • Klucz per użytkownik lub IP — zalogowany/tokenowy klient ma własny licznik, anonimowi liczeni po IP.
  • Priorytet po firewallu — subscriber działa na kernel.request z priorytetem niższym niż firewall (7 < 8), więc w chwili liczenia użytkownik jest już rozpoznany.
  • Nagłówki X-RateLimit-* informują klienta o limicie, pozostałych żądaniach i czasie resetu; 429 z Retry-After po przekroczeniu.

Bezpieczeństwo w dokumentacji OpenAPI

Swagger (Rozdział 16) powinien wiedzieć, że endpointy admina wymagają tokenu. Definiujemy schemat bezpieczeństwa bearerAuth w konfiguracji NelmioApiDoc:

# config/packages/nelmio_api_doc.yaml
nelmio_api_doc:
    documentation:
        components:
            securitySchemes:
                bearerAuth: { type: http, scheme: bearer }

…i oznaczamy chronione akcje atrybutem #[Security]:

use Nelmio\ApiDocBundle\Attribute\Security;

#[Route('/articles', name: 'article_create', methods: ['POST'])]
#[OA\Post(summary: 'Utwórz artykuł (admin)')]
#[Security(name: 'bearerAuth')]
public function articleCreate(/* … */): JsonResponse

Dzięki temu w Swaggerze pojawia się pole na token („Authorize”), a przy chronionych endpointach — kłódka.


Podsumowanie

Blog ma pełne API z zabezpieczeniami:

  • ✅ API admina (/api/admin/blog, ROLE_ADMIN) — POST/PATCH/DELETE z JSON body, zwraca DTO,
  • ✅ uwierzytelnianie tokenem — firewall access_token + ApiTokenHandler (po User::$apiToken), dual-auth (sesja + token przez context: main), 401 zamiast redirectu,
  • ✅ rate limiting — sliding_window 100/min, licznik per użytkownik/IP, nagłówki X-RateLimit-*, 429 z Retry-After,
  • ✅ OpenAPI — schemat bearerAuth + #[Security] na chronionych akcjach,
  • ✅ działający stan: publiczne GET-y otwarte, operacje admina wymagają Authorization: Bearer, a API jest chronione przed nadużyciami.

To koniec Części IV — blog ma kompletne, udokumentowane i zabezpieczone API.

W Części V (Rozdziały 18–20) domkniemy projekt: testy jednostkowe, testy funkcjonalne oraz deployment i SEO. Zaczniemy w Rozdziale 18.

Narzędzia / paczki

access_token ApiTokenHandler symfony/rate-limiter

Spis treści