Autentykacja (uwierzytelnianie) i autoryzacja w Springu, mimo że są mechanizmami całkiem dobrze zaprojektowanymi we frameworku,
potrafią przysporzyć wielu problemów, szczególnie na początku drogi ze Springiem.
Appa Notka.
Celowo użyliśmy słowa autentykacja, ponieważ raz, że staje się ono coraz popularniejsze w branży i powoli wchodzi do kanonu słów branżowych, a dwa,
że dzięki takiemu bezpośredniemu tłumaczeniu łatwiej jest zapamiętać różnicę między ang. authentication a authorization.
Angielskie "authentication" brzmi zupełnie inaczej niż polskie "uwierzytelnienie" i jest mu zdecydowanie bliżej do polskiego słowa autoryzacja.
Na rozmowach kwalifikacyjnych często jest tak, że progamiści potrafią powiedzieć, czym różni się "uwierzytelnianie" od "autoryzacji",
ale już zapytani z ang. o to "czym jest authentication?", przytaczają definicję "autoryzacji".
Warto jednak wiedzieć, że wśród znawców językowych można spotkać się z opinią, iż nie jest to poprawna forma (źródło Wikipedia, https://pl.wikipedia.org/wiki/Uwierzytelnianie).
W kursie słowa uwierzytelnienie i autentykacja stosujemy zamiennie.
W rozdziale omówimy
stworzoną przez nas mini aplikację służącą do logowania użytkownika i autoryzowania jego dostępu do zasobów.
Aplikacja zawiera wszystkie typowe elementy security:
-
Klasę konfiguracyjną SecurityConfig.
-
Klasy odpowiedzialne za logowanie i dostęp do roli (User, Role, Privilege).
-
Generowanie hasła metodą bCrypt.
- Klasy listenerów dla akcji poprawnego i niepoprawnego logowania oraz akcji wylogowania.
- Klasę UserDetailsService zapewniającą pobranie użytkownika wraz z rolami.
- Inicjalizacja bazy danymi początkowymi w postaci dwóch userów z różnym zestawem uprawnień.
- Zezwolenie na uruchomienie konkretnej metody w Springu w zależności od roli (@PreAuthorize, @Secured, @RolesAllowed).
- Bean sesyjny przechowujący dodatkowe informacje o zalogowanym użytkowniku.
- Krótki formularz logowania w Thymeleaf.
Projekt security-app
Na początek szybki rzut oka na projekt aplikacji. Na poniższym zdjęciu widzimy zbiór 20 klas i interfejsów, które
wykonują wszystkie założone zadania. Oczywiście jest to projekt Spring Boot.
Model bezpieczeństwa
Implementując zabepieczenia aplikacji zwykle działamy na dwóch płaszczyznach:
-
Uwierzytelnienie - potwierdzenie tożsamości użytkownika, najczęściej wykonywane za pomocą
formularza logowania, w którym użytkownik wpisuje nazwę użytkownika oraz hasło.
Czasem występuje również drugi czynnik uwierzytelniający, w postaci maila lub smsa
z kodem, który musi być dodatkowo wpisany podczas logowania. Ten sposób jest znany jako Two-Factor Authentication.
-
Autoryzacja - określa prawo dostępu do zasobu, które jest weryfikowane niezależnie lub nawet po wcześniejszym
poprawnym uwierzytelnieniu użytkownika w aplikacji.
Innymi słowy, możemy zalogować się do aplikacji,
ale to nie znaczy, że będziemy posiadać prawa dostępu do wszystkich obiektów
zapisanych w bazie lub wszystkich funkcjonalności uruchamianych przez konkretne metody.
W
security-app realizujemy obydwa rodzaje zabezpieczeń (z pominięciem
Two-Factor Authentication).
Uwierzytelnienie jest wykonywana za pomocą formularza z polami
username i
password.
Po naciśnięciu przycisku
Sign in dane te są wysyłane na serwer pod postacią url-a z końcówką
login.
Po stronie backendu działa Spring (konkretnie Spring Boot) uzbrojony dodatkowo w zależność
spring-boot-starter-security:
Powoduje to, że nie tylko mamy dostęp do klas i interfejsów
Spring Security, ale
również otrzymujemy gotowe funkcjonalności upraszczające proces logowania.
Nie musimy na przykład sami tworzyć kontrolera do przechwytywania requesta wysłanego na url
/login.
Uwierzytelnienie
Spring automatycznie przyjmie request przychodzący na url
/login.
Po odebraniu żądania zapisane w nim dane (użytkownik oraz hasło) są weryfikowane przez framework z danymi
w bazie. Kod weryfikujący dostarczamy sami poprzez zaimplementowanie
odpowiedniej metody w klasie
CustomUserDetailsService.
To tam następuje przeszukanie bazy danych w celu znalezienia pasującego użytkownika i hasła.
Mogą tutaj wystąpić dwa scenariusze:
-
Użytkownik i hasło przesłane w requeście pasują do jednego z użytkowników w bazie i wtedy zostaje uruchomiony kod
świadczący o sukcesie autentykacji (CustomAuthSuccessHandler).
-
Żaden użytkownik zapisany w bazie danych nie posiada nazwy oraz hasła pasującego do tych z requestu i wówczas
zostaje uruchomiony kod obsługujący niepowodzenie autentykacji (CustomAuthFailureHandler).
W obu wspomnianych klasach możemy sami dostarczyć implementację kluczowych metod, czyli to my decydujemy, co chcemy zrobić po poprawnym lub nieudanym
logowaniu.
Co ważne, klasy te, aby mogły zostać użyte w procesie autentykacji, muszą zostać zarejestrowane.
Rejestracji dokonujemy w klasie konfiguracyjnej
SecurityConfig.
Jest to centralne miejsce naszego modelu bezpieczeństwa. To tam definiujemy istotne dla nas ustawienia, takie jak
czas trwania sesji, czy wzorce ścieżek dla requestów, które mają być chronione przed niepowołanym dostępem.
Wszystkie te elementy omówimy w kolejnych rozdziałach.
Warto podkreślić, że hasło nie jest przechowywane w aplikacji w otwartej formie, tylko jest kodowane za pomocą konkretnego algorytmu.
To jakiego algorytmu użyjemy (np.
MD5, czy
bcrypt) zależy od nas. Możemy to ustawić w prosty sposób również w klasie
SpringSecurity.
Autoryzacja
Dostęp do zasobów ograniczymy za pomocą określenia restrykcji dla metod pozwalających na dostęp do tych zasobów,
na przykład uruchomienie metody tylko wtedy, gdy zalogowany użytkownik należy do roli
ROLE_API_READ_PRIVILEGE:
Stworzyliśmy taką rolę w celu sprawdzenia, czy użytkownik ma prawo wywoływać metody api.
W tym konkretnym przypadku, jeśli użytkownik nie ma takich praw (zapisanych wcześniej w bazie),
nie pobierze on listy wszystkich użytkowników.
Wygląda to tak, że nasz kontroler Springa o nazwie
UserApi przyjmie request
/api/users
i spróbuje uruchomić powyższą metodę
getUsers.
Jeśli użytkownik nie należy do roli
ROLE_API_READ_PRIVILEGE, zostanie rzucony wyjątek
AccessDeniedException:
oraz nastąpi wygenerowanie odpowiedzi http o kodzie
403 (
Forbidden):
W przypadku gdy dodamy użytkownikowi przywilej odczytu danych z api, wykonanie tego requestu powiedzie się i serwer zwróci w odpowiedzi listę użytkowników:
Model danych
Na koniec przyjrzyjmy się jeszcze modelowi danych, jaki jest typowy dla większości aplikacji. Mamy trzy podmioty reprezentowane
przez trzy dedykowane klasy:
-
User - dane użytkownika, w tym nazwa i hasło służące do logowania
-
Role - role, na przykład ROLE_ADMIN oraz ROLE_USER (takie role dodaliśmy do security-app)
-
Privilege - przywileje, na przykład ROLE_READ_PRIVILEGE, ROLE_WRITE_PRIVILEGE oraz ROLE_API_READ_PRIVILEGE (takie przywileje dodaliśmy do security-app), które są przypięte do roli, przez co użytkownik
należący do tej roli automatycznie posiada wszystkie przywileje związane z rolą.
W
security-app wygląda to tak:
Dzięki takiemu trójwarstwowemu modelowi możemy łatwo określać restrykcje dla metod lub klas.
Na przykład, jeśli zależy nam by dostęp do danej metody miała tylko rola
ROLE_ADMIN,
wystarczy, że właśnie tak określimy to w adnotacji
@PreAuthorize, czyli
hasRole('ROLE_ADMIN').
Jednak gdy chcemy by dostęp do danej funkcjonalności miał tylko użytkownik z określonym przywilejem, na przykład
ROLE_READ_PRIVILEGE,
wówczas nie musimy wykazywać listy ról, które mają ten przywilej i ich używać w metodzie
hasRole.
Jest to o tyle istotne, że o ile w naszym przypadku występują tylko dwie role, o tyle w prawdziwym systemie może istnieć dziesięć albo i więcej ról zawierających ten sam przywilej.
Wtedy należałoby umieścić wszystkie te role w adnotacji
@PreAuthorize.
A tak wystarczy użyć samej nazwy przywileju, a więc:
hasRole('ROLE_READ_PRIVILEGE').