Uwierzytelnianie i autoryzacja użytkowników
Projektowanie i programowanie systemów internetowych I
wykład 10 z 15

Collegium Witelona
mgr inż. Krzysztof Rewak
Zakład Informatyki, Wydział Nauk Technicznych i Ekonomicznych
Collegium Witelona Uczelnia Państwowa
Blumilk sp. z o.o.
Agenda

  1. Kim jest użytkownik?
  2. Rejestracja użytkowników
  3. Uwierzytelnianie poprzez sesję
  4. Uwierzytelnianie bezstanowe
  5. Autoryzacja
  6. Podsumowanie
Kim jest użytkownik?
Definicja użytkownika

Każdy system internetowy może mieć całkowicie odmienną definicję użytkownika i dobrze jest ją sprecyzować już na samym początku naszej pracy nad danym projektem.
Weźmy jako przykład portal informacyjny taki jak onet.pl. Kto tam jest użytkownikiem? Gość przeglądający artykuły? Ktoś, kto jest zalogowany, bo płaci abonament? A może redaktor? A administrator?
Dlatego warto też rozgraniczyć fakt, że mamy w naszym systemie klasę User od tego, kto naprawdę z niego korzysta.
W aplikacji webowej użytkownikiem praktycznie może być każdy, kto otwiera stronę... ale czy to oznacza, że użytkownikami są boty i inne serwisy?
Schema::create('users', function (Blueprint $table) {
  $table->id();
  $table->string('name');
  $table->string('email')->unique();
  $table->timestamp('email_verified_at')->nullable();
  $table->string('password');
  $table->rememberToken();
  $table->timestamps();
});

Schema::create('password_reset_tokens', function (Blueprint $table) {
  $table->string('email')->primary();
  $table->string('token');
  $table->timestamp('created_at')->nullable();
});

Schema::create('sessions', function (Blueprint $table) {
  $table->string('id')->primary();
  $table->foreignId('user_id')->nullable()->index();
  $table->string('ip_address', 45)->nullable();
  $table->text('user_agent')->nullable();
  $table->longText('payload');
  $table->integer('last_activity')->index();
});
                
Tak wygląda seria migracji bazowego modelu użytkownika w Laravelu.
Rejestracja użytkowników
Serwis rejestrujący użytkowników

Wiemy już, że lepiej jest wspólne akcje wydzielać do odseparowanych klas. Rejestracja użytkownika może wystąpić w kilku miejscach: po wypełnieniu przez gościa formularza na stronie internetowej, w panelu administracyjnym, poprzez żądanie z innego serwisu przez API, podczas automatycznych testów albo przy pierwszym uruchomieniu systemu, gdy tworzony jest pierwszy administrator.
Odpowiedzialność

Zastanówmy się z jakich komponentów musimy skorzystać, żeby użytkownika zarejestrować.
Będą to na pewno: połączenie z bazą danych, serwer mejlowy i funkcja haszująca hasło. Znając wzorzec wstrzykiwania zależności, możemy w ten sposób zbudować interfejs dla takiego serwisu:
interface UserRegistrar
{
    public UserRegistrar(DatabaseContext database, Hasher hasher, Mailer mailer)

    public User register(string email, string password, bool agreementsApproved)
}
                
Pamiętajmy, żeby nie zmuszać serwisu rejestrującego do tego, aby przyjmował obiekt klasy Request czy jakieś ogólne luźne struktury danych. Klasa musi być zdatna do uruchomienia wszędzie i w każdy sposób.
class UserRegistrar implements UserRegistrarContract
{
    public User register(string email, string password, bool agreementsApproved)
    {
        if (!agreementsApproved) {
            throw new AgreementsNotApprovedException()
        }

        if (this.database.select(User.class).where("email", email).count() > 0) {
            throw new UserAlreadyExistsException()
        }

        User user = new User
        user.email = email
        user.password = this.hasher.hash(password).toString()

        this.database.commit(user)
        this.mailer.send(new UserRegistered(user))

        return user
    }
}
                
