Po poprzednim artykule Spring Security – uwierzytelnienie przy pomocy jsona, kilka osób odezwało się do mnie z pytaniem, czy mógłbym opisać uwierzytelnienie z wykorzystaniem JWT (Json Web Token)? Nie jest to trudne zadanie i nie wymaga zbyt wiele pracy w stosunku do tego, co napisałem w poprzednim artykule, więc postanowiłem to krótko opisać.
Bazując na przykładzie z poprzedniego artykułu, wprowadzę kilka zmian, które pozwolą logować się do aplikacji za pomocą JWT. Wcześniejszy przykład jest oparty o bardziej tradycyjne podejście z sesją http po stronie serwera i wymianą cookie po stronie klienta.
Poprzedni przykład Spring Security – uwierzytelnienie przy pomocy jsona miał pokazywać tylko, jak korzystać z jsona przy logowaniu, zamiast tradycyjnego formularza
application/x-www-form-urlencoded
. I nie poruszał on żadnych innych zagadnień (nie dla wszystkich było to jasne).
Zacznę od tego, czym w ogóle jest JWT? Jest to ciąg znaków, który powstaje w wyniku zakodowania obiektu w formacie json, zawiera jakieś informacje np. o użytkowniku (nazwę użytkownika). Jedną z jego charakterystycznych cech jest to, że ma z góry określony czas życia (czas jego ważności). Kolejną zaś to, że jest podpisany sygnaturą, co sprawia, że bez jej poprawnej weryfikacji, token zostanie uznany za niepoprawny. Token zakodowany jest algorytmem base64 i można go łatwo odkodować, więc nie powinien zawierać informacji wrażliwych (jest to uproszczona definicja tokena JWT- jeśli potrzebujesz bardziej szczegółowych informacji o JWT, znajdziesz je tutaj).
Jak działa autoryzacja za pomocą tokena?
Autoryzacja ta opiera się na wymianie tokena JWT pomiędzy klientem i serwerem. Nie różni się to aż tak bardzo, jak wymiana cookie. Różnica jest po stronie serwera. Nie ma sesji http, więc aplikacja pod tym względem jest bezstanowa. Tokeny generowane są w aplikacji poprzez bibliotekę com.auth0:java-jwt
i wyglądają tak jak na przykładzie poniżej:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiZXhwIjoxNTcwMjA5MjA4fQ.VoyTiLVsQXbJ3DZxxthcZgTiINr7zHY5JodZx2IZdns
Pierwszą rzeczą, jaką musimy zrobić, jest dodanie zależności do biblioteki, która stworzy dla nas token i podpisze go przy pomocy naszej sekretnej frazy. Dopisuję więc w zależnościach:
implementation 'com.auth0:java-jwt:3.8.3'
Kolejna rzecz to sprawienie, żeby po zalogowaniu endpoint /login
zwracał nam token. Najłatwiej jest to zrobić w succesHadler. Musisz przeimplementować metodę onAuthenticationSuccess(...)
tak, żeby zwracała tokena. W succesHadler mamy dostępny OutputStream, który posłuży nam do zwrócenia tokena. Możemy to zrobić na trzy sposoby:
- zwrócić tokena w nagłówku
Authorization
- jako zwykłego stringa
- zwrócić jsona
Ja wybrałem trzecią opcję:
@Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { UserDetails principal = (UserDetails) authentication.getPrincipal(); // 1 String token = JWT.create() // 2 .withSubject(principal.getUsername()) // 3 .withExpiresAt(new Date(System.currentTimeMillis() + expirationTime)) // 4 .sign(Algorithm.HMAC256(secret)); // 5 response.getOutputStream().print("{\"token\": \"" + token + "\"}"); // 6 }
- Pobieram szczegóły zalogowanego użytkownika.
- Tworzę builder.
- Dodaję nazwę użytkownika jako
subject
. - Ustawiam datę wygaśnięcia. Zmienna
expirationTime
jest ustawiana w konfiguracji aplikacji. - Ustawiam algorytm na HMAC256 dla
secret
(ta zmienna też jest pobierana z konfiguracji). Podpisuję i tworzę tokena (metoda .sing() robi te dwie rzeczy) - Wypisuje jsona zawierającego tokena na OutputStream. Robię to w bardzo prosty sposób (konkatencja), ponieważ tworzenie obiektu opakowującego i zamienianie go na jsona poprzez
ObjectMapper
wydało mi się trochę nadmiarowe.
Jeśli chcesz zwracać tokena w nagłówku http, możesz to zrobić w takiej formie:
response.addHeader("Authorization", "Bearer " + token);
W tym miejscu możemy wykonać request przy pomocy Postmana (POSTem, z loginem i hasłem).
Endpopint /login
powinien zwracać jsona z tokenem w postaci:
{ "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiZXhwIjoxNTcwNDQ4NjA0fQ.BdBG-16pvbHsfdLEV0UOv_xfQ_hTrOpr7QCqk-GgVWk" }
Takiego tokena możesz sprawdzić używając serwisu https://jwt.io, wystarczy wkleić go na stronie :
Autoryzacja zabezpieczonych endpointów
Gdy już mamy zwróconego tokena, możemy zacząć autoryzować się do zabezpieczonych endpointów. Token będziemy wysyłać w dodatkowym nagłówku http Authorization
w takiej formie:
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiZXhwIjoxNTcwNDQ4NjA0fQ.BdBG-16pvbHsfdLEV0UOv_xfQ_hTrOpr7QCqk-GgVWk
Żeby prawidłowo obsłużyć ten nagłówek, będziemy potrzebowali stworzyć odpowiedni filtr, który będzie dziedziczył po klasie BasicAuthenticationFilter
. Tworzymy nową klasę JwtAuthorizationFilter
i nadpisujemy metodę doFilterInternal(...)
public class JwtAuthorizationFilter extends BasicAuthenticationFilter { private static final String TOKEN_HEADER = "Authorization"; private static final String TOKEN_PREFIX = "Bearer "; private final UserDetailsService userDetailsService; private final String secret; public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserDetailsService userDetailsService, String secret) { super(authenticationManager); this.userDetailsService = userDetailsService; this.secret = secret; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { UsernamePasswordAuthenticationToken authentication = getAuthentication(request); // 1 if (authentication == null) { filterChain.doFilter(request, response); return; } SecurityContextHolder.getContext().setAuthentication(authentication); // 2 filterChain.doFilter(request, response); } private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) { String token = request.getHeader(TOKEN_HEADER); // 3 if (token != null && token.startsWith(TOKEN_PREFIX)) { String userName = JWT.require(Algorithm.HMAC256(secret)) // 4 .build() .verify(token.replace(TOKEN_PREFIX, "")) // 5 .getSubject(); // 6 if (userName != null) { UserDetails userDetails = userDetailsService.loadUserByUsername(userName); // 7 return new UsernamePasswordAuthenticationToken(userDetails.getUsername(), null, userDetails.getAuthorities()); // 8 } } return null; } }
- Pobieram obiekt autoryzacji, jeśli nie istnieje, przekazuję sterowanie do kolejnego filtra.
- Jeśli istnieje, ustawiam obiekt w
SecurityContextHolder
. - Pobieram tokena z nagłówka
Authorization
sprawdzam, czy zawiera prefixBearer
. - Inicjalizuję weryfikację tokena.
- Używam metody
verify(...)
do sprawdzenia poprawności sygnatury tokena (przy okazji wycina z tokena prefix). - Pobieram
subject
(w tym wypadku jest to nazwa usera). - Pobieram użytkownika z userDetailsService (którego wcześniej wstrzykuję w konstruktorze).
- Tworzę
UsernamePasswordAuthenticationToken
w oparciu o pobraneUserDetails
.
W pliku application.properties
, dodaję konfigurację:
# 1 godzina jwt.expirationTime=3600000 # niezapomnij podmienić na produkcji jwt.secret=secretForEncodingSignature
Możesz dostosować konfigurację w zależności od potrzeb. Zwykle tokeny są ważne kilka godzin lub nawet kilka dni. Nie zapomnij też, by ustawić unikalny secret
.
W tym miejscu mamy już dostępne wszystkie składniki potrzebne do korzystanie z JWT w naszej aplikacji. Ostatnią rzeczą, jaką musimy zrobić jest zmiana konfiguracji.
@Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable(); http .authorizeRequests() .antMatchers("/").permitAll() .anyRequest().authenticated() .and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 1 .and() .addFilter(authenticationFilter()) .addFilter(new JwtAuthorizationFilter(authenticationManager(), super.userDetailsService(), secret)) // 2 .exceptionHandling() .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)); }
- Konfiguruję sesję uwierzytelnienia tak, by była bezstanowa.
- Dodaję do konfiguracji nowo utworzony filtr
JwtAuthorizationFilter
, jako parametry podajęauthenticationManager()
,userDetailsService
, który wstrzykuję do konfiguracji isecret
, który wstrzykuję poprzez@Value("${jwt.secret}").
Podsumowanie
Json Web Token to prosty sposób, by uczynić aplikacje bezstanowymi. Dzięki niemu zwiększysz swoje możliwości, jeśli chodzi o skalowanie aplikacji. W sytuacji, gdy zajdzie konieczność uruchomienia kilku węzłów aplikacji, nie będziesz potrzebował takich rozwiązań jak sticki session, czy replikacji sesji pomiędzy węzłami (upraszcza to architekturę).
Link do projektu na githubie: security-example-jwt
Żródła:
https://github.com/auth0/java-jwt
Ten projekt z githuba nie działa, nie zwraca tokena. Nie ma w ogole takie endpointa jak login…. bez sesnu
A co Ci nie działa? Jak napiszesz co, to może Ci z tym pomogę. Endpoint /login nie jest jawnie dodany, bo spring security go dodaje i w aplikacji jest dostępny. Jak masz problem z działem, to może obejrzyj załączony film, który opiera się także o ten artykuł. Pod filmem w opisie na Youtube jest załączony link do repo, na któym zrobiony jest cały kurs z Youtuba (możesz też sprawdzić tamto). Wszystko działa tak jak trzeba w obu repozytoriach 😉
I nie „bez sensuj” mi tutaj, masz problem to napisz jaki!
Super kurs, dzięki za nie go 🙂
Mam pytanie jak zatem wykonać wylogowywanie, tak aby token stracił swoją ważność ?
Zrobisz może o tym jakiś osobny kurs ?
Cześć Darek, dzięki za komentarz 😉 jak sprawdzisz komentarze pod wideo na YT (https://www.youtube.com/watch?v=and2DR_N6tE) to znajdziesz tam różnych propozycji (nie koniecznie trafnych) z moimi komentarzami, warto poczytać.
Generalnie to nie da się unieważnić samego tokenta, to co można zrobić w takiej sytuacji to wpisać go na czarną listę po stronie serwera przy wylogowaniu (nie jest to takie klasyczne wylogowanie, bo też nie ma klasyczngo logowanie z sesją) i trzymać go tam, aż straci ważność. Ale ma to taką wadę, że przy każdym requeście musisz sprawdzać, czy token jest na czarnej liście. To tak w skrócie 😉
Ja generalnie nie planuję specjalnego odcinka z wyjaśnieniem tego, ale planuję odcinek z OAuth i tam może też to wyjaśnię.
Dziękuje za odpowiedź.
Mam pytanie zatem, czy warto wykorzystać JWT w systemie składającym się z aplikacji webowej i aplikacji mobilnej. Aplikacja mobilna jest dla pracowników a aplikacja webowa dedykowana dla administratorów. System będzie umieszczony na serwerze zewnętrznym i oczywiście będzie zabezpieczony certyfikatem SSL.
W przyszłości dostęp do serwisu będzie w formie abonamentu.
To zależy od tego czego potrzebujesz, jaką masz architekturę itd. Tokena używa się po to, żeby uniknąć sesji (aplikacja z sesją jest stanowa – ma stan na serwerze. Aplikacje z tokenem są bezstanowe, nie mają stanu na serwerze). Bezstanowe aplikacje z tokenem lepiej się skalują, możesz mieć kilka instancji aplikacji i nie musisz się martwić o synchronizowanie pomiędzy nimi sesji. Więc jeśli twoja aplikacja będzie miała tylko jedną instancję, to obojętnie co wybierzesz, będzie działać.
Jeśli podejrzewasz, że użytkowników aplikacji będzie dużo i będziesz musiał skalować aplikację, to lepiej jest zrobić tokena.
W moim przypadku adnotacja AuthenticationPrincipal powodowała, że w miejsce UsernamePasswordAuthenticationToken wchodził null. Usunąłem adnotację, ale zatrzymałem parametr i nagle zaczął mi dawać tego usera do kontrolera 😛 Tylko ja robiłem to w Kotlinie, nie w Javie.
Cześć Adrian, w springu 2.4 coś się zmieniło i teraz działa to trochę inaczej. Już ktoś mi to zgłaszał, ale jeszcze nie miałem okazji się temu przyjrzeć 😉
Dokładnie tak jak kolega Adrian wspomniał, adnotacja AuthenticationPrincipal zwraca nulla, walczyłem z tym pół dnia grzebiąc w filtrze a problem okazał się banalny i wystarczyło usunąć adnotację by zwracać usera. Sprawdzone w Javie.
Cześć, czy ta linijka jest konieczna?
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
Tak, jeśli chcesz mieć bezstanową sesję, co w przypadku tokena jwt jest jak najbardziej potrzebne. Token JWt używa się właśnie po to, żeby mieć bezstanową sesję.
Cześć Mateusz!
Mam pytanie, co się dzieje, gdy token wygaśnie? Użytkownik będzie musiał znowu się zalogować? Czy jest możliwość odświeżenia tokena, gdy zbliża się jego koniec ważności?
Pozdrawiam
Paweł
Cześć Paweł, dzięki za pytanie. To jest dosyć prosta implementacja JWT, więc w sytuacji, gdy token wygaśnie, użytkownik musi zalogować się jeszcze raz. Oczywiście można się pokusić o zrobienie jakiegoś mechanizmu odświeżania tokena, ale w tej implementacji trochę nie ma to sensu. Jak potrzebujesz odświeżania tokena, to lepiej jest użyć OAuth2, gdzie masz już wszystko zaimplementowane na dwóch tokenach. W tej implementacji możesz jedynie sterować, czasem ważności tokena. I w zależności od aplikacji może to być kilka minut, godzin albo nawet dni.
Dzięki za odpowiedź. Tak się składa, że piszę aplikację webową, która jest przeznaczona do użytku wewnętrznego firmy – jest to aplikacja tylko na potrzeby nauki. Koncepcyjnie nie chciałbym sytuacji, gdy ktoś używałby mojej apki i w trakcie pracy token wygasa, po czym przekierowuje go do loginu, a niezapisane dane zostają utracone. Czy w takim razie mógłbyś polecić jakiś dobry tutorial z Oauth2 ? Chciałbym to zaimplementować w miarę fachowo 🙂 W poprzedniej aplikacji użyłem keycloaka i robił to za mnie. Teraz chcę to zrobić sam.
W podobnych przypadkach jak twój ustawiałem zwykle tokena na 10-12 godzin. Wtedy pracownik musiał się logować do aplikacji każdego dnia rano i ważność tokena starczała mu na cały dzień pracy.
Nie napisałeś, jakiego frontendu używasz, czy jest to Angular/React, czy może Thymeleaf. Jak to drugie to faktycznie może być tak, że ktoś może stracić jakieś dane przy zapisie. Ale można też to obejść, sprawdzając co jakiś czas ajaxem czy token jest ważny. W Angular/React wszystko dzieje się ajaxem, więc można uniknąć utraty tych danych i po prostu wyświetlić użytkownikowi popup z logowaniem. Jest na pewno kilka sposobów poradzenia sobie z tym i wszystkie będą pewnie łatwiejsze niż wdrażanie OAuth2.
A jak nadal chcesz się bawić w OAuth, to tu znajdziesz kilka artykułów (jak chcesz robić bez zewnętrznego providera, to sprawdź sekcję legacy):
https://www.baeldung.com/spring-security-oauth
Super, dzięki za radę.
Nie myślałem, że to istotne – do frontu używam Angulara. Skoro tak robiłeś to chyba najprościej ustawię ten token na 12 godzin i wykorzystam sposób z pop-upem. Trzeba to będzie przechwycić interceptorem ?