Wstęp – opisanie problemu
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.
Przygotowałem już gotowe rozwiązanie, które możesz pobrać z GitHuba: https://github.com/AdamJachocki/ResourceBasedAuth
Najpierw przyjrzyjmy się mu.
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:
public class TodoItemAuthHandler : AuthorizationHandler<TodoItemOwnerOrSuperAdminRequirement, TodoItem>
{
private readonly LoggedUserProvider _loggedUserProvider;
public TodoItemAuthHandler(LoggedUserProvider loggedUserProvider)
{
_loggedUserProvider = loggedUserProvider;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
TodoItemOwnerOrSuperAdminRequirement requirement,
TodoItem resource)
{
var loggedUser = await _loggedUserProvider.GetLoggedUser();
if(resource.OwnerId == loggedUser.Id)
context.Succeed(requirement);
else
context.Fail();
}
}
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
:
private readonly IAuthorizationService _authService;
public SecuredTodoItemService(IAuthorizationService authService)
{
_authService = authService;
}
I spójrz na przykładowe użycie:
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.
Obrazek z artykułu: Tkanina plik wektorowy utworzone przez storyset – pl.freepik.com