O czym warto pamiętać?

  • hasło zawsze musi być sensownie zahashowane!
  • warto ustawić indeks UNIQUE na kolumny z emailem, jeżeli chcemy zachować jego unikalność
  • jedyna informacja jaką powinien otrzymać użytkownik na temat tego, że rejestracja się nie powiodła, to błędy walidacji
  • wyjątek typu UserAlreadyExistsException lepiej złapać w kontrolerze i wyświetlić ogólny komunikat zamiast błędu, który wskazywałby, że użytkownik faktycznie u nas już jest zarejestrowany
  • warto się zastanowić czy niektóre elementy flow (wysyłanie mejla) zawsze będą potrzebne?
Potwierdzanie rejestracji

  • wiele systemów wymaga potwierdzenia konta (warto zastanowić się, co ma blokować brak tegoż potwierdzenia)
  • najlepiej zrealizować to poprzez wygenerowanie i zapisanie w bazie losowego ciągu znaków (tokenu), który zostanie wysłany w formie linku mejlem; jeżeli ktoś odwiedzi tenże link, aplikacja powinna usunąć token z bazy, a powiązanemu użytkownikowi ustawi flagę "potwierdzony"
  • warto też ustawić maksymalny czas życia takich tokenów
Przypominanie i resetowanie hasła

  • użytkownicy mają tendencje do zapominania haseł, więc zazwyczaj trzeba im udostępnić funkcjonalność resetu hasła
  • raczej odchodzi się od niebezpiecznych podejść w stylu "wpisz swój email, a my prześlemy ci nowe hasło"
  • najlepiej wygenerować token podobny jak przy potwierdzeniu rejestracji i przeprowadzić użytkownika przez proces ustawiania nowego hasła
  • wszystkie takie operacje muszą koniecznie być potwierdzane z poziomu przynajmniej mejla
Uwierzytelnianie poprzez sesję
Sesja

Sesja to sposób na przechowywanie informacji na temat interakcji użytkownika z aplikacją internetową w czasie jednego ciągłego połączenia. Najczęściej jest identyfikowana unikalnym ID, które przechowuje się na serwerze.
Jeżeli użytkownik poprawnie się uwierzytelni (a więc "zaloguje"), serwer powinien utworzyć sesję i zwrócić jej identyfikator użytkownikowi. Najczęściej realizuje się to przez ciasteczka przyczepione do odpowiedzi HTTP.
Odpowiedzialność

Zastanówmy się ponownie z jakich komponentów musimy skorzystać, żeby użytkownika zalogować.
Będą to na pewno: połączenie z bazą danych, manager sesji i funkcja sprawdzająca zahaszowane hasło. Znając wzorzec wstrzykiwania zależności, możemy w ten sposób zbudować interfejs dla takiego serwisu:
interface UserAuthenticator
{
    public UserAuthenticator(DatabaseContext database, SessionContext session, Hasher hasher)

    public string authenticate(User user, string password)
}
                
Klasa ta mogłaby wyglądać następująco:
class UserAuthenticator implements UserAuthenticatorContract
{
    public string authenticate(User user, string password)
    {
        ?User user = this.database.select(User.class).where("email", email).first()

        if (user === null) {
            this.hasher.hash(rand())
            throw new UserNotFoundException()
        }

        bool result = this.hasher.verify(password, user.password)

        if (!result) {
            throw new WrongPasswordException()
        }

        return this.session.registerUser(user).getSessionId()
    }
}
                
O czym warto pamiętać?

  • hasło zawsze powinno być oznaczone jako parametr wrażliwy, więc nigdy nie powinno być nigdzie logowane, zapisywane ani nawet wyświetlane
  • wstrzykiwanie zależności pokazuje nam tak naprawdę jak skomplikowana jest dana klasa w kontekście całego projektu
  • czasami lepszym rozwiązaniem jest trzymanie sesji poza systemem plików - chociaż wygodniej się aplikację wówczas skaluje
