W świecie nowoczesnych aplikacji internetowych, zarządzanie dostępem do zasobów API jest jednym z kluczowych elementów zapewniających zarówno bezpieczeństwo, jak i funkcjonalność systemu. W szczególności, jednym z istotniejszych aspektów jest obsługa CORS (Cross-Origin Resource Sharing), która decyduje o tym, czy dany zasób API może być wykorzystywany przez aplikacje znajdujące się na innych domenach. Niewłaściwe skonfigurowanie tych zasad może prowadzić do poważnych problemów z bezpieczeństwem. Celem tej części jest dokładne wyjaśnienie, jak poprawnie zarządzać CORS w ASP.NET Core 8, wskazując na najczęstsze błędy oraz najlepsze praktyki.

Kiedy aplikacja jest uruchamiana, wszystkie dane żądane przez klienta muszą pochodzić z tego samego źródła, czyli z tego samego serwera lub przynajmniej tej samej domeny HTTP (lub subdomeny). To zabezpieczenie nazywane jest polityką Same-Origin Policy (SOP) i dotyczy szczególnie skryptów takich jak JavaScript, które inicjują żądania HTTP w celu pobrania danych. Aby sprawdzić, czy Twoje API jest uprawnione do obsługi klienta z innej domeny, przeglądarka wykonuje tzw. preflight – wstępne zapytanie wysyłane do serwera. Dzięki temu serwer informuje przeglądarkę, czy pozwala na takie żądania.

Preflight jest wykonywane za pomocą metody HTTP OPTIONS i pozwala serwerowi na odesłanie odpowiednich nagłówków, które wskazują, które domeny i metody są dozwolone. Nagłówki te zaczynają się od „Access-Control-” i obejmują takie jak:

  • Access-Control-Allow-Origin – informuje klienta, które domeny są dozwolone do wykonania zapytania.

  • Access-Control-Allow-Credentials – wskazuje, czy zapytania wymagające uwierzytelniania (np. z nagłówkami autoryzacyjnymi) są dozwolone.

  • Access-Control-Allow-Headers – określa, które nagłówki są akceptowane w żądaniu.

  • Access-Control-Allow-Methods – wskazuje, które metody HTTP są dozwolone w zapytaniach.

  • Access-Control-Expose-Headers – wskazuje, które nagłówki będą dostępne w odpowiedzi na zapytanie.

  • Access-Control-Max-Age – określa czas przechowywania odpowiedzi na zapytanie w pamięci podręcznej.

  • Access-Control-Request-Headers – nagłówek wysyłany przez klienta w zapytaniu preflight, informujący serwer o nagłówkach, które zostaną wysłane w głównym zapytaniu.

  • Access-Control-Request-Method – nagłówek wysyłany przez klienta w zapytaniu preflight, informujący serwer, jaką metodę HTTP zostanie użyta w zapytaniu.

W przypadku korzystania z ASP.NET Core 8, możemy skonfigurować CORS w sposób bardzo elastyczny, w zależności od naszych potrzeb. Przykładem jest konfiguracja "AllowAll", która zezwala na wszystkie domeny, metody i nagłówki, co w prostych przypadkach jest wystarczające. Można to osiągnąć, definiując politykę w następujący sposób:

csharp
builder.Services.AddCors(options => { options.AddPolicy("AllowAll", builder => { builder.AllowAnyHeader() .AllowAnyMethod() .AllowAnyOrigin(); }); });

Ta konfiguracja jest odpowiednia głównie w fazie rozwoju aplikacji, ponieważ pozwala na łatwe testowanie i integrację. Jednak w środowisku produkcyjnym wymaga bardziej restrykcyjnego podejścia, aby zapewnić bezpieczeństwo. Oto, jak powinna wyglądać bardziej ograniczona konfiguracja CORS, np. w sytuacji, gdy chcesz zezwolić na dostęp tylko z dwóch określonych domen:

csharp
options.AddPolicy("Restricted", builder => { builder.AllowAnyHeader() .WithMethods("GET", "POST", "PUT", "DELETE") .WithOrigins("https://mydomain.com", "https://myotherdomain.com") .AllowCredentials(); });

