Własny filtr akcji ASP.NET MVC – autoryzacja z wykorzystaniem logiki biznesowej

W poprzednim artykule zrobiłem teoretyczne wprowadzenie do filtrów akcji w ASP.NET MVC. Dzisiaj przyszła pora na przykład bazujący na realnej potrzebie.

Jaka potrzeba?

Przy tworzeniu aplikacji ASP.NET MVC przyjęło się, że dla każdej encji domenowej mamy kontroler, który zawiera akcje powiązane tylko z daną encją (na przykład: UsersController, ProductsController itp.). Następnie kontroler zawiera szereg akcji dla pojedynczej encji, gdzie akcja na ogół posiada parametr Id określający klucz główny encji (nawet domyślna reguła routing w ASP.NET MVC to określa). Mamy więc akcje w stylu:

public class InvoicesController : Controller
{
public ActionResult Create(int id)
{
return View();
}
public ActionResult Edit(int id)
{
return View();
}
}

Domyślny mechanizm autoryzacji w ASP.NET MVC jest fajny, gdy potrzebujemy określić uprawnienia do akcji na poziomie ról lub użytkowników. Niestety nie sprawdza się, gdy chcemy zabronić dostępu do poszczególnych encji. Z własnego doświadczenia wiem, że czasami zapominamy o tak ważnym aspekcie, przez co później na różnych portalach w stylu Niebezpiecznik można spotkać się z informacją, że użytkownik mógł ściągnąć wszystkie faktury z systemu poprzez zmianę wartości parametru Id w adresie.

Jakie mamy rozwiązania?

Rozwiązań tego problemu mamy kilka. Pierwszym i najprostszym jest dodanie w akcji kodu, który w pierwszej kolejności sprawdzi, czy aktualnie zalogowany użytkownik ma uprawnienia do danego obiektu – i jeśli ma, to dopiero wtedy wyświetli odpowiedni widok. Niestety, takie rozwiązanie prowadzi do tego, że każda akcja w kontrolerze na początku ma identyczny fragment kodu, który jest za każdym razem kopiowany. Jak pisałem w poprzednim artykule, takie rozwiązane na dłuższą metę jest problematyczne. Ale na szczęście z pomocą przychodzą tytułowe filtry akcji, a w naszym przypadku filtr uwierzytelniający.

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

Założenia

Dla naszego przykładu przyjąłem kilka założeń, które prawdopodobnie w większości aplikacji będą łatwe do spełnienia.

  • Wszystkie encje dziedziczą po bazowej klasie, która zawiera w sobie klucz główny – w naszym przypadku będzie to właściwość Id typu int.
  • Dla każdej encji mamy klasę logiki biznesowej np. UserLogic, InvoiceLogic.
  • W aplikacji mamy interfejs dla logiki (w przykładzie IAuthorizeLogic), który zawiera metodę sprawdzającą, czy przekazany użytkownik ma uprawnienia do obiektu o przekazanym Id. Klasy logiki biznesowej implementują wspomniany interfejs.
  • Mamy klasę logiki biznesowej, która umożliwia pobranie użytkownika na podstawie jego nazwy np. UserLogic.GetByName.

Sposób działania?

W większości przypadków filtrem będziemy dekorować cały kontroler, aby nie trzeba było go ustawiać w poszczególnych akcjach (oczywiście można dekorować tylko akcje). Filtr będzie pomijał swoje działanie w przypadku, gdy w danych przesyłanych do przeglądarki nie będzie parametru Id.

Filtr będzie wymagał, aby w jego użyciu określić typ logiki biznesowej, jaka ma zostać użyta do sprawdzenia uprawnień. Na podstawie typu pobierze instancję obiektu z kontenera dependency injection (w przykładzie Unity). Filtr pobierze instancje aktualnie zalogowanego użytkownika, a następnie wywoła metodę HasAccess z wspomnianego wcześniej interfejsu IAuthorizeLogic. W przypadku gdy coś jest nie tak (na przykład użytkownik nie ma uprawnień), nastąpi przekierowanie do strony logowania.

Implementacja własnego filtra akcji ASP.NET MVC

Cały działający przykład jest dostępny na githubie, skąd można go ściągnąć i sprawdzić działanie filtra. Poniżej przedstawiłem opis ciekawszych fragmentów kodu.

Na potrzeby przykładu wykorzystana została klasa faktur (Invoice), która we właściwości User ma informacje, dla jakiego użytkownika została wystawiona faktura. Dodatkowo encja użytkownika posiada właściwość IsAdmin, która określa, czy użytkownik jest administratorem, czy nie. Założenie jest takie, że fakturę może zobaczyć użytkownik, dla którego została wystawiona faktura, oraz administrator aplikacji. Dlatego metoda HasAccess w klasie InvoiceLogic wygląda następująco:

public bool HasAccess(ApplicationUser user, Invoice entity)
{
if(user == null)
{
throw new ArgumentNullException("user");
}
if(entity == null)
{
throw new ArgumentNullException("entity");
}
if(entity.UserId == user.Id)
{
return true;
}
if (user.IsAdmin)
{
return true;
}
return false;
}

Sam kontroler dla faktur mógłby wyglądać następująco (pominąłem większość nieistotnego dla przykładu kodu):