Sprawdzenie uwierzytelnienia

  • informacja o pomyślnie otwartej sesji będzie dodana do komunikacji przez protokół HTTP
  • większość frameworków webowych dostarcza middlewary sprawdzające stan uwierzytelnienia, które można nałożyć na routing, który chcemy chronić przed niezalogowanymi użytkownikami
  • dzięki temu nie musimy ręcznie sprawdzać w każdym kontrolerze czy użytkownik jest zalogowany
  • brak ciastka lub brak zapisanego na serwerze identyfikatora wyrzuci wyjątek i cofnie użytkownika do formularza logowania lub innego widoku na fallbacku
I inne

  • w dzisiejszych czasach często formularz logowania umożliwia też logowanie się za pomocą zewnętrznych providerów takich jak Google czy Facebook; warto zobaczyć jak to pod spodem wygląda i dlaczego taki proces można raczej bezproblemowo dołączyć do większości aplikacji webowych
  • wylogowanie to po prostu usunięcie identyfikatora sesji; można to zrobić na akcję użytkownika (koniecznie POST!), ale niektórzy cyklicznie usuwają sesje, jeżeli użytkownik dawno z niej nie korzystał
Uwierzytelnianie bezstanowe
JSON Web Token

JWT to standard przy uwierzytelnianiu bez użycia sesji. Token - w przeciwieństwie do sesji na serwerze - jest przechowywany tylko i wyłącznie po stronie klienta.
Serwer generuje token zamiast identyfikatora sesji. Token ten zawiera w sobie zaszyfrowane dane (o użytkowniku, o sposobie uwierzytelnienia, o jego dacie ważności i inne) oraz jest cyfrowo podpisany.
Odpowiedzialność

Zastanówmy się jeszcze raz z jakich komponentów musimy skorzystać, żeby użytkownika zalogować.
Będą to na pewno: połączenie z bazą danych, serwis generujący token i funkcja sprawdzająca zahaszowane hasło. Znając wzorzec wstrzykiwania zależności, możemy w ten sposób zbudować interfejs dla takiego serwisu:
interface UserAuthenticator
{
    public UserAuthenticator(DatabaseContext database, JwtService jwt, Hasher hasher)

    public string authenticate(User user, string password)
}
                
Klasa ta mogłaby wyglądać prawie identycznie jak ta do logowania z sesją:
class UserAuthenticator implements UserAuthenticatorContract
{
    public string authenticate(User user, string password)
    {
        ?User user = this.database.select(User.class).where("email", email).first()

        if (user === null) {
            this.hasher.hash(rand())
            throw new UserNotFoundException()
        }

        bool result = this.hasher.verify(password, user.password)

        if (!result) {
            throw new WrongPasswordException()
        }

        return this.jwt.generateToken(user)
    }
}
                
Co dalej?

  • tokeny JWT są podpisane cyfrowo, co zapewnia ich autentyczność i integralność
  • token jest najczęściej zapisywany w pamięci podręcznej przeglądarki i dodawany do nagłówka Authentication jako bearer
  • serwer odczytuje bearer token, weryfikuje jego poprawność i autoryzuje żądanie bez dodatkowego stanu
  • w przeciwieństwie do sesji, serwer nie może zarządzać stanem tokenu JWT; wylogowanie następuje poprzez usunięcie tokenu z przeglądarki po stronie frontendu
Autoryzacja
Słownictwo

Autoryzacja (ang. authorization) to proces nadawania dostępu do zasobów.
Nigdy nie powinno być mylone z uwierzytelnianiem (ang. authentication), które jest potwierdzeniem tożsamości opisanym na poprzednich slajdach. Język polski nie zna słowa autentykacja czy autentyfikacja.
Autoryzacja poprzez pojedynczą rolę

