Blog
Symfony Security — kiedy firewalle i uwierzytelnianie zaczynają ze sobą kolidować
Logowanie działa w jednej części aplikacji, ale API zwraca 401 albo redirect do formularza. Przyczyną często jest inny firewall, authenticator lub reguła dostępu niż ta, której oczekuje zespół.
Panel działa, a API zwraca 401 — od czego zacząć?
Załóżmy, że użytkownik loguje się do panelu administracyjnego Symfony. Otwiera pulpit i chronione strony HTML, ale wywołanie z tej samej przeglądarki:
GET /api/admin/pages HTTP/1.1
Host: app.example.com
Accept: application/jsonkończy się odpowiedzią:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="admin-api"
Content-Type: application/json
{"error":"authentication_required"}Tutaj API oczekuje tokenu Bearer, a logowanie do panelu ustanawia sesję. Oba żądania pochodzą z tej samej przeglądarki, lecz samo to nie sprawia, że API zaakceptuje sesję jako sposób uwierzytelniania.
Przykłady dotyczą Symfony 7.4 i PHP od wersji 8.2. Fragmenty konfiguracji są niepełne; trzeba je dopasować do zainstalowanej wersji oraz modelu uwierzytelniania aplikacji.
Najpierw ustal, który firewall pasuje
Najpierw sprawdziłbym, który firewall rzeczywiście obsłużył /api/admin/pages. W Symfony firewall grupuje ustawienia uwierzytelniania dla pasujących żądań. Framework wybiera pierwszy pasujący wpis w konfiguracji, nawet gdy dalszy wzorzec jest bardziej szczegółowy.
Poniższy niepełny fragment ma celowo błędną kolejność:
security:
firewalls:
main:
pattern: ^/
lazy: true
api:
pattern: ^/api
stateless: truemain przechwytuje /api/admin/pages, więc api nie zostaje rozpatrzony. lazy: true nie zmienia tego wyboru. Jeśli podział na dwa firewalle jest zamierzony, szczegółowy wzorzec powinien poprzedzać ogólny. Następnie trzeba sprawdzić przypisany authenticator, dostawcę użytkowników i reguły dostępu — sama zmiana kolejności nie uruchamia uwierzytelniania Bearer.
Wybrany firewall może odtworzyć uwierzytelnienie z sesji lub skorzystać z pasujących authenticatorów; publiczne żądanie może pozostać anonimowe. Tożsamość użytkownika reprezentuje token bezpieczeństwa Symfony, odrębny od ciągu Bearer przesyłanego przez HTTP. Uprawnienia mogą być sprawdzane przed kontrolerem przez access_control, przez atrybuty takie jak #[IsGranted] oraz tam, gdzie kod jawnie wywołuje autoryzację.
Gdy wiesz już, który firewall obsługuje żądanie, ustal, czego oczekuje od klienta. Jeden z możliwych podziałów wygląda tak:
/admin cookie sesyjne stateful
/api Authorization: Bearer <token> statelessW tym modelu stateless: true oznacza, że firewall API nie odtwarza uwierzytelnienia z sesji panelu. To wyjaśnia 401, gdy przeglądarka wysyła jedynie cookie sesyjne. API panelu może też korzystać z sesji — prefiks /api nie narzuca żadnego z tych rozwiązań.
Osobne firewalle stanowe mogą współdzielić context, jeśli obsługa sesji i dostawcy użytkowników są zgodni. Wszystkie uczestniczące firewalle muszą mieć stateless: false. Wspólny kontekst sesji nie uwierzytelnia żądań obsługiwanych przez niezależny firewall bezstanowy.
Sprawdź, który authenticator podejmuje obsługę
Metoda supports() określa, czy dany authenticator powinien obsłużyć żądanie. Warunek str_starts_with($request->getPathInfo(), '/api') obejmuje także /apiary i nie sprawdza nawet obecności poświadczenia.
Poniższa metoda należy do przykładowego authenticatora obsługującego nagłówki Authorization przesłane pod /api. Jego logika uwierzytelniania akceptuje Bearer, a inne schematy i niepoprawne tokeny odrzuca. Request oznacza Symfony\Component\HttpFoundation\Request; reszta klasy została pominięta:
public function supports(Request $request): bool
{
$path = $request->getPathInfo();
$isApiPath = $path === '/api' || str_starts_with($path, '/api/');
return $isApiPath && $request->headers->has('Authorization');
}false pomija ten authenticator; nadal obowiązują reguły dostępu do trasy. true wybiera go do uwierzytelniania, ale nie potwierdza ważności tokenu. Jeśli inny authenticator obsługuje odmienny schemat Authorization, warunki wyboru muszą je rozróżniać.
Publiczna trasa również może opcjonalnie rozpoznawać użytkownika. PUBLIC_ACCESS nie blokuje uruchomienia authenticatora, więc niepoprawne poświadczenie może spowodować błąd uwierzytelniania także na takiej trasie. Przy kilku authenticatorach sprawdź nakładające się warunki supports() i sposób obsługi błędów.
Gdy chronione żądanie wymaga uwierzytelnienia, skonfigurowany punkt wejścia wskazuje klientowi, jak je rozpocząć. Dla HTML może to być przekierowanie do logowania, a dla API odpowiedź 401. Treść JSON wynika z kontraktu aplikacji. Symfony 7.4 potrafi automatycznie wybrać pojedynczy dostępny punkt wejścia; kilku kandydatów wymaga jawnego entry_point. Jeśli authenticator odrzuca poświadczenie, odpowiedź może też pochodzić bezpośrednio z obsługi jego błędu.
Gdy zamiast JSON przychodzi HTML
Przeglądarkowy fetch domyślnie podąża za przekierowaniami. Poniżej pokazano dwie odrębne odpowiedzi z przykładowej wymiany:
HTTP/1.1 302 Found
Location: /login
HTTP/1.1 200 OK
Content-Type: text/htmlWywołanie response.json() próbuje wtedy odczytać stronę logowania jako JSON. Błąd parsowania może przesłonić problem z uwierzytelnianiem, choć końcowy status wynosi 200. Ten krótki fragment uruchamiany w przeglądarce sprawdza odpowiedź przed jej parsowaniem:
async function loadAdminPages() {
const response = await fetch('/api/admin/pages', {
headers: { Accept: 'application/json' },
});
if (response.redirected) {
throw new Error(
'Nieoczekiwane przekierowanie; sprawdź uwierzytelnianie.',
);
}
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
const mediaType = response.headers.get('content-type')
?.split(';')[0].trim().toLowerCase();
if (mediaType !== 'application/json') {
throw new Error(
'Oczekiwano odpowiedzi application/json.',
);
}
return response.json();
}Accept określa oczekiwany format, ale nie konfiguruje punktu wejścia Symfony. Fragment oczekuje application/json; dla innego typu odpowiedzi JSON trzeba dostosować warunek. Domyślnie wysyła dozwolone cookie do tego samego originu, lecz nie dostarcza tokenu Bearer. API z początku artykułu nadal odrzuci takie wywołanie.
response.redirected wykrywa przekierowanie już po jego wykonaniu. Aby zabronić podążania za przekierowaniami, ustaw redirect: 'error' i obsłuż odrzucenie obietnicy.
Tożsamość i uprawnienia to dwie różne kwestie
Uwierzytelnianie ustala tożsamość wywołującego. Autoryzacja rozstrzyga, czy wolno mu wykonać operację. Poprawna sesja lub token nie oznaczają prawa do usunięcia strony.
Podobnie jak przy firewallach, w access_control działa pierwsza pasująca reguła. Dla API dostępnego wyłącznie administratorom poniższa kolejność jest celowo błędna:
security:
access_control:
- { path: ^/api, roles: PUBLIC_ACCESS }
- { path: ^/api/admin, roles: ROLE_ADMIN }Dla /api/admin/pages zostanie wybrana tylko reguła publiczna. Zamierzony wymóg ROLE_ADMIN przestaje więc obowiązywać na poziomie access_control. Atrybut kontrolera lub jawne sprawdzenie uprawnień mogą nadal chronić operację, ale konfiguracja nie zapewnia ograniczenia z drugiego wpisu. Umieść szczegółową regułę przed ogólną, a dostęp anonimowy dopuść tylko do zasobów, które mają być publiczne.
Osobno trzeba sprawdzić role. Poniższa konfiguracja sprawia, że administrator dziedziczy również uprawnienia edytora:
security:
role_hierarchy:
ROLE_ADMIN:
- ROLE_EDITORNie przyznaje edytorom praw administratora. Hierarchię należy uwzględniać przez mechanizmy sprawdzania uprawnień Symfony, a nie traktować surowego wyniku getRoles() jako pełnego zestawu efektywnych uprawnień.
Sama rola może jednak nie wystarczyć. Przykładowa polityka usuwania strony może wymagać, by użytkownik był jej właścicielem, należał do przypisanego zespołu, a strona była szkicem w bieżącej organizacji (tenant). Wywołanie $this->denyAccessUnlessGranted('PAGE_DELETE', $page) w kontrolerze zleca taką decyzję dla konkretnego obiektu. Voter uczestniczy w niej, gdy obsługuje wskazany atrybut i obiekt; na wynik wpływają również strategia decyzji i pozostałe votery. Ukrycie przycisku usuwania w React nie egzekwuje tej polityki.
Status odpowiedzi odczytuj razem ze skonfigurowanym kontraktem API:
401 Unauthorizedstandardowo oznacza brak prawidłowych danych uwierzytelniających dla zasobu. HTTP wymaga wskazania mechanizmu wWWW-Authenticate; przykład otwierający artykuł używa Bearer.403 Forbiddenoznacza odmowę obsługi. Nie dowodzi zalogowania użytkownika: odmowę może otrzymać także żądanie anonimowe, niepoprawne pod względem CSRF lub naruszające inną politykę.- Przekierowanie do logowania może być poprawne dla klienta HTML. Trzeba obejrzeć pierwotną odpowiedź i kolejne przekierowania, nie tylko końcową stronę lub błąd parsowania JSON.
Przeglądarka i serwer Next.js wysyłają osobne żądania
Jeśli nawigacja działa, a odświeżenie strony kończy się błędem, porównaj miejsce wykonania obu wywołań API. Komponent serwerowy lub procedura obsługi trasy wysyła własne żądanie do Symfony. Nawigacja w App Routerze także może uruchamiać pracę serwera, więc sam sposób otwarcia strony nie wystarcza do ustalenia kontekstu fetch.
Przeglądarka -> Symfony
Cookie: zgodnie z ich zakresem i trybem credentials.
Bearer: token jawnie dodany w Authorization.
Przeglądarka -> Next.js -> Symfony
Symfony otrzymuje tylko poświadczenia dodane
przez serwer do jego własnego żądania.Serwerowy fetch nie dziedziczy cookie ani nagłówka Authorization z żądania przeglądarki; credentials: 'include' również ich nie przekazuje. Odczytaj kontekst przychodzącego żądania przez API używanej wersji Next.js i wyślij tylko przewidziane poświadczenie do zaufanego adresu Symfony. Kopiowanie wszystkich nagłówków, adres docelowy sterowany przez klienta lub współdzielenie odpowiedzi użytkownika w pamięci podręcznej tworzyłyby osobne problemy bezpieczeństwa.
Zakres cookie
Przy żądaniu z przeglądarki sprawdź cookie faktycznie wysłane do API. Origin to schemat, host i port; zakres cookie podlega innym regułom i nie jest izolowany przez port:
Path=/adminwyklucza wysłanie do/api. Ścieżka steruje wysyłaniem cookie, ale nie stanowi granicy autoryzacji.- Bez
Domaincookie jest przypisane tylko do hosta, który je ustawił. Dozwolona domena nadrzędna rozszerza zakres na subdomeny, a wraz z nim zakres ekspozycji. Secureogranicza wysyłanie do bezpiecznych połączeń, z wyjątkami przeglądarek dla lokalnego środowiska.HttpOnlyblokuje odczyt w JavaScript, lecz nie dołączanie cookie przez przeglądarkę dofetch.SameSitedotyczy witryn, nie wyłącznie originów. Dwie subdomeny HTTPS mogą należeć do tej samej witryny, mimo różnych originów. Cookie używane między witrynami zSameSite=NonewymagaSecure, a polityka prywatności przeglądarki nadal może je zablokować.
Jawne SameSite=Lax dopuszcza niektóre przejścia między witrynami w głównym oknie przy użyciu bezpiecznej metody HTTP, np. GET po kliknięciu odnośnika. Wyjątek ten nie obejmuje fetch. Strict wyklucza wysyłanie między witrynami. credentials: 'include' nie omija tych ustawień ani ograniczeń hosta i ścieżki. Ogranicz zakres cookie do potrzeb sesji i używaj HTTPS w produkcji.
Żądania między originami i ochrona CSRF
Załóżmy, że przeglądarka wywołuje https://api.example.com ze strony https://frontend.example.com. Originy są różne. Uwierzytelnianie przez cookie wymaga wtedy credentials: 'include', cookie dopuszczonego do wysłania oraz odpowiedzi CORS zezwalającej na dostęp z originu frontendu. Poniższe nagłówki odpowiedzi ilustrują taki układ:
Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Credentials: true
Vary: OriginPrzed zwróceniem originu sprawdź go na liście dozwolonych wartości; * nie dopuszcza przeglądarkowego dostępu z poświadczeniami. Jeśli potrzebne jest żądanie wstępne, odpowiedź musi też zezwalać na właściwą metodę i nagłówki, w tym jawnie na używany Authorization. Wstępny OPTIONS nie zawiera cookie sesyjnego ani tokenu Bearer właściwego żądania. Odpowiedź 401 na tym etapie może więc zatrzymać wywołanie API przed jego wysłaniem.
CORS reguluje dostęp przeglądarki do odpowiedzi z innych originów oraz wysyłanie żądań wymagających kontroli wstępnej. Część żądań dociera do serwera nawet wtedy, gdy JavaScript nie może odczytać odpowiedzi. Właściwa operacja nadal wymaga więc uwierzytelniania i autoryzacji — CORS nie zapewnia żadnego z nich i nie ogranicza w ten sam sposób komunikacji serwer–serwer.
Przy operacjach zmieniających stan ochrona CSRF zależy od sposobu przesyłania poświadczeń. Automatycznie wysyłane cookie wymagają odpowiedniego zabezpieczenia, np. weryfikacji tokenu CSRF lub starannie zaprojektowanej kontroli originu. Jawnie dodawany nagłówek Authorization: Bearer zmienia ryzyko klasycznego CSRF. Token przechowywany i wysyłany w cookie albo zapasowa metoda przyjmująca automatyczne poświadczenia nadal wymagają jednak oceny. Samo HttpOnly nie zapobiega CSRF. Globalne wyłączenie ochrony lub otwarcie CORS na dowolne originy nie naprawia tego błędu uwierzytelniania.
Określ, co unieważnia wylogowanie
Zakończenie sesji panelu nie musi unieważniać osobno wydanego tokenu API. Usunięcie go z localStorage usuwa kopię w tej przeglądarce; inna kopia może działać do wygaśnięcia lub unieważnienia po stronie serwera, jeśli mechanizm je obsługuje.
Ustal, czy wylogowanie kończy tę sesję, unieważnia tokeny API, uniemożliwia odnowienie przez token odświeżający, czy obejmuje wszystkie te działania. Test powinien sprawdzać reakcję serwera na stare poświadczenia, także po ich wygaśnięciu.
Testuj odmowę i dozwoloną operację
Dla przykładowego API Bearer przyjmijmy następujący kontrakt: anonimowy odczyt zwraca 401, usuwanie przez uwierzytelnionego edytora — 403, a przez uprawnionego administratora — 204.
Metody abstrakcyjne poniżej wymagają implementacji w konkretnej klasie testowej. Nie są istniejącymi helperami projektu ani Symfony. Przygotuj izolowanych użytkowników testowych i tokeny akceptowane przez rzeczywisty authenticator. Każdy test usuwania potrzebuje istniejącej strony, którą poza sprawdzanym ograniczeniem wolno usunąć: edytor nie ma uprawnienia, natomiast administrator spełnia warunki własności, organizacji i stanu obiektu.
pageExists() musi ponownie odczytać zapisane dane. Asercje zakładają natychmiastowe usunięcie; dostosuj je do usuwania logicznego lub zadania w kolejce, jeśli tak działa aplikacja. Używaj wyłącznie danych i poświadczeń testowych.
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
abstract class AdminApiSecurityTestCase extends WebTestCase
{
abstract protected function editorBearerToken(): string;
abstract protected function adminBearerToken(): string;
abstract protected function deletablePageId(): int;
abstract protected function pageExists(int $pageId): bool;
public function testAnonymousUserCannotAccessAdminApi(): void
{
$client = static::createClient();
$client->request('GET', '/api/admin/pages', server: [
'HTTP_ACCEPT' => 'application/json',
]);
self::assertResponseStatusCodeSame(401);
self::assertResponseHeaderSame(
'WWW-Authenticate',
'Bearer realm="admin-api"',
);
}
public function testEditorCannotDeletePage(): void
{
$client = static::createClient();
$token = $this->editorBearerToken();
$pageId = $this->deletablePageId();
$client->request(
'DELETE',
'/api/admin/pages/' . $pageId,
server: [
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
],
);
self::assertResponseStatusCodeSame(403);
self::assertTrue($this->pageExists($pageId));
}
public function testAdminCanDeletePage(): void
{
$client = static::createClient();
$token = $this->adminBearerToken();
$pageId = $this->deletablePageId();
$client->request(
'DELETE',
'/api/admin/pages/' . $pageId,
server: [
'HTTP_ACCEPT' => 'application/json',
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
],
);
self::assertResponseStatusCodeSame(204);
self::assertFalse($this->pageExists($pageId));
}
}Test edytora sprawdza odmowę i zachowanie strony, a test administratora — sukces oraz faktyczne usunięcie. Razem wykrywają sytuację, w której system odmawia wszystkim. Sam status 403 nie wskazałby, czy zadziałała zamierzona reguła uprawnień.
Przy API sesyjnym użyj użytkownika testowego i loginUser($user, $firewallContext), podając rzeczywisty współdzielony kontekst, jeśli został skonfigurowany. Helper omija logowanie i nie działa z firewallem bezstanowym, dlatego sam proces logowania wymaga osobnego testu. Gdzie potrzebna jest ochrona CSRF, dostarcz prawidłowe dane CSRF zamiast ją wyłączać.
W miarę potrzeb dodaj przypadki nieważnych i wygasłych tokenów, wylogowania oraz dostępu do obcej organizacji. Cookie, CORS, CSRF i przekierowania sprawdź także w testach przeglądarkowych: klient testowy Symfony nie egzekwuje wszystkich reguł przeglądarki.
Prześledź jedno żądanie w ustalonej kolejności
Sprawdź jedno nieudane żądanie w poniższej kolejności, oddzielając fakty z konfiguracji od założeń:
- Zapisz metodę, dokładny URL, status,
Content-Typei przekierowania. Ustal, czy odpowiedź pochodzi z Symfony, czy z pośredniczącego serwera proxy. - Sprawdź pasujący firewall, kontekst sesji i
stateless. Liczy się efektywna konfiguracja wdrożonego środowiska, nie oderwany fragment YAML. - Sprawdź właściwe authenticatory, punkt wejścia i obsługę błędów. Ustal, czy oczekiwane poświadczenie dotarło; w kontrolowanej diagnostyce sprawdź jego ważność i ładowanie użytkownika.
- Ustal rozpoznanego użytkownika, jeśli istnieje, pierwszą regułę
access_controloraz dodatkowe kontrole w kontrolerze, voterach lub jawnych wywołaniach autoryzacji. - Porównaj żądanie z przeglądarki i serwera Next.js: adres docelowy, zakres cookie i świadomie przekazywane dane uwierzytelniające.
Informacje Security w Symfony Profilerze pomagają w chronionym środowisku programistycznym; nie należy udostępniać profilera publicznie. W produkcji wystarczą ograniczone metadane: identyfikator korelacji żądania, firewall, wynik i obecność poświadczenia. Nie zapisuj haseł, wartości tokenów, pełnych nagłówków cookie ani sekretów sesji. Zanonimizuj również eksporty przechwyconych żądań.
Dokumentacja odniesienia
Szczegóły zależne od wersji należy porównać z wdrożonymi zależnościami:
- Symfony 7.4: firewalle, uwierzytelnianie i hierarchia ról — https://symfony.com/doc/7.4/security.html
- Kontekst firewalla i konfiguracja stateless — https://symfony.com/doc/7.4/reference/configuration/security.html
- Dopasowanie reguł dostępu — https://symfony.com/doc/7.4/security/access_control.html
- Własne authenticatory i punkty wejścia — https://symfony.com/doc/7.4/security/custom_authenticator.html oraz https://symfony.com/doc/7.4/security/entry_point.html
- Uwierzytelnianie w testach funkcjonalnych — https://symfony.com/doc/7.4/testing.html#logging-in-users-authentication
- Cookie w przeglądarce i CORS — https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie oraz https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- Rozpoznawanie i blokowanie przekierowań fetch — https://developer.mozilla.org/en-US/docs/Web/API/Response/redirected
- Nagłówki żądania w Next.js — https://nextjs.org/docs/app/api-reference/functions/headers
- Ochrona przed CSRF — https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- Znaczenie statusów HTTP — https://www.rfc-editor.org/rfc/rfc9110.html#name-status-codes
Zanim zmienisz logowanie, uprawnienia lub frontend, prześledź nieudane żądanie HTTP w kontekście bezpieczeństwa, który rzeczywiście je obsługuje.
