Sesje użytkownika oraz autentykacja
Dziś przyjrzymy się najbardziej podstawowemu modelowi autentykacji użytkowników przy pomocy loginu i hasła oraz wykorzystamy cookies do przechowania informacji o jego tożsamości.
Wideo towarzyszące
Sesja użytkownika
Jeżeli chcemy przechowywać jakieś dane o użytkowniku po stronie serwera nie ujawniając ich, możemy utworzyć na serwerze tak zwany obiekt sesji. Obiekt sesji ma identyfikator, który odeślemy użytkownikowi, a poza tym może zawierać dowolne dane. Pierwszą informacją którą umieścimy w sesji użytkownika będzie informacja o jego tożsamości, ale o tym za chwilę.
Identyfikator sesji
Ponieważ obiekt sesji będzie powiązany z tożsamością użytkownika musimy w miarę możliwości uchronić jego identyfikator przed ujawnieniem osobom trzecim oraz zabezpieczyć przed zgadnięciem. Gdyby sesje były numerowane kolejno, użytkownik mógłby podmienić swój identyfikator na jeden numer większy lub mniejszy tym samym podszywając się po kogoś innego. Dlatego identyfikator sesji będzie dużą, losowo generowaną liczbą. Organizacja OWASP rekomenduje 64 bity entropii dla identyfikatora sesji, co dla losowych liczb całkowitych oznacza po prostu liczbę 64-bitową.
Liczby 64-bitowe w JavaScript
Niestety domyślny sposób reprezentacji liczby w JavaScript jest oparty o liczby zmiennoprzecinkowe i pozwala na dokładne reprezentowanie liczb całkowitych na maksymalnie 53 bitach. Żeby zrozumieć o czym mowa, możemy uruchomić w terminalu REPL JavaScript poleceniem ’node’ i wykonajmy parę eksperymentów:
> node
Welcome to Node.js v22.22.0.
Type ".help" for more information.
> ((1n << 64n) - 1n); // maksymalna wartość zapisywalna na 64 bitach
18446744073709551615n
> ((1n << 64n) - 1n).toString(16); // wszystkie 64 bity ustawione
'ffffffffffffffff'
> Number.MAX_SAFE_INTEGER; // maksymalna liczba którą bezpiecznie możemy zapisać w typie Number
9007199254740991
> (Number.MAX_SAFE_INTEGER + 1) === (Number.MAX_SAFE_INTEGER + 2) // powyżej zaczyna spadać precyzja
trueW związku z powyższym musimy albo użyć typu BigInt do zapisu identyfikatora sesji, albo zignorować rekomendację OWASP i zdecydować się na mniejszy zakres potencjalnych identyfikatorów. Ponieważ zastosowanie BigInt będzie bardziej problematyczne, a to są materiały edukacyjne, zdecydujemy się na trudniejszą opcję i zobaczymy, jak bardzo to skomplikuje nasz kod. Jeżeli dotrzemy do ściany, zawsze możemy wrócić do łatwiejszej opcji.
Jeden z problemów z BigInt polega na tym, że funkcje związane z serializacją danych do formatu JSON nie radzą sobie ze zmiennymi typu BigInt. Jest to o tyle kłopot, że np. cookie-parser korzysta z funkcji JSON.parse oraz JSON.stringify w swojej implementacji, więc będziemy musieli być ostrożni w przekazywaniu danych poza nasz kod.
> node
Welcome to Node.js v22.22.0.
Type ".help" for more information.
> JSON.stringify({ foo: 1n });
Uncaught TypeError: Do not know how to serialize a BigInt
at JSON.stringify (<anonymous>)Drugi problem polega na tym, że moduł node:sqlite domyślnie czyta liczby całkowite z bazy danych jako typ Number. Możemy to obejść dodając do wywołania konstruktora klasy DatabaseSync. Natomiast wtedy każda liczba całkowita jest czytana jako BigInt i będziemy musieli to odpowiednio obsłużyć.
Implementacja modelu sesji użytkownika
| |
Przeanalizuj powyższy kod, wykorzystujemy w nim poznane wcześniej techniki. Chciałem podkreślić i wyjaśnić tylko kilka rzeczy:
- w linii 5 konfigurujemy moduł
node:sqlitedo czytania liczb całkowitych jakoBigInt. - w linii 31 generujemy losową liczbę 64-bitową z wykorzystaniem modułu
node:crypto - w linii 72 wykorzystujemy funkcję której jeszcze nie omawialiśmy, ale dojdziemy do tego kiedy będziemy mówić o asynchroniczności w JavaScript. Na chwilę obecną wyjaśnię tylko, że
setImmediateodracza wykonanie funkcji podanej jako argument na później, nie blokując przetwarzania obecnego żądania HTTP.
Zadanie praktyczne
Zmień powyższy kod tak aby logowanie informacji o sesji odbywało się w ramach przetwarzania żądania i sprawdź, czy wpływa to na czas odpowiedzi serwera. (Podpowiedź: logi morgan)
Autentykacja hasłem
Autentykacja to proces ustalania tożsamości użytkownika. Nic więcej. Pod koniec procesu autentykacji chcemy mieć pewność, że osoba korzystająca z naszej aplikacji jest tą samą osobą, która założyła w niej konto wcześniej. Przyjmiemy model autentykacji w oparciu o unikatowy identyfikator użytkownika (w skrócie login) i hasło. Są inne rozwiązania i warto poszerzyć w tym zakresie wiedzę, ale nadal najpopularniejszym rozwiązaniem pozostaje para login+hasło, więc zacznijmy od poznania podstaw.
(Nie)przechowywanie haseł na serwerze
Po pierwsze, nie przechowujemy haseł na serwerze. Po drugie i po trzecie nie przechowujemy haseł na serwerze. W bazie danych przechowujemy hash albo skrót hasła. Nigdy hasło bezpośrednio. Wynika to ze stosowania dobrych praktyk bezpieczeństwa. Gdybyśmy przechowywali w bazie danych hasło:
- Mógłby je podejrzeć ciekawski i nieetyczny współpracownik
- W wypadku wykradnięcia bazy danych atakujący otrzymują na tacy wszystkie hasła naszych użytkowników
- Można by wykraść hasła z dowolnego backupu bazy danych, co poszerza grupę potencjalnych podejrzanych o pracowników firmy przechowującej nasze backupy
- Nawet pomijając potencjalnych umyślnych atakujących, zdarzają się przypadki zwykłej niekompetencji: źle skonfigurowane serwery przechowujące backupy, wrzucenie backupu bazy danych na publiczny serwer przez dewelopera w celu przeniesienia na inną maszynę, sprzedaż używanej i niewyczyszczonej poprawnie maszyny deweloperskiej na rynku wtórnym.
Przechowywanie skrótów na serwerze
Skróty (albo hashe) haseł uzyskujemy tak zwanymi funkcjami skrótu albo, bardziej potocznie, funkcjami haszującymi. Omawiany na ostatniej lekcji algorytm HMAC też korzystał z funkcji skrótu. Co do zasady funkcje skrótu sprowadzają się do zamienienia dowolnych danych wejściowych na jedną, zazwyczaj długą liczbę. Popularnych funkcji skrótu jest dużo, np. CRC, md5, SHA-1, bcrypt, itd. Każda z nich ma inne właściwości i trochę inne zastosowania.
Skróty haseł moglibyśmy teoretycznie liczyć dowolną funkcją skrótu, ale powinniśmy wybrać mądrze, albo przynajmniej polegając na mądrości ludzi, którzy się na bezpieczeństwie znają. TL;DR jest takie, że OWASP rekomenduje algorytm argon2id i dlatego spróbujemy go wykorzystać w naszej aplikacji.
Jakie właściwości powinna mieć funkcja skrótu, żeby ją zastosować do autentykacji użytkownika? Wbrew intuicji powinna być możliwie wolna i pożerać stosunkowo dużo zasobów komputera. Dlaczego? Ponieważ cały czas musimy mieć z tyłu głowy fakt, że nasza baza danych albo jej backup może kiedyś trafić w niepowołane ręce. Wtedy atakujący mając prawidłowe hashe haseł mogą przy pomocy ataku słownikowego albo brute force zgadnąć hasła użytkowników. Muszą znać tylko użytą przez nas funkcję skrótu. Jest to informacja nietrudna do pozyskania, nawet bez dostępu do kodu aplikacji. Wystarczy że atakujący utworzą wcześniej konto ze znanym sobie hasłem i na tej podstawie sprawdzą wyniki najpopularniejszych opcji.
Salt, pepper, rainbow tables
Kolejnym sposobem zwiększania bezpieczeństwa przechowywanych w naszej bazie danych skrótów jest wykorzystywanie podczas ich liczenia tzw. salt oraz pepper. Jedno i drugie jest zazwyczaj zbiorem losowych bajtów, które są dodawane do hasła na jakimś etapie haszowania. Różnica między nimi jest następująca:
- Salt jest generowany losowo dla każdego użytkownika i jest przechowywany w bazie danych razem ze skrótem hasła.
- Pepper jest jednym ciągiem znaków dla wszystkich użytkowników, ale jest przechowywany poza bazą danych. Dzięki temu jeżeli nawet komuś wpadnie do rąk backup naszej bazy danych, bez tej dodatkowej wartości atak na hasła użytkownika będzie utrudniony.
Zaraz, jeżeli salt jest przechowywany w bazie danych, to w jaki sposób zwiększa bezpieczeństwo przechowywanych skrótów? Pamiętajmy, że funkcje skrótów są deterministyczne. Dla tych samych danych wejściowych dadzą zawsze ten sam wynik. Jeżeli nie wprowadzimy do procesu odrobiny losowości, jeżeli dwóch użytkowników ustawi sobie to samo hasło, hasze zapisane w bazie danych będą identyczne i będzie można łatwo je wyłowić.
Jeżeli funkcje haszujące dają te same wyniki dla tych samych danych wejściowych, moglibyśmy nawet przygotować listę skrótów wszystkich możliwych kombinacji znaków do określonej długości, a potem porównywać uzyskane wartości ze skrótami zapisanymi w bazie. O ile wydaje się to lekko szalone, o tyle taka technika jest stosowana przez atakujących. Te ogromne zbiory skrótów nazywane są rainbow tables i można nawet znaleźć w Internecie już przeliczone listy dla popularnych funkcji haszujących.
Dodając do hasła salt powodujemy, że trzeba by było przygotować osobną rainbow table dla każdej wartości salt. Jest to możliwe, ale jeżeli użyta przez nas funkcja haszująca jest zasobożerna, to staje się to niepraktyczne. Niepraktyczne w sensie “przeliczenie tego dla haseł o długości do 8 znaków zajmie na obecnie dostępnych komputerach ponad 10 000 lat”.
Jeżeli jeszcze do tego dołożymy pepper, którego atakujący naszą aplikację nie dadzą rady zdobyć, to możemy spać spokojnie jeżeli chodzi o bezpieczeństwo haseł naszych użytkowników.
Model użytkownika
Stworzymy sobie kolejny model, tym razem służący do przechowywania w aplikacji informacji o użytkowniku oraz skrótu jego hasła. Będziemy potrzebowali przynajmniej możliwości stworzenia użytkownika i odnalezienia użytkownika po identyfikatorze, oraz weryfikacji hasła użytkownika.
Wybranie implementacji argon2id
Node.js od wersji 24 udostępnia własne API do implementacji algorytmu argon2id, ale nasza aplikacja jest obecnie rozwijana na Node.js 22. W przyszłości będziemy mogli zmigrować aplikację do nowszej wersji, ale obecnie możemy użyć zewnętrznej implementacji przy pomocy pakietu argon2, zainstalujmy go.
npm install argon2Moduł argon2 zapewnia stosunkowo wygodny interfejs, polecam sprawdzić dokumentację na GitHubie. Autor automatycznie dodaje do każdego hashowanego hasła salt i zapisuje go w wynikowym stringu ze skrótem hasła, co uprości naszą implementację.
Jak wspomniałem wcześniej, jeszcze nie przerobiliśmy tematu asynchroniczności w JavaScript, a argon2 korzysta z tego konceptu. Omówimy sobie to w dużym skrócie za chwilę.
Generacja sekretu pepper
Jeżeli chcemy dodać do naszego pepper, dobrze by było przechować go poza bazą danych. Użyjemy znów zmiennych środowiskowych i zmodyfikujemy nasz generator pliku środowiskowego.
| |
Ponieważ implementacja argon2 pozwala na użycie typu Buffer w którym możemy przechowywać dowolne dane binarne, wygenerujemy sobie ciąg 64 znaków heksadecymalnych i przekształcimy go później na 32 bajtowy bufor danych.
Implementacja modelu
Kiedy wszystko już jest przygotowane możemy przejść przez implementację samego modelu:
| |
W zaznaczonych liniach pojawiają się słowa kluczowe async i await.
Słowo await sprawia, że wykonanie funkcji zostanie w tym miejscu zawieszone aż do czasu uzyskania wyniku wywołania po prawej stronie. W międzyczasie środowisko node może się zająć innymi rzeczami, np. obsługą innych żądań do serwera. Aby móc użyć słowa kluczowego await w funkcji, musi ona być stworzona jako funkcja asynchroniczna, czyli przy jej deklaracji musi pojawić się async function zamiast zwykłego function.
Poza tym szczegółem, na podstawie dotychczasowej zdobytej wiedzy, powinnxś być w stanie zrozumieć resztę implementacji naszego nowego modelu.
Logowanie użytkownika
Zanim zaczniemy implementować cokolwiek dalej, zastanówmy się, czego będziemy potrzebowali? Jakie minimum funkcjonalności potrzebujemy zaimplementować, żeby mówić o kompletnym systemie logowania?
- Rejestracja
- obsługa ścieżek HTTP dla rejestracji (get+post)
- walidacja unikalności loginu
- walidacja wymagań odnośnie hasła (przynajmniej minimalnej długości)
- formularz rejestracji
- Logowanie
- obsługa ścieżek HTTP dla logowania (get+post)
- sprawdzenie poprawności hasła
- formularz logowania
- Indykator zalogowania, coś co pozwoli użytkownikowi rozpoznać, że jest zalogowany
- Ścieżka HTTP umożliwiająca wylogowanie
- Linki nawigacyjne prowadzące do odpowiednich ścieżek
Sporo tego jak na “prostą funkcjonalność”, którą można zamknąć w dwóch słowach, tj. logowanie użytkownika. Niech to będzie przestrogą na przyszłość, że czasami dodanie jednej małej rzeczy do projektu może się okazać dużo większym nakładem pracy. A nawet nie mówimy jeszcze o powiązaniu tego z prawami dostępu do czegokolwiek - o tym na następnej lekcji. A teraz czas się wziąć znów do pracy.
Kontroler logowania
Stworzymy sobie nowy katalog na moduły, które będziemy nazywać kontrolerami. Nazwa kontroler, podobnie jak model, nawiązuje do starego, ale nadal bardzo popularnego wzorca architektury Model-View-Controller. O ile modele odpowiadają za przechowywanie danych i manipulowania nimi, o tyle kontrolery służą do interpretowania komend użytkownika i przekształcania ich na operacje na modelach. W przypadku aplikacji internetowej komendy od użytkownika przychodzą w formie żądań HTTP. Widoki powinny być odpowiedzialne za prezentację danych użytkownikowi, co w przypadku backendowych aplikacji webowych można sprowadzić do generowania dokumentów w formacie HTML, JSON lub dowolnym innym.
W każdym razie stworzymy moduł obsługujący żądania dotyczące autentykacji użytkownika i wykorzystujący nasze modele sesji oraz użytkownika do zapisania odpowiednich informacji w bazie danych.
| |
Jak widać implementacja nie jest krótka, ale nie jest też bardzo skomplikowana. Zwróć uwagę na ponownie pojawiające się słowa kluczowe async i await. Skoro nasze funkcje z modelu użytkownika są asynchroniczne, to jeżeli chcemy z nich skorzystać, również musimy korzystać z tych samych mechanizmów. Dokładniej postaram się to omówić na jednej z następnych lekcji.
Przeanalizuj kod modułu i upewnij się, że rozumiesz jak działa.
W tym module postanowiłem podejść w nieco bardziej uporządkowany sposób do tematu formularza logowania i rejestracji, dzięki czemu będziemy mogli wygenerować sobie za chwilę formularz w sposób generyczy.
Generyczny formularz
Dzięki ustrukturyzowaniu informacji na temat formularza w podobny sposób, widoki obsługujące rejestrację i logowanie użytkownika mogą być mocno uproszczone:
| |
| |
To zazwyczaj oznacza, że skomplikowanie jest zaszyte gdzieś w niżej warstwie, w tym wypadku w pliku views/forms/generic.ejs:
| |
Nie jest to może bardzo skomplikowane, głównie sprowadza się to do warunkowego dodawania atrybutów w zależności od tego, jakie dane zostały podane do formularza.
Podpięcie kontrolera do serwera
Aby spiąć to wszystko z resztą aplikacji musimy dodać odpowiednią konfigurację do naszego pliku index.js.
| |
Pozostałe wymagane zmiany
Wcześniej wypunktowaliśmy sobie, czego potrzebujemy aby nasz system logowania uznać za działający. Z listy pozostało nam:
- Indykator zalogowania, coś co pozwoli użytkownikowi rozpoznać, że jest zalogowany
- Ścieżka HTTP umożliwiająca wylogowanie
- Linki nawigacyjne prowadzące do odpowiednich ścieżek
Kwestię analizy zmian w zakresie CSS i dodania odpowiednich linków nawigacyjnych pozostawiam jako pracę własną. Kod znajdziecie w repozytorium z kodem do tych zajęć w podfolderze dedykowanym tej lekcji.