Przykładowa implementacja systemów kontroli dostępu
W tej lekcji, korzystając z naszej aplikacji fiszkowej, zaproponuję przykładową implementację dwóch z trzech ostatnio omówionych metod kontroli dostępu. Przed nami znacznie mniej teorii i znacznie więcej praktyki.
Wideo towarzyszące
Dostęp dla zalogowanych użytkowników
Implementację ograniczenia dostępu do części aplikacji wyłącznie dla zalogowanych użytkowników mogłeś przeanalizować samodzielnie podczas ostatniej lekcji. Jej centralnym elementem jest funkcja middleware login_required.
| |
Jak pamiętamy z poprzednich lekcji, funkcje middleware mogą albo obsłużyć żądanie i zakończyć jego przetwarzanie, albo przekazać je do kolejnych handlerów w łańcuchu. Dlatego żądania o pliki statyczne nigdy nie trafiają do naszych logów morgan - ponieważ middleware express.static obsługuje te żądania w całości i nie przekazuje ich dalej w dół łańcucha kolejnych handlerów.
Powyższe middleware login_required działa trochę podobnie. W linii 100 sprawdza ono, czy do obecnego żądania jest przypięty jakiś użytkownik i jeżeli tak, w linii 104 daje znać Express, że żądanie powinno być dalej przetwarzane, tym samym dając szansę handlerom odpowiedniej ścieżki na przygotowanie odpowiedzi w formie strony HTML. Jeżeli do obecnego żądania nie jest przypisany żaden użytkownik systemu, middleware odsyła do przeglądarki przekierowanie do ścieżki zawierającej formularz logowania. Dodatkowo do ścieżki jest dodawany parametr query next, ale o nim za moment.
Jeżeli nie jesteś pewien, skąd bierze się obiekt przedstawiający użytkownika systemu w zmiennej res.locals.user, to przypominam o middleware odpowiedzialnym za obsługę sesji:
| |
Poza tym centralnym elementem kontroli dostępu, najwięcej pracy wymagało dodanie funkcjonalności przekierowywania z ekranu logowania z powrotem do strony, do której użytkownik próbował się dostać. Jest to zrealizowane w handlerze obsługującym logowanie użytkownika poprzez parametr query next będący częścią URI:
| |
Podkreślone fragmenty pliku są odpowiedzialne za przekierowanie użytkownika pod właściwy adres po zalogowaniu. Jeżeli sobie przypominasz, to middleware login_required ustawiało parametr query next na ścieżkę obecnie przetwarzanego żądania.
Na koniec musieliśmy dopisać middleware login_required przed handlerem dla wszystkich ścieżek, do których chcemy dopuścić tylko zalogowanych użytkowników. Przykładowe dodanie tego middleware wygląda tak:
| |
ReBAC: edycja własnych zasobów
Aby dodać zależność pomiędzy zestawem fiszek a użytkownikiem musimy zmodyfikować jakoś nasze tabele w bazie danych. Ponieważ w tym momencie mówimy o relacji jeden-do-wielu (każdy zestaw fiszek ma autora, użytkownik może być autorem zera lub większej liczby zestawów), najsensowniejszym rozwiązaniem będzie zmiana sposobu przechowywania zestawów fiszek w bazie danych i dodanie do nich obowiązkowej relacji do użytkownika jako autora.
Zmiana tego rodzaju jest zmianą psującą działanie aplikacji. Samo dodanie dodatkowej kolumny do zapytań SQL spowoduje, że baza zacznie nam zgłaszać błędy. Na naszym etapie rozwoju aplikacji nie ma to znaczenia, ponieważ nie mamy nigdzie działających systemów produkcyjnych ani nawet długo działających systemów testowych. Możemy po prostu skasować starą bazę danych i wygenerować nowe dane testowe. W innym przypadku musielibyśmy przygotować tzw. skrypty migracyjne, które modyfikowałyby istniejące bazy danych tak, aby działały one z nową wersją naszej aplikacji.
| |
W linii 11 wprowadzamy nowe pole przechowujące identyfikator użytkownika będącego autorem. Większość wynikających z tego zmian sprowadza się do dodania tej informacji do przygotowanych zapytań do bazy danych w obiekcie db_ops tak, żeby informacja o autorze trafiła do reszty aplikacji.
Ponieważ relacja do użytkownika jest wymagana, obowiązkowo musi się także zmienić sygnatura funkcji dodającej nowy zestaw fiszek:
| |
Dygresja: refactoring modelu flashcards.js
Jeżeli uważnie przejrzałxś zmiany w kodzie podczas ostatniej lekcji, to zauważyłxś, że w modelu flashcards.js zmieniło się sporo, ale były to głównie korekty nazewnictwa. Dawne “kategorie” stały się “zestawami fiszek”, a identyfikatory zostały nazwane bardziej adekwatnie do ich funkcji - identyfikator tekstowy jest teraz nazwany slug. Slug jest angielskim terminem wywodzącym się z druku prasy i w kontekście aplikacji internetowych oznacza krótki kawałek tekstu identyfikujący dany artykuł. Slug w praktyce to zazwyczaj fragment tytułu zakodowany w sposób zdatny do bycia częścią ścieżki HTTP - zupełnie jak u nas.
Tego typu zmiany, poprawki w kodzie niezmieniające funkcjonalności aplikacji, ale mające na celu poprawę czytelności, logiczną reorganizację czy poprawę wydajności nazywamy refactoringiem. Refactoring jest potrzebnym procesem w rozwoju aplikacji, ale wymaga trochę pracy, szczególnie jeżeli wpływa na wiele plików w aplikacji. Z drugiej strony poświęcamy czas na zmiany, które intencjonalnie nie mają żadnego wpływu na działanie aplikacji - z punktu widzenia klienta płacącego za rozwój aplikacji, marnujemy jego pieniądze. Z jednej strony rozumiem taki punkt widzenia, można utknąć w wiecznej pętli refactoringu w poszukiwaniu “idealnego” kodu, podczas gdy aplikacja pod względem funkcjonalności stoi w miejscu. Podjęcie dobrej decyzji o refactoringu wymaga odrobiny wprawy - w tym wypadku zdecydowałem się na podjęcie wysiłku, bo rozróżniania identyfikatora tekstowego od identyfikatora numerycznego już mnie momentami myliło. Najlepsza rada jaką mogę dać w temacie, kiedy się brać za refactoring, to ufać swojej intuicji.
Naprawienie skryptu generującego bazę danych
Ponieważ teraz stworzenie zestawu fiszek wymaga konta użytkownika, musimy zmodyfikować skrypt wypełniający bazę danymi testowymi tak, aby stworzył testowe konta użytkowników zanim doda nasze przykładowe fiszki.
| |
- W linii 30 i 31 tworzymy konta dla administratora oraz konto testowe z loginem “student”.
- W linii 34 podajemy obiekt reprezentujący nowo stworzone konto użytkownika jako trzeci argument do funkcji tworzącej zestaw fiszek.
Umożliwienie edycji zestawu fiszek wyłącznie autorowi
W celu weryfikacji uprawnień do edycji zestawu fiszek stworzymy funkcję, która zwróci nam informację, czy podany użytkownik może edytować wybrany zestaw fiszek. Aby powiązać funkcję z obiektem dodamy ją jako pole obiektu reprezentującego zestaw fiszek:
| |
- W linii 91 sprawdzamy, czy podany obiekt użytkownika reprezentuje autora danego zestawu fiszek. Warto też przygotować się na możliwość podania jako argumentu wartości
null, ponieważ w ten sposób komunikujemy do pozostałych części aplikacji niezalogowanego, anonimowego użytkownika. - W linii 82 przypisujemy funkcję
cardsetEditableBydo obiektu jako metodęeditableBy. Dzięki temu pozostałe części aplikacji będą mogły wywołać ją bezpośrednio na obiekcie zestawu fiszek.
Dodatkowo możemy chcieć też wyeksportować z modelu funkcję, która sprawdzi możliwość edycji zestawu fiszek na podstawie slug tego zestawu. W ten sposób możemy w innych częściach aplikacji sprawdzić możliwość edycji bez pobierania całości zestawu kart łącznie z powiązanymi kartami. Mała i być może zbędna optymalizacja, ale nikt nam tego nie zabroni:
| |
Dlaczego korzystamy tu znów z funkcji cardsetEditableBy zamiast przepisać na nowo jej logikę? Czy to nie byłoby bardziej efektywne niż wywoływanie funkcji na obiekcie? Tak! Z drugiej strony dzięki użyciu dokładnie tej samej funkcji upewniam się, że logika jest zdefiniowana tylko w jednym miejscu. Jeżeli kiedykolwiek będę chciał tę logikę zmienić, nie będę musiał pamiętać o zmienianiu jej we wszystkich miejscach, gdzie została zdefiniowana.
Obsługa uprawnień po stronie serwera
Teraz kiedy mamy możliwość potwierdzenia, czy obecny użytkownik może edytować zestawy fiszek, możemy dodać odpowiednią logikę w naszych handlerach żądań HTTP:
| |
Nie są to wszystkie użycia nowych funkcji, jeżeli chcemy obwarować dostęp do edycji zestawów dla autorów, ale pokazują one podstawowy mechanizm.
Zwróć uwagę, że w przypadku negatywnej weryfikacji praw do edycji odpowiadamy statusem HTTP 401 Unauthorized.
Ukrywanie linków do edycji dla nieuprawnionych użytkowników
Ponieważ w widokach mamy dostęp do wszystkich obiektów przekazanych do funkcji render oraz tych dodanych wcześniej jako właściwości do obiektu res.locals, w naszych widokach możemy bezpośrednio odnosić się do zmiennej user przedstawiającej obecnie zalogowanego użytkownika. Jeżeli do widoku przekażemy też obiekt reprezentujący zestaw fiszek wraz z przypiętymi do niego metodami, to możemy z tych metod korzystać bezpośrednio w widoku. W widoku dla pojedynczego zestawu fiszek wykorzystanie tych technik może wyglądać w ten sposób:
| |
Celem organizacji kodu w dotychczasowy sposób jest sprawienie, że reszta kodu będzie czytelna. Mam nadzieję, że patrząc na kod widoku zgodzisz się, że udało się cel zrealizować.
ABAC: uprawnienia administratora
Jeżeli chcemy nadać specjalne uprawnienia jakiemuś użytkownikowi, musimy gdzieś umieścić informację na ten temat. To oznacza kolejną modyfikację bazy danych. Zanim jednak do tego przejdziemy, zastanówmy się, jakie mamy możliwości:
- Jedną z opcji jest dodanie do naszej aplikacji systemu ról użytkowników. W ten sposób przypisanie użytkownika do danej roli nadawałoby mu wszelkie uprawnienia przypisane do tejże roli. Wiązałoby się to z wprowadzeniem nowego modelu ról, powiązania go z użytkownikami, a także koniecznością dodania funkcjonalności zarządzania rolami: przypisywania do nich użytkowników, zarządzania uprawnieniami, etc. Zaletą tego podejścia jest to, że dałoby się to rozwinąć zupełnie w separacji od modelu użytkownika, natomiast wadą jest sztywność tak zaimplementowanego systemu.
- Alternatywnie możemy zmodyfikować model użytkownika tak, żeby móc dodawać do niego arbitralne właściwości a następnie w kodzie aplikacji uzależniać logikę działania systemu od wartości tych właściwości. Jest to mniej usystematyzowane podejście, które może z czasem być trudniejsze w utrzymaniu, ale będzie też bardziej elastyczne jeżeli będziemy chcieli dodawać nowe funkcjonalności związane z uprawnieniami użytkowników.
Tutaj zaimplementujemy drugie rozwiązanie, ale możesz potraktować tę lekcję jako okazję do zaimplementowania pierwszego podejścia i porównania wyników pod względem poziomu skomplikowania i ilości wymaganej pracy.
Arbitralne właściwości użytkownika
Jeżeli chcemy przypisać do użytkownika dowolne atrybuty, możemy wykorzystać fakt, że programujemy w języku JavaScript i mamy wbudowane, natywne wsparcie dla notacji JSON. Dzięki temu możemy do tabeli użytkowników dodać dodatkowe pole tekstowe, które będzie przechowywać zserializowany (tj. przetworzony na ciąg znaków) obiekt JSON, który będzie zawierać rzeczone atrybuty. Nie będzie to bardzo efektywne pod względem wydajności, ale na pewno będzie elastyczne.
Przyjmując powyższe założenia, jeżeli postanowilibyśmy dodać dla użytkownika admin właściwość “is_admin=true” w bazie danych byłoby to przechowane jako “{"is_admin":true}”.
Wyciągając informacje z bazy danych musimy przetworzyć przechowany string na obiekt JavaScript, do czego służy funkcja JSON.parse. Moglibyśmy tak uzyskany obiekt umieścić w obiekcie użytkownika w polu attributes, wtedy obiekt reprezentujący użytkownika w aplikacji miałby taką postać:
{
"id": 1,
"username": "admin",
"created_at": 123456789,
"attributes": {
"is_admin": true
}
}Co nie jest złą implementacją, ale odwoływanie się do właściwości obiektu przez pole o nazwie “właściwości” wydaje się zapętloną logiką. Możemy chcieć zatem przetworzyć dane wyciągnięte z bazy danych tak, aby obiekt reprezentujący użytkownika miał bardziej płaską strukturę:
{
"id": 1,
"username": "admin",
"created_at": 123456789,
"is_admin": true
}Jeżeli się na to zdecydujemy, warto jednak obwarować dopuszczalne nazwy dodawanych do użytkownika atrybutów, tak aby nie można było nadpisać tych, które już są w bazie danych, np. id czy username.
Biorąc to wszystko pod uwagę przeanalizuj poniższy kod skupiając się na podkreślonych fragmentach:
| |
Funkcja addAttribute najpierw upewnia się, że podane argumenty są odpowiednich typów i że nazwa atrybutu nie widnieje na liście zakazanych nazw. Jeżeli argumenty przejdą poprawnie sprawdzenia, pobieramy z bazy danych string reprezentujący aktualne atrybuty powiązane z użytkownikiem i przetwarzamy go na obiekt JSON. Jeżeli użytkownik nie ma żadnych przypisanych atrybutów, w bazie danych będzie wartość null, więc w linii 113 obsługujemy taki przypadek tworząc nowy, pusty obiekt. Następnie do obiektu dodajemy podaną jako argument wartość i zapisujemy z powrotem atrybuty w bazie danych.
W przypadku tej funkcji przyjęliśmy konwencję, że funkcja zwróci null w przypadku sukcesu, albo string z treścią błędu w przypadku niepowodzenia.
W celu przeszukania zestawu zakazanych nazw lub dozwolonych typów korzystamy z klasy Set dostępnej w JavaScript, która jest implementacją zbiorów. Wybrałem tę klasę, ponieważ pozwala na potencjalnie szybsze znalezienie poszukiwanych elementów niż tablica (Array) i nie przechowuje dodatkowych danych powiązanych z kluczami, tak jak obiekt (Object). Jeżeli jesteś zainteresowanx, polecam przeczytanie dokumentacji klasy Set.
To co powoduje, że reszta aplikacji otrzymuje płaski obiekt reprezentujący użytkownika wraz z przypisanymi mu atrybutami to implementacja funkcji getUser, w której najpierw pobieramy z bazy danych informacje o użytkowniku i przypisujemy je do osobnych zmiennych, których nazwy odpowiadają nazwom właściwości obiektu zwróconego z bazy danych. Następnie używając operatora spread tworzymy nowy obiekt zawierający dodatkowe właściwości.
Implementacja może na pierwszy rzut oka być nieczytelna. To kwestia obycia ze składnią JavaScript i zrozumienia działania użytych operatorów. Przeprowadźmy symulację działania funkcji krok po kroku używając REPL node:
$ node
Welcome to Node.js v23.11.0.
Type ".help" for more information.
> let user = {
... "id": 1,
... "username": "admin",
... "created_at": 123456789,
... "attributes": '{ "is_admin": true }',
... };
undefined
> user
{
id: 1,
username: 'admin',
created_at: 123456789,
attributes: '{ "is_admin": true }'
}
> let {id, username, created_at, attributes} = user;
undefined
> id
1
> username
'admin'
> attributes
'{ "is_admin": true }'
> let output = {};
undefined
> let parsed_attributes = JSON.parse(attributes);
undefined
> parsed_attributes
{ is_admin: true }
> output = {
... id,
... username,
... created_at,
... ...JSON.parse(attributes),
... };
{ id: 1, username: 'admin', created_at: 123456789, is_admin: true }
>Zaraz, zaraz. Ale co się stanie, jeżeli użytkownik nie ma przypisanych żadnych atrybutów? W bazie danych w polu attributes będzie przechowana wartość null. Prześledźmy, jak zachowa się wtedy JavaScript krok po kroku:
$ node
Welcome to Node.js v23.11.0.
Type ".help" for more information.
> let user = { id: 2, username: "bob", created_at: 2345678901, attributes: null };
undefined
> user
{ id: 2, username: 'bob', created_at: 2345678901, attributes: null }
> let {id, username, created_at, attributes } = user;
undefined
> attributes
null
> let parsed_attributes = JSON.parse(attributes)
undefined
> parsed_attributes
null
> let output = {};
undefined
> output = {
... id,
... username,
... created_at,
... ...parsed_attributes
... }
{ id: 2, username: 'bob', created_at: 2345678901 }
> JSON.parse(null)
null
> { id, ...null }
{ id: 2 }
>Program zadziała tak jak się tego spodziewamy, ponieważ funkcja JSON.parse dla parametru null zwróci również null, a operator spread dla wartości null nie dodaje żadnych właściwości do tworzonego obiektu.
Uprawnienia administratora
Po zaimplementowaniu dodawania do kont użytkowników arbitralnych właściwości możemy teraz odpowiednio oznaczyć nasze konto administratora:
| |
Ostatnim krokiem jest modyfikacja funkcji pozwalającej na edytowanie zestawów fiszek. Dzięki temu, że dotychczasowe zmiany dodają do odpowiedniego konta atrybut, który jest potem reprezentowany przez właściwość na obiekcie reprezentującym użytkownika, implementacja dodania uprawnień jest trywialna:
| |
Teraz możemy spróbować zalogować się na konto administratora i sprawdzić, czy wszystko działa tak, jak się tego spodziewaliśmy.
Podsumowanie
Przeszliśmy przez implementację kilku rodzajów systemów kontroli dostępu. Na tym etapie rozwoju aplikacji te systemy nie są może proste, ale nadal możliwe do prześledzenia i zrozumienia. Niestety wraz z rozwojem skomplikowania całego systemu, pilnowanie spójności systemów kontroli dostępu staje się trudniejsze. Właśnie dlatego “Broken Access Control” jest na szczycie listy zagrożeń aplikacji internetowych według OWASP TOP 10 z 2025 roku.
Niestety nie zapowiada się, żeby pojawiła się jakaś droga na skróty jeżeli chodzi o zabezpieczanie systemów informatycznych. Dlatego warto poświęcić czas na nauczenie się technik związanych z bezpieczeństwem, nabranie w nich biegłości i stałe udoskonalanie się w tym zakresie.
Zadania praktyczne
Stwórz nową “rolę” moderatora, który będzie miał uprawnienia do edycji zestawów fiszek. Następnie dodaj w ramach skryptu wypełniającego bazę danych stwórz konto moderatora bez uprawnień administratorskich. Na koniec zastanów się, czy pozostawić uprawnienia do edycji zestawów fiszek dla administratora, czy lepiej zostawić je wyłącznie moderatorom, ale nadać administratorowi również “rolę” moderatora. Co przemawia za jednym bądź drugim rozwiązaniem?