W tym przypadku tylko określone domeny mogą korzystać z API, a dostęp do zasobów jest ograniczony do określonych metod HTTP (GET, POST, PUT, DELETE).

Ważnym punktem, o którym należy pamiętać, jest fakt, że nie zawsze możliwe jest jednoczesne zezwolenie na wszelkie domeny i jednocześnie wymuszenie obsługi poświadczeń (credentials). W przypadku próby połączenia tych dwóch opcji, np. gdy zezwalamy na dowolne domeny przy włączonym AllowCredentials(), napotkamy na błąd, jak pokazuje poniższy przykład:

csharp
builder.Services.AddCors(options => { options.AddPolicy("AllowAll", builder => { builder.AllowAnyHeader() .AllowAnyMethod() .AllowAnyOrigin() .AllowCredentials(); // Błąd - nie można ustawić AllowAnyOrigin z AllowCredentials }); });

Jeśli wprowadzimy tę konfigurację, otrzymamy błąd związany z tym, że przeglądarka nie pozwala na używanie poświadczeń z dowolnej domeny z poziomu CORS, ponieważ jest to niebezpieczne z punktu widzenia prywatności.

Warto zaznaczyć, że CORS to tylko jedna z warstw bezpieczeństwa, które należy wdrożyć w API. Oprócz właściwego zarządzania dostępem z różnych domen, należy również zadbać o inne aspekty, takie jak walidacja danych wejściowych, odpowiednia autoryzacja oraz kontrola dostępu do wrażliwych danych.

Ponadto, warto również dodać, że CORS nie jest jedynym mechanizmem służącym do zabezpieczania API. Wraz z nim należy wdrożyć odpowiednią kontrolę dostępu (np. za pomocą OAuth2), szyfrowanie połączeń (TLS), a także monitorowanie i logowanie wszelkich nieautoryzowanych prób dostępu.

Jak poprawić strukturę REST API z ASP.NET Core 8?

W poprzednim rozdziale omówiłem podstawy implementacji API i zaprezentowałem Ci, jak zacząć budować czyste, proste aplikacje REST w ASP.NET Core. W tej części pójdziemy krok dalej, koncentrując się na tym, jak uczynić Twoje API bardziej strukturalnym, eleganckim oraz łatwiejszym do rozwoju. Dzięki zastosowaniu kilku dodatkowych technik zyskasz nie tylko lepsze doświadczenie użytkownika, ale także zwiększysz bezpieczeństwo oraz poprawisz testowalność aplikacji. W tym rozdziale nauczysz się m.in. jak:

  • Zastosować minimalną implementację punktów końcowych

  • Implementować niestandardowe bindowanie parametrów

  • Wykorzystać middlewares

  • Użyć filtrów akcji

  • Zastosować ograniczenia liczby zapytań (Rate Limiting)

  • Zarządzać błędami globalnie

Minimalna implementacja punktu końcowego

Pierwszym krokiem do poprawy struktury Twojego API jest unikanie nadmiernej złożoności, zachowując jednocześnie przejrzystość kodu. W poprzednich rozdziałach wykorzystywaliśmy funkcje lambda do bezpośredniego mapowania zapytań HTTP, takich jak MapGet, MapPost i innych. Choć ta technika jest szybka i prosta, w większych aplikacjach szybko staje się mało czytelna. Dlatego warto wydzielić kod do dedykowanych metod w statycznych klasach, co poprawia organizację kodu i pozwala na łatwiejsze zarządzanie.

Załóżmy, że mamy punkt końcowy POST /countries, który dodaje nowy kraj do bazy danych. W przykładzie poniżej kod do tego punktu końcowego jest implementowany bezpośrednio w funkcji lambda:

csharp
app.MapPost("/countries", ([FromBody] Country country, IValidator validator, ICountryMapper mapper, ICountryService countryService) => { var validationResult = validator.Validate(country); if (validationResult.IsValid) { var countryDto = mapper.Map(country); return Results.CreatedAtRoute("countryById", new { Id = countryService.CreateOrUpdate(countryDto) }); } return Results.ValidationProblem(validationResult.ToDictionary()); });

Choć kod jest funkcjonalny, jest mało czytelny, co w przyszłości może utrudnić dalszą rozbudowę. Dlatego warto go przenieść do statycznej klasy, która będzie przechowywać logikę związaną z każdym punktem końcowym. Oto jak może wyglądać kod po refaktoryzacji:

csharp
public static class CountryEndpoints { public static IResult PostCountry([FromBody] Country country, IValidator validator, ICountryMapper mapper, ICountryService countryService) { var validationResult = validator.Validate(country); if (validationResult.IsValid) { var countryDto = mapper.Map(country); return Results.CreatedAtRoute("countryById", new { Id = countryService.CreateOrUpdate(countryDto) }); } return Results.ValidationProblem(validationResult.ToDictionary()); } }

Dzięki tej zmianie kod staje się bardziej czytelny i łatwiejszy do testowania. Ponadto, dzięki rozdzieleniu logiki związanej z mapowaniem i walidacją od samego procesu rejestracji punktu końcowego, nasz API staje się bardziej elastyczne i łatwiejsze w utrzymaniu. Warto również zauważyć, że zredukowaliśmy złożoność konfiguracji w pliku Program.cs, co sprawia, że jest on bardziej przejrzysty i łatwiejszy do zrozumienia.

Niestandardowe bindowanie parametrów

Często w pracy nad API natrafimy na nietypowe przypadki związane z danymi, które przychodzą od klientów. Twoi klienci mogą korzystać z przestarzałych systemów, które przesyłają dane w formacie, który nie odpowiada standardowym oczekiwaniom frameworka. Przykładem może być przypadek, w którym dane wysyłane w zapytaniach HTTP nie są zgodne z oczekiwanym formatem, co może prowadzić do błędów w procesie ich przetwarzania.

W takich sytuacjach warto zainwestować w niestandardowe bindowanie parametrów. Dzięki tej metodzie możemy dostosować sposób, w jaki dane przychodzące do naszego API są mapowane na obiekty w aplikacji. Implementacja niestandardowego bindowania pozwala na transformację danych, które nie pasują do standardowego formatu, do formy użytecznej w naszej logice biznesowej. To może obejmować np. parsowanie dat w nietypowym formacie lub dostosowanie wartości parametrów do specyficznych wymagań systemu klienta.

Dzięki niestandardowemu bindowaniu API może zostać przygotowane do obsługi bardziej zróżnicowanych i specyficznych przypadków, co nie tylko zwiększa elastyczność aplikacji, ale również zmniejsza liczbę błędów wynikających z niedopasowania formatu danych.

Rate Limiting i zarządzanie błędami

Ograniczenie liczby zapytań (Rate Limiting) oraz globalne zarządzanie błędami to kolejne elementy, które powinny stać się integralną częścią każdego nowoczesnego API. Ograniczając liczbę zapytań, które użytkownicy mogą wysłać w danym czasie, zwiększamy bezpieczeństwo naszej aplikacji, minimalizując ryzyko przeciążenia serwera lub ataków typu DDoS. Ograniczenie liczby zapytań można łatwo zaimplementować za pomocą middleware, co pozwala na elastyczne zarządzanie tymi parametrami w całym API.

Podobnie, zarządzanie błędami jest kluczowe dla zapewnienia stabilności i niezawodności API. Zamiast traktować błędy jako incydenty, które trzeba naprawić ręcznie w każdym punkcie końcowym, lepiej jest wprowadzić globalny mechanizm obsługi błędów. Dzięki niemu wszelkie wyjątki, które wystąpią w trakcie przetwarzania zapytań, mogą zostać automatycznie przechwycone i odpowiednio obsłużone, zapewniając użytkownikowi odpowiedź, która jest nie tylko informacyjna, ale również pozwala na łatwiejsze śledzenie i diagnozowanie problemów.

Podsumowanie

Aby Twoje API było naprawdę wydajne i gotowe na długoterminowy rozwój, warto zwrócić uwagę na kilka kluczowych aspektów: strukturę kodu, implementację niestandardowego bindowania parametrów, zarządzanie ograniczeniami zapytań i obsługę błędów. Wraz z dalszym rozwojem aplikacji, stosowanie dobrych praktyk programistycznych pozwoli Ci utrzymać wysoką jakość kodu i zapewnić, że Twoje API będzie w stanie sprostać wymaganiom rosnących potrzeb użytkowników i klientów.