Jednym z najprostszych sposobów autoryzacji jest sprawdzenie czy dany użytkownik ma określoną rolę. Rola ta może być flagą w bazie danych, a w najprostszym wydaniu będzie to kolumna is_admin z wartościami zero lub jeden.
Middleware jest oczywiście wygodnym sposodem na sprawdzenie takiego warunku.
Middleware sprawdzający czy użytkownik jest administratorem może wyglądać następująco:
class OnlyAdminMiddleware:

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if not request.user.is_admin:
            raise ResourceForbiddenException()

        return self.get_response(request)
                
Autoryzacja wielu ról

Oczywiście bardzo rzadko w świecie spotkamy domenę, gdzie będzie potrzebna tylko jedna rola dla każdego użytkownika. O wiele częściej różni użytkownicy będą mogli nabyć kilka ról.
Jednym z rozwiązań może być wykorzystanie operatora bitowego (choć to raczej rozwiązanie oldschoolowe).
enum UserRole: int
{
  case Administrator = 1;
  case Moderator = 2;
  case Reviewer = 4;
  case Participant = 8;
}

# (...)
return (bool)($user->getRoleIndicator() & UserRole::Administrator);
Tabela ról wedle operatora bitowego
Tabela ról wedle operatora bitowego
Innym rozwiązaniem, raczej popularniejszym, jest bazodanowa relacja wiele do wielu na linii użytkownik i rola.
Autoryzacja poprzez uprawnienia

Jedną z sensowniejszych alternatyw jest budowa systemu uprawnień. System wówczas przechowuje informacje jaki użytkownik ma dostęp do jakiego typu akcji. Można to przedstawić na przykład następująco:
aktor moduł akcja dostęp
user:1 users get true
user:1 users post false
user:1 user get true
user:1 user patch false
user:1 user delete false
ACL, czyli access control list

  • z takiej listy można usunąć wszystkie wpisy z false - jeżeli nie znajdą się w bazie danych, oznacza to, że użytkownik nie ma prawa dostępu
  • SELECT COUNT(id) FROM permissions WHERE user_id = ? AND module = ? AND action = ? wystarczy do sprawdzenia uprawnienia
  • powyższe lepiej zapisać jakoś ładniej obiektowo
  • oczywiście moduły i akcje można definiować we własny sposób; często przydaje się na przykład uprawnienie na zarządzanie np. własnymi artykułami
Im dalej w las...

  • nic nie stoi na przeszkodzie, aby łączyć i mutować różne podejścia z autoryzacji
  • można utworzyć system ról, do których będa przypisane zestaw uprawnień i te zestawy przepisywać na konkretnego użytkownika przy przypisaniu roli
  • można część elementów systemu zamknąć za rolami, a część za uprawnieniami
  • można jednocześnie stosować flagi, operatory binarne i ACL, jeżeli system jest duży i skomplikowany
  • i na koniec: można skorzystać z zewnętrznego serwisu typu RBAC i oddać tę odpowiedzialność na zewnątrz
Podsumowanie
Highlights

  • w każdym systemie trzeba zdefiniować pojęcie użytkownika, jego stany i role (może diagram to dobry pomysł)
  • nie wolno informować o tym czy dany użytkownik już jest tam zarejestrowany w systemie
  • hasła to najbardziej wrażliwe dane w systemach informatycznych; jako inżynierowie musimy pracować z nimi solidnie i z najwyższymi etycznymi standardami
  • z sesją czy bez - na to pytanie musimy sobie umieć odpowiedzieć przy budowie każdego systemu
  • autoryzacja to trudny kawałek chleba, ale na szczęście współczesne narzędzi pozwalają na odseparowanie jej od większości systemu, przez co możemy ją wdrażać innym tempem niż prowadzenie pozostałych prac
Źródła i do dalszego poczytania

Dziękuję za uwagę