W tym artykule pokazuję, jak przesyłać pliki między aplikacjami webowymi. Myślę, że wiedza tutaj zawarta jest kompletna i dużo obszerniejsza niż w innych artykułach. Zobaczysz tutaj kompletne mechanizmy do przesyłania plików pożądane w każdej aplikacji.
UWAGA! Ze względu na prostotę i czytelność, kody przedstawione w tym artykule nie są zbyt czyste. Miej to na uwadze.
Na GitHubie znajdują się przykładowe projekty. W pliku readme.md jest instrukcja, która pokazuje jak je poprawnie uruchamiać.
Jeśli szukasz informacji jak przesłać formularzem plik z danymi w JSON, przeczytaj ten artykuł.
Po stronie HTML posługujemy się zwykłym inputem o typie file. Atrybut accept pozwala ograniczyć typy plików tylko do tych, które chcemy zobaczyć w okienku do wyboru plików.
Ale ważna uwaga – pamiętaj że musisz ustawić atrybut enctype dla formularza na multipart/form-data. W innym przypadku plik po prostu nie przejdzie.
Teraz pytanie, jak odebrać plik po stronie serwera? Służy do tego specjalny interfejs IFormFile. Ten interfejs znajduje się w Nugecie: Microsoft.AspNetCore.Http, więc zainstaluj go najpierw.
Model
Napiszmy teraz klasę, która będzie modelem dla tego formularza i prześle plik wraz z jakimiś innymi danymi:
public class FormData
{
[Required(ErrorMessage = "Musisz wybrać plik")]
public IFormFile? FileToUpload { get; set; }
[Required(ErrorMessage = "Musisz wpisać wiadomość")]
public string? Message { get; set; }
}
Zwróć uwagę, że oba pola są oznaczone jako Required – będą wymagane. I tu mała dygresja.
Wszystkie pola, które nie są oznaczone jako nullable – znakiem zapytania (a nullable używane jest w projekcie) domyślnie są uznane za wymagane. Z tego wynika, że powyższy kod mógłbym zapisać tak:
public class FormData
{
public IFormFile FileToUpload { get; set; }
public string Message { get; set; }
}
Zauważ, że w drugim przykładzie przy typach pól nie ma znaku zapytania, który oznacza pole jako nullable. W tym momencie te pola są domyślnie wymagane. I jeśli któreś z nich nie będzie wypełnione, formularz nie przejdzie walidacji.
Przesyłanie
Jeśli nie wiesz co robią atrybuty Required i jak walidować formularz, koniecznie przeczytaj artykuł Walidacja danych w MVC.
To teraz dodamy ten model do strony i zaktualizujemy formularz:
index.cshtml.cs:
public class IndexModel : PageModel
{
[BindProperty]
public FormData Data { get; set; }
public async Task OnPostAsync()
{
}
}
Jeśli nie wiesz, czym jest atrybut asp-validation-for lub _ValidationScriptsPartial, koniecznie przeczytaj artykuł o walidacji. Krótko mówiąc – jeśli jakiś warunek walidacyjny nie zostanie spełniony (np. [Required]), to w tym spanie pojawi się komunikat błędu.
Pamiętaj też, że _ValidationScriptsPartial musisz umieścić na końcu strony. W przeciwnym razie mechanizm walidacji po stronie klienta może nie zadziałać poprawnie.
Odbieranie danych
Teraz już wystarczy tylko odebrać te dane. Robimy to w metodzie OnPostAsync.
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
return Page();
EnsureFilesDirExists();
var secureFileName = Path.GetFileName(Data.FileToUpload.FileName);
var filePath = Path.Combine("files", secureFileName);
using var stream = new FileStream(filePath, FileMode.CreateNew);
await Data.FileToUpload.CopyToAsync(stream);
return Page();
}
private void EnsureFilesDirExists()
{
if (!Directory.Exists("files"))
Directory.CreateDirectory("files");
}
Najpierw sprawdzam, czy stan modelu jest prawidłowy – po stronie serwera też musisz o to zadbać.
Następnie tworzę obiekt klasy FileStream, do którego wkopiowuję otrzymany plik. Plik za sprawą tego strumienia ląduje na dysku.
Jeśli przesyłane jest kilka plików, zamiast pojedynczej właściwości IFormFile użyj jakiejś formy kolekcji w swoim modelu, np. IEnumerable<IFormFile>, czy też List<IFormFile>.
Nazwa właściwości w modelu musi być taka sama jak wartość atrybutu name w elemencie input. Oczywiście, jeśli posługujesz się tylko RazorPages i domyślnym bindingiem, to masz to zagwarantowane.
Niebezpieczeństwa
Zapchanie pamięci i atak DoS
UWAGA! Nie używaj MemoryStream do łapania plików po stronie serwera. Pamiętaj, że wszystko co jest w MemoryStream znajduje się w pamięci. Jeśli na Twój serwer przesyłane są pliki, wtedy użycie MemoryStream może doprowadzić do wyczerpania pamięci i BUM! Wszystko wybuchnie. Stworzysz też podatność na atak Denial Of Service.
Dlatego też zawsze w takiej sytuacji używaj FileStream lub lepiej – jeśli masz taką możliwość – przesyłaj plik bezpośrednio na blob storage – o tym za chwilę.
Niebezpieczna nazwa pliku i przejęcie kontroli nad komputerem
Drugim zagrożeniem jest nazwa pliku. Zdziwiony? Nazwa pliku i plik mogą zostać tak spreparowane, żeby dobrać się do danych znajdujących się na serwerze. A nawet przejąć nad nim całkowitą kontrolę. Jeśli posłużysz się gołą nazwą pliku, która przychodzi w IFormFile.FileName stworzysz sobie podatność na atak Path Traversal. Piszę o tym więcej w swojej książce Zabezpieczanie aplikacji internetowych w .NET – podstawy. Jest tam wyjaśniony ten atak wraz z przykładem. A także kilka innych. Polecam zerknąć 🙂
Aby uniknąć podatności, zastosuj po prostu nazwę pliku, którą zwróci Ci Path.GetFileName().
Dobre praktyki
Poza tym, co opisałem wyżej, warto jeszcze sprawdzić rozmiar przesyłanego pliku. Ten rozmiar masz we właściwości IFormFile.Length. Daj jakieś maksimum na wielkość pliku. Jeśli przesłany plik jest za duży, to po prostu zwróć błąd.
Dobrze jest też uruchomić jakiś skaner antywirusowy na przesyłanym pliku, jeśli masz taką możliwość.
No i pamiętaj, żeby zawsze sprawdzać po stronie serwera co do Ciebie przychodzi, bo walidacje po stronie klienta można łatwo obejść.
Jeśli przesyłany plik jest naprawdę duży, to zapisuj go w osobnym tasku. Zwróć odpowiedź do klienta w stylu, że plik jest procesowany i wkrótce się pojawi.
Ochrona przed ponownym przesłaniem
Może się zdarzyć tak, że w przeglądarce ktoś zdąży zupełnym przypadkiem wcisnąć przycisk do wysyłania pliku dwa razy. Można się przed tym uchronić w prosty sposób. Wystarczy zablokować guzik po przesłaniu. Wymaga to jednak nieco JavaScriptu. W standardowym mechanizmie walidacji można to osiągnąć w taki sposób:
Tutaj nadałem ID formularzowi i przyciskowi do wysyłania. Dodatkowo dodałem obsługę zdarzenia onsubmit dla formularza. W tym handlerze najpierw sprawdzam, czy formularz jest poprawnie zwalidowany. Wywołanie form.validate().checkForm() nie powoduje kolejnej walidacji, tylko sprawdza, czy formularz jest zwalidowany. Jeśli nie jest (bo np. nie podałeś wszystkich wymaganych danych), wtedy zwracam false – formularz wtedy nie zostaje przesłany.
W przeciwnym razie blokuję guzik do wysyłania i zwracam true. Nie ma opcji podwójnego wciśnięcia tego przycisku. Taki mechanizm powinieneś stosować w każdym formularzu. Możesz też schować taki formularz i wyświetlić komunikat, że formularz jest wysyłany. Po prostu pokaż zamiast niego jakiegoś diva z taką informacją.
Walidacja rozmiaru pliku
Tak jak pisałem wyżej – warto dać jakiś maksymalny rozmiar pliku. Można to dość łatwo zwalidować zarówno po stronie klienta jak i serwera – wystarczy użyć własnego atrybutu walidacyjnego 🙂 Przede wszystkim muszę Cię odesłać do tego artykułu, w którym opisałem jak tworzyć własne atrybuty walidacyjne.
Nie będę tutaj powtarzał tej wiedzy, więc warto żebyś go przeczytał, jeśli czegoś nie rozumiesz. Tutaj dam Ci gotowe rozwiązanie.
Atrybut może wyglądać tak:
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field,
AllowMultiple = false)]
public class MaxFileSizeAttribute : ValidationAttribute, IClientModelValidator
{
private readonly int _maxFileSize;
public MaxFileSizeAttribute(int maxFileSize)
{
_maxFileSize = maxFileSize;
}
public override bool IsValid(object value)
{
var file = value as IFormFile;
if (file == null)
return true;
return file.Length <= _maxFileSize;
}
public void AddValidation(ClientModelValidationContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
MergeAttribute(context.Attributes, "data-val", "true");
MergeAttribute(context.Attributes, "data-val-filesize", ErrorMessageString);
MergeAttribute(context.Attributes, "data-val-filesize-maxsize", _maxFileSize.ToString());
}
private static void MergeAttribute(IDictionary<string, string> attributes, string key, string value)
{
if (!attributes.ContainsKey(key))
{
attributes.Add(key, value);
}
}
}
Dodatkowo trzeba dodać walidację po stronie klienta. Ja robię to w pliku _ValidationScriptsPartial.cs. Cały plik może wyglądać tak:
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
<script type="text/javascript">
$.validator.addMethod("filesize", function (value, element, param) {
if (element.type != "file")
return false;
for(let i = 0; i < element.files.length; i++)
if(element.files[i].size > param)
return false;
return true;
});
$.validator.unobtrusive.adapters.addSingleVal("filesize", "maxsize");
</script>
Teraz wystarczy już tylko użyć tego w modelu:
public class FormData
{
[Required(ErrorMessage = "Musisz wybrać plik")]
[MaxFileSize(1024 * 1024, ErrorMessage = "Plik jest za duży, maksymalna wielkość pliku to 1 MB")]
public IFormFile? FileToUpload { get; set; }
[Required(ErrorMessage = "Musisz wpisać wiadomość")]
public string? Message { get; set; }
}
Ważne! Pamiętaj że żeby walidacja działała, model na stronie musi być opatrzony atrybutem BindProperty:
public class IndexModel : PageModel
{
[BindProperty]
public FormData Data { get; set; } = new();
public async Task<IActionResult> OnPostAsync()
{
}
}
Jeśli tego nie będzie, formularz może zadziałać poprawnie i przesłać dane, ale walidacja nie zostanie uruchomiona.
Przesyłanie pliku bezpośrednio na blob storage
BlobStorage jest to usługa Azure’owa do przechowywania plików. Czasem lepiej/łatwiej umieścić pewne pliki właśnie tam niż u siebie na serwerze. To nie jest artykuł o tym, więc nie piszę tutaj więcej w szczególności jak obsługować i tworzyć taki storage. Jeśli wiesz o co chodzi, to ten akapit może Ci się przydać. Jeśli nie – pewnego dnia na pewno to opiszę.
Generalnie wszystko sprowadza się do wywołania odpowiedniego przeciążenia metody UploadBlobAsync z BlobContainerClient:
W takiej sytuacji plik ląduje bezpośrednio na Twoim blob storage.
Przesyłanie pliku w Blazor
Tutaj sprawa wygląda nieco inaczej. Generalnie mamy gotowy komponent do pobrania pliku: InputFile. Co więcej, ten komponent nie musi być częścią formularza. To jednak nieco zmienia sposób podejścia do zadania. Zacznijmy od tego jak w ogóle działa przesyłanie plików w Blazor:
<InputFile OnChange="FileChangeHandler" accept=".jpg" />
@code{
private async Task FileChangeHandler(InputFileChangeEventArgs args)
{
using var stream = args.File.OpenReadStream();
}
}
Po pierwsze mamy komponent InputFile, którego kluczowym zdarzeniem jest OnChange. Trzeba tutaj podać handlera, który dostanie przekazany plik.
Parametr metody posiada pole File (typu IBrowserFile). Znajduje się tam pierwszy dostępny plik. Jeśli pozwoliłeś na dodanie kilku plików, wtedy możesz je odczytać z metody GetMultipleFiles, która zwraca Ci listę IBrowserFile. To wszystko znajduje się w klasie InputFileChangeEventArgs w przesłanym parametrze.
Interfejs IBrowserFile zawiera metodę OpenReadStream, która zwraca strumień z danymi pliku. I teraz uwaga nr 1. OpenReadStream przyjmuje w parametrze maksymalny rozmiar pliku, który może przekazać. Domyślnie ten parametr wskazuje 512 KB. Jeśli plik w strumieniu będzie większy, wtedy metoda wybuchnie wyjątkiem.
UWAGA! Blazor WASM pracuje tylko po stronie klienta – w jego przeglądarce. Nie ma tutaj żadnego serwera. Daje nam to pewne ograniczenie – nie możesz zapisać pobranego pliku – Blazor nie ma dostępu do systemu plików. Dlatego też w przypadku Blazor nie możesz posłużyć się FileStream, jak to było wyżej. Tutaj musisz po prostu przechować plik albo w tablicy bajtów, albo w MemoryStream.
Zobaczmy teraz w jaki sposób można zrobić pobieranie pliku z progress barem.
Przesyłanie pliku z progress barem
<InputFile OnChange="FileChangeHandler" accept=".jpg" />
@if(ReadingFile)
{
<div>
<progress value="@DataRead" max="@FileSize" />
</div>
}
@code{
private long FileSize { get; set; }
private long DataRead { get; set; }
private bool ReadingFile { get; set; } = false;
private async Task FileChangeHandler(InputFileChangeEventArgs args)
{
try
{
FileSize = args.File.Size;
DataRead = 0;
ReadingFile = true;
byte[] buffer = System.Buffers.ArrayPool<byte>.Shared.Rent(1024); //pobranie buforu min. 1024 bajty
using var stream = args.File.OpenReadStream(1024 * 1024); //ograniczenie wielkości pliku do 1 MB
using MemoryStream fileContent = new MemoryStream();
while(await stream.ReadAsync(buffer) is int read && read > 0)
{
DataRead += read;
fileContent.Write(buffer);
StateHasChanged();
}
}finally
{
ReadingFile = false;
}
}
}
Wszystko rozbija się o odczyt pliku w porcjach po 1024 bajty. Kolejne części pliku są zapisywane w strumieniu fileContent. Przy każdym zapisywanym fragmencie aktualizuję ilość odczytanych danych (DataRead), która jest aktualną wartością w progress barze. Aby progress bar się odświeżył, trzeba zawołać StateHasChanged.
Na koniec odczytu cały plik będzie w strumieniu fileContent.
Pokazanie pobranego obrazka
W związku z tym, że Blazor pracuje po stronie klienta, nie możemy pliku zapisać gdzieś i go pokazać. Najprościej zatem pokazać taki obrazek w postaci zakodowanej do Base64. Robi się to w taki sposób, że odczytujemy dane obrazka (np. przekazujemy go do MemoryStream lub tablicy bajtów), następnie zamieniamy na Base64, a na koniec do tagu IMG przekazujemy ten obrazek w takiej postaci:
<img src="data:image/jpeg;base64,==data==" />
Ten tajemniczy ciąg w atrybucie src możemy rozbić na poszczególne elementy:
data:image/jpeg;base64,==data==
data – to określenie, że obrazek będzie w formie danych, a nie ścieżki do pliku
image/jpeg – to określenie typu tych danych
base64 – format danych i dane
Zwróć uwagę na różne separatory konkretnych elementów – po data jest dwukropek, po content-type jest średnik, a po base64 jest przecinek. Jasne, prawda? 🙂 Jeśli nie widzisz faktycznego obrazka tylko ikonkę zastępczą, sprawdź te separatory w pierwszej kolejności.
Zwróć uwagę, że content type przekazywanego pliku wziąłem z parametru InputFileChangeEventArgs. Mógłbym go wpisać na sztywno, ale wtedy byłby problem jeśli użytkownik wybrałby inny typ pliku. Png, svg, czy chociażby bmp.
Walidacja pliku po stronie klienta
W związku z tym, że plik nie musi być w Blazor częścią formularza i tak naprawdę nie ma żadnej walidacji, musimy posłużyć się małym mykiem.
Tak jak w kodzie powyżej, odczytamy plik do jakiejś zmiennej i będziemy walidować tę zmienną. To oznacza, że trzeba zmienić model widoku dla Blazor:
public class FormDataViewModel
{
[Required(AllowEmptyStrings = false, ErrorMessage = "Wybierz plik")]
[MinLength(1, ErrorMessage = "Plik nie może być pusty")]
[MaxLength(1024 * 1024, ErrorMessage = "Plik jest za duży")]
public byte[]? FileContent { get; set; }
[Required(ErrorMessage = "Musisz wpisać wiadomość")]
public string? Message { get; set; }
}
I zasadniczo taka walidacja jest prawie dobra. Problem w tym, że jeśli przekażemy zbyt duży plik, to nie będzie o tym informacji. Dlaczego? Przypomnij sobie, jak odczytujemy plik:
var stream = args.File.OpenReadStream(1024 * 1024);
Jeśli będzie większy niż 1MB, to wtedy OpenReadStream rzuci wyjątek. Dlatego też powinniśmy sprawdzić w tym miejscu, czy plik nie jest za duży.
Zgłoszenie błędu można zrobić na kilka sposobów. Najprostszym z nich jest po prostu pokazanie jakiegoś spana przy InputFile z odpowiednim tekstem. Np.:
To jest najprostsze rozwiązanie, które pewnie sprawdzi się w większości przypadków. Jednak można to też zrobić inaczej, za pomocą ValidationMessage. Wymaga to jednak zmiany sposobu walidacji formularza i wykracza poza ramy tego artykułu. Obiecuję, że napiszę osobny artykuł o dodatkowej walidacji w Blazor.
Przesyłanie pliku na serwer do WebApi
Skoro mamy już fajny przykład w Blazorze, wykorzystamy go do przesłania pliku do WebApi. A właściwie całego modelu. Pokażę Ci jak przesłać dane wraz z plikiem.
Przede wszystkim musisz pamiętać, że tak jak w przypadku MVC / Razor Pages, plik jest wysyłany formularzem. Zasadniczo – przesyłanie pliku na serwer zawsze sprowadza się do umieszczenia go w formularzu i wysłaniu tego formularza.
Odczyt po stronie Api
Na początek stwórzmy projekt WebApi. Następnie dodajmy model, który będzie wyglądał tak:
public class WebApiFormData
{
public IFormFile File { get; set; }
public string Message { get; set; }
}
Zwróć uwagę na starego znajomego – IFormFile.
Teraz najlepsze, jedyne co musimy zrobić w kontrolerze w WebApi to:
[Route("api/[controller]")]
[ApiController]
public class FormController : ControllerBase
{
[HttpPost]
public IActionResult PostHandler([FromForm]WebApiFormData data)
{
if (!ModelState.IsValid)
return BadRequest();
return Ok();
}
}
I to wystarczy, żeby WebApi odpowiednio odebrało dane! Po tej stronie już niczego nie musimy robić. Czy to nie jest piękne?
UWAGA! Jeśli podczas wysyłania danych do WebApi otrzymujesz błąd 415 (Unsupported Media Type) sprawdź, czy na pewno pobierasz w WebApi dane z formularza – atrybut FromForm.
Wysyłanie do API
Rejestracja HttpClientFactory
Teraz musimy te dane wysłać formularzem za pomocą HttpClient. Zrobimy to z aplikacji Blazor, która była tworzona wyżej w tym artykule. Zacznijmy od rejestracji fabryki dla HttpClient przy rejestracji serwisów:
builder.Services.AddHttpClient("api", client =>
{
client.BaseAddress = new Uri("https://localhost:7144/");
});
Tego klienta pobierzemy sobie w metodzie, która jest wywoływana po poprawnym przesłaniu formularza w Blazor:
[Inject]
public IHttpClientFactory HttpClientFactory { get; set; }
private async Task ValidSumbitHandler(EditContext ctx)
{
var client = HttpClientFactory.CreateClient("api");
}
Teraz musimy utworzyć całe ciało wiadomości, którą będziemy wysyłać. Będzie to formularz złożony z części.
Każda część to tzw. content. C# daje nam różne takie klasy jak np. StreamContent, do którego możemy wrzucić strumień, ByteArrayContent, do którego możemy podać tablicę, StringContent, do którego wrzucamy stringa.
I tutaj uwaga. Pojedyncze żądanie może mieć tylko jeden content. Ale jest specjalny typ – MultipartFormDataContent, który składa się z innych contentów. Ostatecznie wszystko jest przesyłane oczywiście jako strumień bajtów. A skąd wiadomo gdzie kończy się jedna zawartość a zaczyna druga?
Istnieje coś takiego jak Boundary. Jest to string, który oddziela poszczególne części między sobą. Jest ustawiany automatycznie, ale sam też go możesz podać. Pamiętaj tylko, że taki string powinien być unikalny.
Najpierw stwórzmy i dodajmy zawartość pliku. Posłużymy się tutaj ByteArrayContent, ponieważ trzymamy plik w tablicy bajtów:
var request = new HttpRequestMessage(HttpMethod.Post, "api/form");
var form = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(Data.FileContent);
form.Add(fileContent, nameof(WebApiFormData.File), "uploaded.jpg");
Na początku utworzyłem HttpRequestMessage, który ostatecznie zostanie wysłany. To on będzie miał zawarty w sobie formularz.
Potem tworzę formularz, do którego będą dodawane poszczególne zawartości.
I tak tworzymy fileContent z zawartością pliku, który został odczytany. Podczas dodawania tego contentu (form.Add) ważne jest wszystko – nazwa tego contentu – file – taka sama jak w modelu, który będzie przyjmowany przez WebApi. No i nazwa pliku – „uploaded.jpg”. Nazwa pliku musi być dodana w przypadku przesyłania pliku. Tak naprawdę powinieneś dodać prawdziwe rozszerzenie. Ja tylko z czystego lenistwa i prostoty wpisałem na sztywno „jpg”. Pamiętaj, żeby dodawać faktyczne rozszerzenie.
To teraz zajmiemy się pozostałymi właściwościami:
var msgContent = new StringContent(Data.Message, new MediaTypeHeaderValue("text/plain"));
form.Add(msgContent, nameof(WebApiFormData.Message));
Tutaj wszystko wygląda analogicznie, z tym że posługuję się StringContent zamiast ByteArrayContent.
I tutaj istotna uwaga. Zawsze dobrze jest podać media-type dla stringa, który przesyłasz. Może to być czysty tekst (text/plain), może to być string z json’em (application/json), cokolwiek. Może być też tak, że WebApi nie chce mieć przekazanego żadnego mime-type albo wręcz przeciwnie – jakiś konkretny. W takim przypadku zastosuj to, co jest napisane w dokumentacji konkretnego WebApi.
Teraz można już dodać nasz formularz do żądania i wysłać je:
Często trzeba przesłać większą ilość danych wraz z plikiem. Napisałem osobny artykuł o tym jak przesłać dane w JSON razem z plikiem. Zachęcam do przeczytania, bo mówię też tam o mechanizmie bindingu.
Dzięki za przeczytanie tego artykułu. Mam nadzieję, że wszystko jest jasne. Jeśli jednak znalazłeś jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu.
Pewnie nie raz spotkałeś się z sytuacją, gdzie próba wywołania API z Blazor albo JavaScript zakończyła się radosnym błędem
XMLHttpRequest cannot load http://…. No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin ‘http://…’ is therefore not allowed access.
Czym jest CORS, dlaczego jest potrzebne i jak się z nim zaprzyjaźnić? O tym dzisiaj.
Co to CORS
Cross Origin Request Sharing to mechanizm bezpieczeństwa wspierający politykę same-origin. Same-origin polega na tym, że JavaScript z jednej domeny (a konkretnie origin) nie może komunikować się z serwerem z innej domeny (origin).
Innymi słowy, jeśli masz stronę pod adresem: https://example.com i chciałbyś z niej wywołać za pomocą JavaScript coś ze strony https://mysite.com, to musisz mieć na to specjalne pozwolenie wydane przez mysite.com.
Czyli jeśli u siebie lokalnie z końcówki https://localhost:3000 będziesz chciał zawołać jakieś API z końcówki: https://localhost:5001, to też się to nie uda bez specjalnego pozwolenia. Tym wszystkim zarządza przeglądarka.
Czym jest ORIGIN
Już wiemy, że żeby nie było problemów, obydwie strony żądania muszą należeć do tego samego originu. Czym jest zatem origin?
To połączenie: protocol + host + port, czyli np:
https://example.com i https://example.com:443 – należą do tego samego originu. Pomimo, że w pierwszym przypadku nie podaliśmy jawnie portu, to jednak protokół https domyślnie działa na porcie 443. A więc został tam dodany niejawnie.
http://example.com i https://example.com – nie należą już do tego samego originu. Różnią się protokołem i portem (przypominam, że https działa domyślnie na porcie 443, a http na 80).
https://example.com:5000 i https://example.com:5001 – też nie należą do tego samego originiu, ponieważ różnią się portem.
https://api.example.com i https://example.com – też nie należą do tego samego originu, bo różnią się hostem. Zasadniczo origin definiuje aplikację internetową.
Polityka same-origin
Jak już pisałem wcześniej, polityka same-origin zakazuje jednej aplikacji korzystać z elementów innej aplikacji. Skryptów js, arkuszy css i innych… Ale…
No, ale jak to? A CDN? A linkowanie bootstrapa itd?
No właśnie. Przede wszystkim przeglądarki nie są zbyt rygorystyczne pod tym względem. Głównie ze względu na kompatybilność wsteczną. Pół Internetu przestałoby działać. Jednak to „rozluźnienie” niesie za sobą pewne zagrożenia. Np. może dawać podatność na atak XSS lub CSRF (pisałem o Cross Site Request Forgery w książce o podstawach zabezpieczania aplikacji internetowych).
Wyjątki polityki same-origin
Skoro przeglądarki niezbyt rygorystycznie podchodzą do polityki same-origin, to znaczy że są pewne luźniejsze jej elementy. Oczywiście, że tak. Przeglądarki pozwalają ogólnie na:
zamieszczanie obrazków z innych originów
wysyłanie formularzy do innych originów
zamieszczanie skryptów z innych originów – choć tutaj są już pewne ograniczenia
Na co same-origin nie pozwoli
Przede wszystkim nie pozwoli Ci na dostęp do innych originów w nowych technologiach takich jak chociażby AJAX. Czyli strzały HTTP za pomocą JavaScriptu. Co to oznacza? Zacznijmy od najmniejszego problemu – jeśli piszesz aplikację typu SPA w JavaScript lub Blazor, to chcesz się odwoływać do jakiegoś API. W momencie tworzenia aplikacji prawdopodobnie serwer stoi na innym originie niż front. Na produkcji może być podobnie. W takiej sytuacji bez obsługi CORS po stronie serwera, nie połączysz się z API.
Idąc dalej, jeśli chcesz na swojej stronie udostępnić dane pobierane z innego źródła – np. pobierasz AJAXem kursy walut – to też może nie zadziałać. W prawdzie użyłem tych kursów walut jako być może nieszczęśliwy przykład. Jeśli to działa to tylko ze względu na luźną politykę CORS. W przeciwnym razie musiałbyś się kontaktować z dostawcą danych, żeby pozwolił Ci na ich pobieranie. I tak też często się dzieje. I on może to zrobić właśnie dzięki CORS.
Więc jak działa ten CORS?
Pobierz sobie przykładową solucję, którą przygotowałem na GitHub. Jest kam kilka projektów:
WebApiWithoutCors – api, które w żaden sposób nie reaguje na CORS – domyślnie uniemożliwi wszystko
WebApiWithCors – api z obsługą CORS
ClientApiConfig – podstawowy klient, który chciałby pobrać dane i zrobić POST
DeletableClient – klient, któremu polityka CORS pozwala jedynie na zrobienie DELETE
BadClient – klient, któremu żadne API na nic nie pozwala
Każdy projekt pracuje w HTTP (nie ma SSL/TLS) specjalnie, żeby umożliwić w łatwy sposób podsłuchiwanie pakietów w snifferze.
Przede wszystkim działanie CORS (Cross Origin Request Sharing) jest domeną przeglądarki. Jeśli uruchomisz teraz przykładowe projekty: ClientApp (aplikacja SPA pisana w Blazor) i WebApiWithoutCors i wciśniesz guzik Pobierz dane, to zobaczysz taki komunikat:
A teraz wywołaj tę samą końcówkę z PostMan, to zobaczysz że dane zostały pobrane:
Co więcej, jeśli posłużysz się snifferem, np. WireShark, zobaczysz że te dane do przeglądarki przyszły:
To znaczy, że to ta małpa przeglądarka Ci ich nie dała. Co więcej, wywaliła się wyjątkiem HttpRequestException przy pobieraniu danych:
var client = HttpClientFactory.CreateClient("api");
try
{
var response = await client.GetAsync("weatherforecast");
if (!response.IsSuccessStatusCode)
ErrorMsg = $"Nie można było pobrać danych, błąd: {response.StatusCode}";
else
{
var data = await response.Content.ReadAsStringAsync();
WeatherData = new(JsonSerializer.Deserialize<WeatherForecast[]>(data));
}
}catch(HttpRequestException ex)
{
ErrorMsg = $"Nie można było pobrać danych, błąd: {ex.Message}";
}
Co z tą przeglądarką nie tak?
Przeglądarka odebrała odpowiedź z serwera, ale Ci jej nie pokazała. Dlaczego? Ponieważ nie dostała z serwera odpowiedniej informacji. A konkretnie nagłówka Access-Control-Allow-Origin, o czym informuje w konsoli:
XMLHttpRequest cannot load http://.... No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin ‘http://...’ is therefore not allowed access.
To poniekąd serwer zdecydował, że nie chce klientowi danych pokazywać. A dokładniej – serwer nie zrobił niczego, żeby te dane pokazać. Po prostu na dzień dobry obsługa CORS na serwerze jest wyłączona. Można powiedzieć, że jest zaimplementowana jedynie w przeglądarce.
Niemniej jednak przeglądarka wysłała do serwera zapytanie GET, które odpowiednie dane pobrało i zwróciło. Czyli jakaś operacja na serwerze się wykonała. Pamiętaj, że zapytanie GET nie powinno mieć żadnych skutków ubocznych. Czyli nie powinno zmieniać żadnych danych. Powinno dane jedynie pobierać. A więc teoretycznie nic złego nie może się stać.
A gdyby tak przeglądarka wysłała POST? Zadziała czy nie? No właśnie nie w każdej sytuacji.
Jeśli teraz w przykładowej aplikacji uruchomisz narzędzia dewelopera (Shift + Ctrl + i) i wciśniesz guzik Wywołaj POST, to zobaczysz coś takiego:
Zanim przeglądarka wyśle to żądanie, najpierw wykona specjalne zapytanie, tzw. preflight. Czyli niejako zapyta się serwera: „Hej serwer, jestem z takiego originu i chciałbym wysłać do Ciebie POST z takimi nagłówkami. Mogę?”
To specjalne żądanie wysyłane jest na adres, na który chcesz rzucić POSTem. Z tym że tutaj metodą jest OPTIONS. Poza tym w nagłówkach są zaszyte informacje:
Access-Control-Request-Headers – lista nagłówków z jakimi chcesz wysłać POST
Access-Control-Request-Method – metoda, jaką chcesz wywołać (POST, DELETE itd)
Origin – origin, z którego żądanie będzie wysłane
Możesz to podejrzeć zarówno w narzędziach dewelopera jak i w Wireshark:
A co zrobił serwer? Zwrócił błąd: 405 - Method not allowed. Co znaczy, że pod takim endpointem serwer nie obsługuje zapytań typu OPTIONS. Co dla przeglądarki daje jasny komunikat: „Nie wysyłaj mi tego, nie obsługuję CORS”. Przeglądarka więc zaniecha i nie wyśle takiego zapytania.
Wyjątkowe formularze
Jak już pisałem wcześniej, formularze są pewnym wyjątkiem. Przeglądarka i tak je wyśle. To kwestia kompatybilności wstecznej. Jeśli będziesz chciał wysłać metodę POST z Content-Type ustawionym na multipart/form-data, to takie zapytanie zostanie wykonane bez żadnego preflight'u. Takich wyjątków jest więcej i są bardzo dobrze opisane na stronie Sekuraka, więc nie będę tego powielał. Jeśli masz ochotę zgłębić temat, to polecam.
Obsługa CORS w .NET
Skoro już wiesz z grubsza czym jest CORS i, że to serwer ostatecznie musi dać jawnie znać, że zgadza się na konkretne zapytanie, to teraz zaimplementujmy ten mechanizm po jego stronie. Spójrz na projekt WebApiWithCors z załączonej solucji.
Jeśli pracujesz na .NET < 6, to pewnie będziesz musiał dorzucić Nugeta: Microsoft.AspNetCore.Cors.
Przede wszystkim musisz dodać serwisy obsługujące CORS podczas rejestracji serwisów:
builder.Services.AddCors();
a także wpiąć obsługę CORS w middleware pipeline. Jeśli nie wiesz, czym jest middleware pipeline, przeczytaj ten artykuł.
Pamiętaj, że UseCors musi zostać wpięte po UseRouting, ale przed UseAuthorization.
Takie dodanie jednak niczego nie załatwi. CORS do odpowiedniego funkcjonowania potrzebuje polityki. I musimy mu tę politykę ustawić.
Polityka CORS
CORS Policy mówi jakich dokładnie klientów i żądania możesz obsłużyć. Składa się z trzech części:
origin – obsługuj klientów z tych originów
method – obsługuj takie metody (POST, GET, DELETE itd…)
header – obsługuj takie nagłówki
To znaczy, że klient aby się dobić do serwera musi spełnić wszystkie trzy warunki – pochodzić ze wskazanego originu, wywołać wskazaną metodę i posiadać wskazany nagłówek.
Wyjątkiem jest tu POST. Co wynika z wyjątkowości formularzy. Jeśli będziesz chciał wysłać POST, przeglądarka zapyta się o to jedynie w przypadku, gdy Content-Type jest odpowiedni (np. nie wskazuje na formularz). Co to dalej oznacza? Jeśli stworzysz na serwerze politykę, która nie dopuszcza POST, ale dopuszcza wszystkie nagłówki (AllowAnyHeader), to ten POST i tak zostanie wysłany. Kwestia kompatybilności wstecznej.
Ona zezwala na połączenia z dowolnego originu, wykonanie dowolnej metody z dowolnymi nagłówkami. Czy to dobrze? To zależy od projektu.
Co więcej, metoda AddDefaultPolicy doda domyślną politykę. Wpięty w pipeline UseCors będzie używał tej domyślnej polityki do sprawdzenia, czy żądanie od klienta może pójść dalej.
Za pomocą metody AddPolicy możesz dodać politykę z jakąś nazwą. Tylko wtedy do UseCors musisz przekazać w parametrze nazwę polityki, której chcesz używać domyślnie. UseCors wywołany bez parametrów będzie używał polityki dodanej przez AddDefaultPolicy. Jeśli jej nie dodasz, wtedy CORS nie będzie obsługiwany.
Konkretna polityka
Oczywiście możesz w polityce wskazać konkretne wartości, np.:
To spowoduje, że polityka dopuści strzały tylko z originu http://localhost:5001 z jakąkolwiek metodą i dozwolonym nagłówkiem X-API-KEY.
I tutaj dwie uwagi. Po pierwsze – pamiętaj, żeby originu nie kończyć slashem: / . Jeśli tak wpiszesz http://localhost:5001/, wtedy origin się nie zgodzi i mechanizm CORS nie dopuści połączeń. Czyli – brak slasha na końcu originu. Idąc dalej, nie podawaj pełnych adresów w stylu: https://localhost:5001/myapp – to nie jest origin.
A teraz pytanie za milion punktów. Co się stanie, gdy mając taką politykę z poprawnego originu wywołasz:
var data = new WeatherForecast
{
Date = DateTime.Now,
Summary = "Cold",
TemperatureC = 5
};
var client = HttpClientFactory.CreateClient("api");
client.DefaultRequestHeaders.Add("X-API-KEY", "abc");
var response = await client.PostAsJsonAsync("weatherforecast", data);
Dodałeś nagłówek X-API-KEY do żądania i wysyłasz JSONa za pomocą post (dowolna metoda).
Zadziała?
Przemyśl to.
Jeśli powiedziałeś „nie”, to zgadza się. Gratulacje 🙂 A teraz pytanie dlaczego to nie zadziała. Spójrz jaki przeglądarka wysyła preflight:
O co pyta przeglądarka w tym żądaniu?
„Hej serwer, czy mogę ci wysłać POST z nagłówkami contenty-type i x-api-key? A co odpowiada serwer?
„Ja się mogę zgodzić co najwyżej na metodę POST i nagłówek X-API-KEY„.
Przeglądarka teraz patrzy na swoje żądanie i mówi: „Ojoj, to nie mogę ci wysłać content-type. Więc nie wysyłam”. To teraz pytanie skąd się wzięło to content type? Spójrz jeszcze raz na kod:
var response = await client.PostAsJsonAsync("weatherforecast", data);
Wysyłasz JSONa. A to znaczy, że gdzieś w metodzie PostAsJsonAsync został dodany nagłówek: Content-Type=application/json. Ponieważ w zawartości żądania (content) masz json (czyli typ application/json).
Uważaj na takie rzeczy, bo mogą doprowadzić do problemów, z którymi będziesz walczył przez kilka godzin. Ale w tym wypadku już powinieneś wiedzieć, jak zaktualizować politykę CORS:
Jeśli wydaje Ci się, że CORS powinien zadziałać, a nie działa, w pierwszej kolejności zawsze zobacz jaki preflight jest wysyłany, jakie nagłówki idą w żądaniu i czy są zgodne z polityką.
Pamiętaj też, że w żądaniu nie muszą znaleźć się wszystkie nagłówki. Jeśli nie będzie w tej sytuacji X-API-KEY nic złego się nie stanie. Analogicznie jak przy polityce dotyczącej metod. Możesz wysłać albo GET, albo POST, albo DELETE… Nie możesz wysłać kilku metod jednocześnie, prawda? 🙂
CORS tylko dla jednego endpointu
Corsy możesz włączyć tylko dla konkretnych endpointów. Możesz to zrobić za pomocą atrybutów. I to na dwa sposoby. Jeśli chcesz umożliwić większość operacji, możesz niektóre endpointy wyłączyć spod opieki CORS. Spójrz na kod kontrolera:
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
[HttpPost]
[DisableCors]
public IActionResult PostTest([FromBody]WeatherForecast data)
{
return Ok();
}
Atrybut DisableCors spowoduje, że mechanizm CORSów uniemożliwi wywołanie tej końcówki. Jeśli przeglądarka użyje preflight, wtedy serwer odpowie, ale nie pozwalając na nic.
Kilka polityk CORS
Skoro można zablokować CORS na pewnych końcówkach, to pewnie można też odblokować na innych. No i tak. Zgadza się. Zróbmy sobie takie dwie polityki:
Zwróć uwagę na to, że nie dodajemy teraz żadnej domyślnej polityki (AddDefaultPolicy), tylko dwie, które jakoś nazwaliśmy. Teraz każdy endpoint może mieć swoją własną politykę:
[HttpGet]
[EnableCors("get-policy")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
[HttpPost]
[EnableCors("set-policy")]
public IActionResult PostTest([FromBody]WeatherForecast data)
{
return Ok();
}
Każdy endpoint dostał swoją własną politykę za pomocą atrybutu EnableCors. Jako parametr przekazujemy nazwę polityki. Jeśli w takim przypadku nie podasz atrybutu EnableCors, to końcówka będzie zablokowana. Dlaczego? Spójrz na middleware:
app.UseCors();
Taki middleware po prostu będzie chciał użyć domyślnej polityki (AddDefaultPolicy), której jednak nie ma. Dlatego też zablokuje wszystko. Oczywiście możesz w tym momencie podać konkretną politykę, jaka ma być używana przez middleware:
app.UseCors("get-policy");
Wtedy każdy endpoint bez atrybutu [EnableCors] będzie używał tej polityki.
Dynamiczna polityka CORS
Czasem możesz potrzebować bardziej płynnej polityki, która może zależeć od konkretnego originu. Możesz chcieć wpuszczać tylko te originy, które są zarejestrowane w bazie albo dla różnych originów mieć różne polityki.
Wtedy sam musisz zadbać o to, żeby mechanizm CORS dostał odpowiednią politykę. Na szczęście w .NET6 jest to banalnie proste. Wystarczy zaimplementować interfejs ICorsPolicyProvider, np. w taki sposób:
public class OriginCorsPolicyProvider : ICorsPolicyProvider
{
private readonly CorsOptions _corsOptions;
public OriginCorsPolicyProvider(IOptions<CorsOptions> corsOptions)
{
_corsOptions = corsOptions.Value;
}
public Task<CorsPolicy?> GetPolicyAsync(HttpContext context, string? policyName)
{
var origin = context.Request.Headers.Origin;
var policy = _corsOptions.GetPolicy(origin);
if (policy == null)
policy = _corsOptions.GetPolicy(policyName ?? _corsOptions.DefaultPolicyName);
return Task.FromResult(policy);
}
}
Interfejs wymaga tylko jednej metody – GetPolicyAsync. Najpierw jednak zobacz w jaki sposób zarejestrowałem odpowiednie polityki podczas rejestracji serwisów CORS:
Nazwa polityki to po prostu origin, dla którego ta polityka jest utworzona. A teraz wróćmy do providera. Spójrz najpierw na metodę GetPolicyAsync.
Najpierw pobieram origin z requestu, następnie pobieram odpowiednią politykę. Metoda GetPolicy z obiektu _corsOptions zwraca politykę po nazwie. Te polityki są tam dodawane przez setup.AddPolicy. Gdzieś tam pod spodem są tworzone jako dodatkowe opcje, co widzisz w konstruktorze – w taki sposób możesz pobrać zarejestrowane polityki.
Oczywiście nic nie stoi na przeszkodzie, żebyś w swoim providerze połączył się z bazą danych i na podstawie jakiś wpisów sam utworzył odpowiednią politykę dynamicznie i ją zwrócił.
Teraz jeszcze tylko musimy zarejestrować tego providera:
Słowem zakończenia, pamiętaj żeby nie podawać w middleware kilka razy UseCors, bo to nie ma sensu. Pierwszy UseCors albo przepuści żądanie dalej w middleware albo je sterminuje.
To tyle jeśli chodzi o CORSy. Dzięki za przeczytanie artykułu. Mam nadzieję, że teraz będziesz się poruszał po tym świecie z większą pewnością. Jeśli znalazłeś błąd w artykule albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu.
Często zdarza się, że użytkownik musi mieć pewne uprawnienia, żeby móc pracować z pewnymi zasobami. Żeby to nie brzmiało aż tak enigmatycznie, posłużę się przykładem.
Załóżmy, że jest aplikacja do fakturowania (np. Fakturownia, której sam używam :)). Załóżmy też, że aplikacja ma kilka ról – administrator, użytkownik i super admin.
Administrator – może tworzyć organizacje, dodawać do niej użytkowników i zarządzać wszystkimi rekordami w swojej organizacji. Użytkownik może tylko przeglądać i dodawać nowe faktury. A superadmin jest „ponad to” i może zupełnie wszystko. Super adminem byłby w tym przypadku właściciel takiego serwisu.
I teraz tak. Administrator zakłada konto „Piękny Lolo”. Dodaje rekordy związane z wydatkami (dla swojej organizacji), usuwa i git. Dodaje też użytkowników pod swoje konto.
Ale nagle rejestruje się nowy administrator (z innej organizacji) – zakłada konto: „Wąsaty Jan”. I teraz jakby wyglądała sytuacja, gdybyś posługiwał się autoryzacją opartą o role? Zarówno Wąsaty Jan, jak i Piękny Lolo mają uprawnienia administratora. Więc teoretycznie Wąsaty Jan może pracować na rekordach Pięknego i vice versa. Nie chcemy tego.
Trzeba zatem ograniczyć ich działanie tylko do ich „organizacji”. W innym przypadku mamy podatność bezpieczeństwa (jest to jedna z podatności, o których piszę w swojej książce – „Zabezpieczanie aplikacji internetowych”).
Tutaj z pomocą przychodzi tzw. autoryzacja oparta na zasobach (resource based authorization).
Przykładowy projekt
Dla ułatwienia posłużymy się prostszym problemem – zrobimy standardową aplikację do zadań. Nie zaciemni to obrazu, a postępowanie jest dokładnie takie samo.
To jest zwykły projekt RazorPages z ustawionym uwierzytelnianiem (Authentication Type) na Individual Accounts. Dla ułatwienia wszystko zawarłem w jednym projekcie. Pamiętaj, że w prawdziwym życiu powinieneś rozdzielić ten projekt na kilka innych.
UWAGA! To jest bardzo prosta aplikacja bez żadnych walidacji. Pokazuje właściwie najprostszy uporządkowany kod, żeby bez sensu nie zaciemniać obrazu.
Potrafi utworzyć zadanie (TodoItem), zmodyfikować i usunąć je.
Zanim uruchomisz projekt, musisz utworzyć bazę danych. W katalogu z projektem uruchom polecenie:
dotnet ef database update
Namespacey projektu
Abstractions
Zawiera interfejs ITodoItemService, który jest wstrzykiwany do RazorPages. On obsługuje wszystkie operacje na bazie danych. Są dwa serwisy, które implementują ten interfejs: SecureTodoItemService – który pokazuje operowanie na zasobach w sposób bezpieczny, a także InsecureTodoItemService – ten pokazuje działania bez żadnych zabezpieczeń.
Domyślnie działającym jest InsecureTodoItemService. Możesz to zmienić w pliku Program.cs.
Areas
To domyślna obsługa .Net Identity – zakładanie kont, logowanie itp.
Data
Głównym jej elementem jest model bazodanowy TodoItem. Poza tym zawiera migracje EfCore, a także DbContext.
Pages
Zawiera strony i komponenty – zgodnie z nomenklaturą RazorPages
Services
Zawiera potrzebne serwisy.
Działanie niezabezpieczone
Spójrz na serwis InsecureTodoItemService. Jak widzisz nie ma on żadnych zabezpieczeń ani sprawdzeń. Przykładowa metoda usuwająca zadanie wygląda tak:
public async Task RemoveItem(int id)
{
var model = new TodoItem { Id = id };
_db.TodoItems.Remove(model);
await _db.SaveChangesAsync();
}
To znaczy, że właściwie każdy, kto ma konto może usunąć dowolne itemy. Wystarczy poznać ID. Nie jest to, coś co byśmy chcieli uzyskać.
Więc zajmijmy się tym.
Zabezpieczamy program
Zabezpieczenie w tym przypadku polega na sprawdzeniu, czy użytkownik, który wykonuje operację ma prawo do wykonania tej operacji na danym zasobie. Czyli w przypadku tej aplikacji – czy jest właścicielem danego zasobu.
Oczywiście można to zrobić na kilka sposobów, jednak pokażę Ci tutaj standardowy mechanizm .NET, który to zadanie ułatwia.
Krok 1 – dodawanie wymagań
Pierwszy krok jest zarówno najprostszy, jak i najcięższy do zrozumienia. Musimy dodać wymaganie (requirement). To wymaganie musi zostać spełnione, żeby użytkownik mógł przeprowadzić operację.
To wymaganie może wyglądać tak:
public class TodoItemOwnerOrSuperAdminRequirement: IAuthorizationRequirement
{
}
Zapytasz się teraz – dlaczego ta klasa jest pusta? Jaki jest jej sens? To wytłumaczyć najtrudniej. Generalnie interfejs IAuthorizationRequirement nie ma w sobie żadnych metod, właściwości… zupełnie niczego. Jest pusty. Służy głównie tylko do opisania wymagania. Samego zaznaczenia odpowiedniej klasy. Oczywiście nikt Ci nie zabroni dodać do tej klasy jakiejś logiki. Możesz też ją wstrzykiwać do swoich serwisów.
Krok 2 – dodawanie AuthorizationHandler
Drugim krokiem jest dodanie handlera, który sprawdzi, czy użytkownik może wykonać daną operację. Prosty przykład w naszej aplikacji:
Twoja klasa musi dziedziczyć po AuthorizationHandler. AuthorizationHandler jest abstrakcyjną klasą generyczną. W parametrach generycznych przyjmuje typ wymagania, a także typ resource’a. Jest jeszcze druga jej postać, która przyjmuje tylko typ wymagania.
Musisz przesłonić tylko jedną metodę – HandleRequirementAsync. W parametrze AuthorizationHandlerContext dostajesz m.in. zalogowanego użytkownika (ClaimsPrincipal). Ja się posługuję swoim serwisem LoggedUserProvider ze względu na prostotę (w przeciwnym razie musiałbym jakoś odczytywać i zapisywać claimsy). W parametrze dostajesz również obiekt, o który pytasz.
I jeśli spojrzysz teraz do ciała tej metody, zobaczysz że sprawdzam, czy zalogowany użytkownik jest właścicielem danego zasobu. Normalnie sprawdzałbym, czy zalogowany użytkownik jest superadminem lub właścicielem zasobu. Ze względu na prostotę, pominęliśmy tutaj aspekt ról i superadmina.
I teraz, jeśli użytkownik jest superadminem lub właścicielem zasobu, przekazuję do kontekstu sukces. W przeciwnym razie blokuję.
Krok 3 – użycie AuthorizationHandler
W pierwszej kolejności musimy zarejestrować naszą klasę AuthorizationHandler, żeby móc jej używać. Rejestrujemy to oczywiście podczas rejestracji serwisów:
services.AddScoped<IAuthorizationHandler, TodoItemAuthHandler>(); //może być jako singleton, jeśli Twój serwis nie wykorzystuje innych scoped serwisów
A potem już tylko mały zastrzyk do serwisu (plik SecuredTodoItemService.cs). Wstrzykujemy interfejs IAuthorizationService:
public async Task ModifyItem(TodoItem item)
{
var authResult = await _authService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, item,
new TodoItemOwnerOrSuperAdminRequirement());
if(authResult.Succeeded)
{
_db.TodoItems.Update(item);
await _db.SaveChangesAsync();
}
}
_httpContextAccessor to oczywiście wstrzyknięty IHttpContextAccessor, bo metoda AuthorizeAsync niestety wymaga od nas przekazania zalogowanego użytkownika (on się później znajdzie w kontekście HandleRequirementAsync z klasy dziedziczącej po AuthorizationHandler).
Generalnie klasa implementująca IAuthorizationService została zarejestrowana automatycznie podczas rejestrowania autoryzacji (AddAuthorization()). Gdy wywołujesz AuthorizeAsync, ona sprawdza typ zasobu i wymaganie, o które pytasz. Na tej podstawie wywołuje metodę HandleRequirementAsync z odpowiedniej klasy dziedziczącej po AuthorizationHandler. A ich możesz mieć wiele. Dla różnych zasobów i różnych wymagań.
Jaki z tego wniosek? Wystarczy, że napiszesz jedną klasę pod konkretny typ zasobu, który chcesz chronić.
Dodatkowe uproszczenie
Oczywiście to można jeszcze bardziej ukryć/uprościć, tworząc przykładową klasę ResourceGuard, np:
public class ResourceGuard
{
private readonly IAuthorizationService _authService;
private readonly IHttpContextAccessor _httpCtx;
public ResourceGuard(IAuthorizationService authService, IHttpContextAccessor httpCtx)
{
_authService = authService;
_httpCtx = httpCtx;
}
public async Task<AuthorizationResult> LoggedIsAuthorized<T>(object resource)
where T: IAuthorizationRequirement, new()
{
var requirement = new T();
var user = _httpCtx.HttpContext.User;
//tu możesz sprawdzić, czy user jest super adminem albo pójść dalej:
return await _authService.AuthorizeAsync(user, resource, requirement);
}
}
Wykorzystanie takiej klasy byłoby już dużo łatwiejsze:
public async Task DeleteItem(TodoItem item)
{
var authResult = await _guard.LoggedIsAuthorized<TodoItemOwnerOrSuperAdminRequirement>(item);
if (!authResult.Succeeded)
return;
else
{
//todo: usuń
}
}
Gdzie wstrzykujesz już tylko ResourceGuard'a.
Moim zdaniem, jeśli masz dużo zasobów do chronienia, pomysł z ResourceGuardem jest lepszy, ale to oczywiście wszystko zależy od konkretnego problemu.
Pobieranie danych
A jak sprawdzić autoryzację przy pobieraniu danych? Tutaj trzeba odwrócić kolejność. Do tej pory najpierw sprawdzaliśmy autoryzację, a potem robiliśmy operacje na danych.
W przypadku pobierania musisz najpierw pobrać żądane dane, a dopiero potem sprawdzić autoryzację, np.:
public async Task<TodoItem> GetItemById(int id)
{
var result = await _db.TodoItems.SingleOrDefaultAsync(x => x.Id == id);
if (result == null)
return null;
var authResult = await _authService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, result,
new TodoItemOwnerOrSuperAdminRequirement());
if (authResult.Succeeded)
return result;
else
return null;
}
Jeśli musisz pobrać całą listę, to być może będziesz musiał sprawdzić każdy rekord z osobna. I potem w zależności od analizy biznesowej – albo zwracasz tylko te rekordy, do których użytkownik może mieć dostęp, albo gdy nie ma dostępu przynajmniej do jednego – nie zwracasz niczego.
UWAGA!
Zwróć uwagę, że w przykładowym projekcie, jeśli użytkownik nie ma uprawnień do wykonania operacji to albo jej nie wykonuję, albo zwracam null. W rzeczywistym projekcie dobrze jednak jest w jakiś sposób poinformować kontroler, żeby odpowiedział błędem 403 - Forbidden.
Dzięki za przeczytanie artykułu. Jeśli czegoś nie rozumiesz lub znalazłeś błąd w tekście, koniecznie daj znać w komentarzu 🙂
Daj znać w komentarzu jaką formę artykułów wolisz – taką z gotowym projektem na GitHub, który omawiam – tak jak tutaj, czy klasyczną, w której tworzymy projekt od nowa z pominięciem GitHuba.
Jeśli trafiłeś tu tylko po to, żeby dowiedzieć się, jak walidować złożony obiekt, zobacz końcówkę artykułu.
Kiedyś napisałem co nieco o walidacji w .NetCore MVC. Możesz te artykuły przeczytać tu i tu. Powinieneś je przeczytać, ponieważ mówią również o podstawach walidacji jako takiej (w tym o data annotation validation, którym będziemy się tu posługiwać).
Wchodząc w świat Blazor, bardzo szybko będziesz chciał tworzyć formularze i je walidować. Na szczęście w Blazor jest to chyba jeszcze prostsze niż w Razor.
Pierwszy formularz
Blazor ma wbudowany komponent, który nazywa się EditForm. Jak można się domyślić, jest to po prostu formularz. Ma on kilka ciekawych możliwości, w tym artykule skupimy się jednak tylko na walidacji.
Na początek stwórzmy bardzo prosty model – Customer. Tę klasę utworzymy oczywiście w osobnym pliku Customer.cs.
public class Customer
{
[Required]
[StringLength(50, MinimumLength = 5)]
public string Name { get; set; }
}
Jest klasa z jednym polem i dodanymi adnotacjami:
Pole jest wymagane
Musi mieć długość minimum 5 znaków i maksimum 50.
Teraz utwórzmy w pliku .razor stronę z formularzem:
Spójrz, co mamy w sekcji code. Tworzymy nowy obiekt dla naszego modelu. To jest ważne, ponieważ ten obiekt powiążesz z formularzem.
Teraz spójrz wyżej na komponent EditForm. To jest właściwie najbardziej podstawowa konstrukcja formularza. Mamy właściwość Model, która określa OBIEKT powiązany (zbindowany) z formularzem. Pamiętaj, że to jest obiekt utworzony w sekcji code, a nie nazwa klasy.
Dalej mamy zdarzenie OnValidSubmit. Przypisujemy tutaj metodę, która wykona się po wysłaniu formularza, gdy będzie on prawidłowy. Z tego wynika, że ta metoda nie zostanie wykonana, jeśli formularz będzie zawierał błędy (są inne zdarzenia, które do tego służą).
Dalej w formularzu masz komponent InputText, który określa powiązanie z odpowiednim polem modelu – za pomocą @bind-value – czyli typowy binding z Blazor.
No i na koniec mamy guzik do wysłania formularza – pamiętaj, że w formularzu możesz mieć TYLKO JEDEN guzik typu submit.
Dodajemy walidację do strony
Przede wszystkim trzeba powiedzieć formularzowi, żeby walidował model za pomocą DataAnnotations (można to zrobić inaczej, ale w tym artykule skupiamy się na prostych podstawach). W tym celu trzeba mu dorzucić komponent DataAnnotationsValidator:
Właściwie to załatwia sprawę, formularz może być już walidowany. Sprawdź to. Jeśli spróbujesz wysłać pusty formularz, wymagane pole zaświeci się na czerwono.
Komunikaty błędów
Pewnie chciałbyś uzyskać jakiś komunikat błędu? Można to zrobić na kilka sposobów. Najprościej – dodaj komponent ValidationSummary – czyli podsumowanie walidacji.
Możesz go dodać w dowolnym miejscu formularza – tutaj pojawią się po prostu komunikaty o błędach. Uruchom teraz i zobacz, co się stanie, gdy podasz nieprawidłowe dane:
Widzimy opis błędu i podświetlone konkretne pole. Przy czym zwróć uwagę, że opis błędu wskazuje konkretnie na pole o nazwie „Name” – to zostało wzięte z modelu. Można to zmienić. Wszystkie rodzaje inputów (a jest ich kilka) w EditForm mają właściwość DisplayName. Dodaj ją:
<InputText id="name" @bind-Value="Customer.Name" DisplayName="imię i nazwisko"/>
To jest po prostu przyjazna nazwa pola dla walidacji. Niestety w mojej wersji Blazor (wrzesień 2021) ta właściwość nie chce działać. Ale nic to. I tak w prawdziwym świecie posłużymy się odpowiednią adnotacją w modelu:
public class Customer
{
[Required(ErrorMessage = "Musisz wypełnić imię i nazwisko")]
[StringLength(50, MinimumLength = 5, ErrorMessage = "Imię i nazwisko musi być dłuższe niż 5 znaków i krótsze niż 50")]
public string Name { get; set; }
}
W artykule o własnej walidacji w .NetCore pisałem też o tym, jak zrobić wymagany checkbox. Np do akceptacji regulaminu. Wtedy trzeba było się nieźle nakombinować. Dzisiaj jest dużo prościej i zdecydowanie bardziej przyjaźnie. Spójrz na model:
public class Customer
{
[Required(ErrorMessage = "Musisz wypełnić imię i nazwisko")]
[StringLength(50, MinimumLength = 5, ErrorMessage = "Imię i nazwisko musi być dłuższe niż 5 znaków i krótsze niż 50")]
public string Name { get; set; }
[Required]
[Range(typeof(bool), "true", "true", ErrorMessage = "Musisz zaakceptować plitykę prywatności")]
public bool PrivacyAgreed { get; set; }
}
Nie wiem jak Ty, ale ja nie lubię widzieć całej listy rzeczy, które zostały źle wprowadzone. Lubię, jak każdy błąd jest wyświetlony przy konkretnym polu. Można to zrobić bardzo prosto.
Błędy przy konkretnych polach
W tym momencie możesz pozbyć się komponentu ValidationSummary. To on właśnie pokazuje pełną listę błędów. Pamiętaj jednak, żeby zostawić DataAnnotasionsValidator, który odpowiada za walidację.
Teraz do każdego walidowanego pola możesz dodać komponent ValidationMessage:
Oczywiście te komponenty możesz umieścić gdziekolwiek w formularzu. Za pomocą właściwości For określasz, do którego pola się odnoszą. Zwróć uwagę, że przekazujesz tam lambdę, a nie nazwę pola
Wszystko fajnie, tylko że brzydko. A co, jeśli chcielibyśmy trochę popracować nad wyglądem komunikatów? Można to zrobić. W standardowym projekcie znajduje się plik site.css (w katalogu wwwroot/css). Jak sobie popatrzysz do środka, zobaczysz taki styl:
.validation-message {
color: red;
}
To właśnie odpowiada za wygląd komunikatów. Możemy to zmienić, np:
Co ma każdy klient? Każdy klient ma swój adres. Stwórzmy teraz bardzo prosty model adresu:
public class Address
{
[Required(ErrorMessage = "Musisz podać nazwę ulicy")]
public string StreetName { get; set; }
[Required(ErrorMessage = "Musisz podać numer ulicy")]
public string StreetNumber { get; set; }
[Required(ErrorMessage = "Musisz podać miasto")]
public string City { get; set; }
}
I dodajmy go do modelu klienta, a także do formularza:
public class Customer
{
[Required(ErrorMessage = "Musisz wypełnić imię i nazwisko")]
[StringLength(50, MinimumLength = 5, ErrorMessage = "Imię i nazwisko musi być dłuższe niż 5 znaków i krótsze niż 50")]
public string Name { get; set; }
[Required]
[Range(typeof(bool), "true", "true", ErrorMessage = "Musisz zaakceptować plitykę prywatności")]
public bool PrivacyAgreed { get; set; }
public Address Address { get; set; } = new Address();
}
Okazuje się, że Blazor ma pewne problemy z takimi polami, chociaż być może jest to celowe działanie. Jeszcze dosłownie niedawno trzeba było tworzyć obejścia. Jednak teraz mamy to PRAWIE w standardzie:
Pobierz NuGet: Microsoft.AspNetCore.Components.DataAnnotations.Validation. UWAGA! Na wrzesień 2021 ten pakiet nie jest jeszcze oficjalnie wydany. Jest to prerelease. A więc możliwe, że będziesz musiał zaznaczyć opcję Include Prerelase w NuGet managerze:
W swoim modelu Customer oznacz pole Address atrybutem: ValidateComplexType. Pamiętaj, że jest to składnik pobranego właśnie NuGeta. Jeśli więc masz modele w innym projekcie, musisz tam też pobrać ten pakiet.
W razor zmień DataAnnotationsValidator na ObjectGraphDataAnnotationsValidator
I to na tyle. Teraz wszystko już działa. ObjectGraphAnnotationsValidator sprawdzi walidacje wszystkich zwykłych pól, jak i tych „kompleksowych”.
Wbudowane kontrolki
Powiem jeszcze na koniec o kontrolkach formularzy wbudowanych w Blazor. Oczywiście możemy tworzyć własne jeśli będzie taka potrzeba (czasami jest), jednak do dyspozycji na starcie mamy:
InputText
InputCheckbox
InputDate
InputFile
InputNumber
InputRadio
InputRadioGroup
InputSelect
InputTextArea
To tyle, jeśli chodzi o podstawy walidacji w Blazor. Jeśli masz jakieś pytania lub znalazłeś błąd w artykule, podziel się w komentarzu.
Obsługujemy pliki cookies. Jeśli uważasz, że to jest ok, po prostu kliknij "Akceptuj wszystko". Możesz też wybrać, jakie chcesz ciasteczka, klikając "Ustawienia".
Przeczytaj naszą politykę cookie