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
Userz polemapiToken(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 Createdpo utworzeniu. - Analogicznie
PATCH /articles/{id}(aktualizacja) iDELETE /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: mainsprawia, że firewallapidzieli sesję z webowymmain. 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) — boaccess_tokenjest bezstanowym punktem wejścia API. - Publiczne GET-y działają dalej bez tokenu (anonimowo); dopiero endpointy z
ROLE_ADMINwymuszają 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.requestz 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 zRetry-Afterpo 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(poUser::$apiToken), dual-auth (sesja + token przezcontext: main), 401 zamiast redirectu, - ✅ rate limiting —
sliding_window100/min, licznik per użytkownik/IP, nagłówkiX-RateLimit-*, 429 zRetry-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.