[LogicAuthorize(typeof(IInvoiceLogic))]
public class InvoicesController : Controller
{
private IInvoiceLogic _invoiceLogic;
public InvoicesController(IInvoiceLogic invoiceLogic)
{
_invoiceLogic = invoiceLogic;
}
public ActionResult Index()
{
return View();
}
public ActionResult Edit(int id)
{
return View(_invoiceLogic.GetById(id));
}
[HttpPost]
public ActionResult Edit(Invoice invoice)
{
return View(invoice);
}
}

Filtr LogicAuthorize

Sam filtr to zwykła klasa LogicAuthorizeAttribute, która implementuje interfejs IAuthorizationFiler (dla ułatwienia warto dziedziczyć po klasie FilterAttribute). W ramach naszej klasy musimy zaimplementować metodę OnAuthorization, która przyjmuje jeden parametr AuthorizationContext – tam znajdziemy szereg informacji, między innymi parametry przekazywane do akcji, nazwę akcji, kontrolera itp. Metoda będzie składać się z trzech kroków, które zostały rozbite na mniejsze metody:

  • IsAuthenticated – sprawdza, czy użytkownik jest zalogowany:
private bool IsAuthenticated(AuthorizationContext filterContext)
{
if (filterContext.RequestContext.HttpContext.User.Identity.IsAuthenticated)
{
return true;
}
return false;
}
  • GetId – zwraca wartość parametru Id – metoda wyszukuje Id w danych z routingu oraz z QueryString – tutaj można też dodać przeszukiwanie innych obszarów żądania np. dane formularza czy pliki cookie:
private int? GetId(AuthorizationContext filterContext)
{
string idValue = string.Empty;
if (filterContext.RouteData.Values.Any(d => d.Key == "id"))
{
idValue = filterContext.RouteData.Values.FirstOrDefault(d => d.Key == "id").Value.ToString();
}
if (string.IsNullOrEmpty(idValue))
{
if (filterContext.HttpContext.Request.QueryString.AllKeys.Any(k => k == "id"))
{
idValue = filterContext.HttpContext.Request.QueryString["id"];
}
}
if (string.IsNullOrEmpty(idValue))
{
return null;
}
int id = 0;
if (int.TryParse(idValue, out id) == false)
{
throw new ArgumentException("The id parameter in request has wrong value.");
}
return id;
}
  • HasAccess – metoda sprawdza, czy zalogowany użytkownik ma dostęp do rekordu o przekazanym Id. Wykorzystywana jest tutaj logika przekazana jako parametr do filtra (rozwiązanie jej jest robione z wykorzystaniem kontenera Unity), podobnie jest z logiką biznesową użytkownika:
private bool HasAccess(AuthorizationContext filterContext, int? id)
{
if (id.HasValue == false)
{
return false;
}
var user = UserLogic.GetByName(filterContext.RequestContext.HttpContext.User.Identity.Name);
if (user == null)
{
HandleUnauthorizedRequest(filterContext);
return true;
}
if (AuthorizeLogic.HasAccess(user, id.Value))
{
return true;
}
return false;
}

Sama metoda OnAuthorization wygląda następująco:

public void OnAuthorization(AuthorizationContext filterContext)
{
if (IsAuthenticated(filterContext) == false)
{
HandleUnauthorizedRequest(filterContext);
return;
}
int? id = GetId(filterContext);
if (id.HasValue == false)
{
return;
}
if (HasAccess(filterContext, id))
{
return;
}
HandleUnauthorizedRequest(filterContext);
}

W kilku miejscach używana jest metoda HandleAnautorizedRequest, która powoduje przekierowanie do strony logowania. Kod metody wygląda następująco:

protected void HandleUnauthorizedRequest(AuthorizationContext filterContext)
{
filterContext.Result = new RedirectToRouteResult(
new RouteValueDictionary(
new
{
controller = "Account",
action = "Login"
})
);
}

Testowanie

Przykład na githubie korzysta z Entity Framework i za pomocą metody Seed dodaje dane testowe do bazy, aby można było przetestować filtr na działającej aplikacji. Aplikacja do bazy dodaje trzech użytkowników (wszyscy mają to same hasło – 123456):

  • admin – administrator – użytkownik z ustawioną flagą IsAdmin
  • user1 – użytkownik, który nie będzie miał dostępu do faktury
  • user2 – użytkownik, dla którego została dodana testowa faktura.

Metoda Seed dodaje również jedną fakturę (Id 1) dla użytkownika user2.

Aby przetestować przykład, wystarczy uruchomić aplikację, zalogować się na jednego z użytkowników i przejść pod adres /invoices/edit/1.

Co dalej?

Na podstawie powyższego przykładu widać, jak bardzo filtry akcji przydają się do minimalizowania kodu w kontrolerach relatywnie małym nakładem pracy.

Powyższy kod bazuje na bardzo prostym założeniu, że mamy prostą logikę dostępu – albo mamy dostęp, albo nie. Czasami może to być za mało, więc można ten mechanizm rozbudować o wsparcie na przykład dla typów enum, które będą określały bardziej rozbudowane uprawnienia, np. tylko odczyt lub zapis. Ale to może kiedyś w przyszłości.

Jeszcze raz zachęcam do pobrania kodu z githuba i zostawienia komentarza.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.