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:
1 | 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:
1 | 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ę:
1 2 3 4 5 6 7 8 9 10 | @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:
1 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:
1 2 3 | { "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:
1 | 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(...)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | 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 2 3 4 | # 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | @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ę).
Podziel się opinią!
Jeśli podobał Ci się artykuł, daj mi znać o tym w komentarzach. Jeśli masz jakieś pytania, to też zapraszam cię do komentowania lub skorzystanie z formularza kontaktowego Kontakt.
Zachęcam Cię także do zapisania się do newslettera i polubienia mojej strony na Facebooku
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.