JWT – JSON Web Token w Spring Boot
JWT – JSON Web Token w Spring Boot
JSON Web Token (z ang, JWT) to rodzaj tokenu przechowywany po stronie klienta w formacie JSON. Jest to otwarty standard (RFC 7519) który definiuje metodę wymiany danych między stronami w bezpieczny sposób – dane te mogą być zweryfikowane z użyciem cyfrowego podpisu będącego częścią JWT. Cechą charakterystyczną jest to, że JWT ma określony czas życia po którym wygasa. Jest to obok Basic Authentication – wykorzystanie loginu i hasła druga najpopularniejsza metoda autoryzacji użytkowników.
Gdzie JWT może być wykorzystywany:
- autoryzacja – kiedy należy przydzielić dostęp do chronionych zasobów i później bez konieczności przechowywania stanu po swojej stronie zweryfikować czy dostęp ten jest możliwy. W tradycyjnym podejściu sesja HTTP trzymana jest po stronie serwera natomiast pliki cookie przechowywane są po stronie klienta.
- transmisji danych – kiedy potrzeba pewności, że dane które zostały wysłane nie zostały zmodyfikowane oraz strona która wysyła dane jest stroną za którą się podaje.
- skalowanie aplikacji – brak konieczności stosowania rozwiązań do replikacji sesji co w dużym stopniu upraszcza architekturę oprogramowania.
- ograniczenie zapytań do bazy danych – JWT pozwala ograniczyć ilość zapytań do bazy danych ponieważ podstawowe dane użytkownika zawarte są w tokenie. Jeśli podpis się zgadza to dane są prawidłowe,
- bardzo często jest to technika wykorzystywana w różnych restowych API np. w Allegro API.
Przykładowa struktura tokena JWT przedstawia się następująco:
[źródło] http://artsy.github.io/blog/2016/10/26/jwt-artsy-journey/
JWT to zbiór znaków w postaci header.payload.signature:
• Nagłówek (z ang. Header):
- zawiera informacje o rodzaju tokena oraz rodzaj użytego algorytmu szyfrowania.
• Zawartość (z ang. Payload):
- zawiera dane przesyłane w tokenie – m.in. są to data ważności tokena czy rola/role użytkownika.
• Sygnatura (z ang. Signature)
- zawiera sygnaturę która stanowi potwierdzenie autentyczności zawartych w tokenie danych.
Graficznie proces logowania (uzyskania dostępu do chronionych części aplikacji) przedstawia się następująco – jest to porównanie tradycyjnego mechanizmu z użyciem sesji HTTP i nowoczesnego podejścia z użyciem tokena JWT:
[źródło] http://stormpath.com/blog/token-authentication-scalable-user-mgmt
Podstawowy mechanizm logowania z użyciem Spring Security – bez użycia JSON Web Token:
Zanim zaczniemy projektować logowanie z użyciem JWT pokażę w jaki sposób wygląda podstawowy mechanizm z użyciem sesji HTTP. Tworzymy nowy projekt Spring Boota – plik pom.xml – niezbędne zależności:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> </exclusion> </exclusions> </dependency> </dependencies>
RestController – dwa podstawowe endpointy – jeden dostępny dla wszystkich, drugi będzie zabezpieczony:
@RestController public class RestControllerApi { @GetMapping("/getMsg") public String getMsg(){ return "msg permit all"; } @GetMapping("/getSecuredMsg") public String getSecuredMsg(){ return "msg secured"; } }
Konfiguracja Spring Security:
@Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication().withUser(User.builder() .username("admin") .password("nimda") .roles("ADMIN")); }; @Override protected void configure(httpecurity http) throws Exception { http.authorizeRequests().antMatchers("/getSecuredMsg").hasAnyRole( "ADMIN" ).and().formLogin().permitAll(); } }
Uruchamiamy aplikację:
http://localhost:8080/getMsg
wynik:
msg permit all
dla adresu:
http://localhost:8080/getSecuredMsg
wynik:
po wpisaniu danych:
login: admin
hasło: admin
aplikacja kończy się błędem:
There is no PasswordEncoder mapped for the id "null"
co oznacza, że trzeba skonfigurować metodę szyfrowanie hasła – wybierzemy BCryptPasswordEncoder:
@Bean public PasswordEncoder getPasswordEncoder(){ return new BCryptPasswordEncoder(); }
i zmodyfikować metodę configure():
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication().withUser(User.builder() .username("admin") .password(getPasswordEncoder().encode("admin")) .roles("ADMIN")); };
teraz po poprawnym zalogowaniu się mamy dostęp do chronionych zasobów aplikacji.
W pliku cookie JSESSIONID trzymane są dane sesji, usunięcie ciasteczka powoduje zniszczenie sesji co skutkuje z kolei wylogowaniem się z aplikacji:
Logowanie z użyciem Spring Security i JSON Web Token
W pierwszym kroku należy dodać zależność do pliku pom.xml która odpowiada za parsowanie tokena:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
Jeśli projekt jest uruchamiany na Javie w wersji wyższej niż 8 należy również dodać zależność:
<dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency>
Musimy zdefiniować teraz filtr. Czym są filtry? Mechanizm filtrów pozwala przefiltrować żądania do zasobów aplikacji. Daje to możliwość m.in. odrzucenia żądań które skoncentrowane są np. na atakach DDos lub pochodzą z nieprawidłowego hosta. Poniżej zamieszczony filtr sprawdza poprawność wysłanego w nagłówku żądania tokena JWT a następnie dokonuje autentykacji użytkownika:
public class JSONWebTokenFilter extends BasicAuthenticationFilter { public JSONWebTokenFilter(AuthenticationManager authenticationManager) { super(authenticationManager); } protected void doFilterInternal(httpervletRequest request, httpervletResponse response, FilterChain chain) throws IOException, ServletException { String header = getHeaderFromRequest(request); UsernamePasswordAuthenticationToken authResult = getAuthenticationByToken(header); SecurityContextHolder.getContext().setAuthentication(authResult); chain.doFilter(request,response); } private UsernamePasswordAuthenticationToken getAuthenticationByToken(String header) { final String BEARER = "Bearer "; final String EMPTY_STRING = ""; final byte[] KEY = "1a2b3c".getBytes(); Jws<Claims> claimsJws = Jwts.parser().setSigningKey(KEY).parseClaimsJws(header.replace(BEARER,EMPTY_STRING)); Tuple3 getAllClaims = getClaimsJws(claimsJws); String username = getAllClaims._1.toString(); String password = getAllClaims._2.toString(); String role = getAllClaims._3.toString(); UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password, getAllAuthorities(role)); return usernamePasswordAuthenticationToken; } private String getHeaderFromRequest(httpervletRequest request) { return request.getHeader("Authorization"); } private Tuple3<String, String, String> getClaimsJws(Jws<Claims> claimsJws) { String username = claimsJws.getBody().get("username").toString(); String password = claimsJws.getBody().get("password").toString(); String role = claimsJws.getBody().get("role").toString(); return Tuple.of(username, password, role); } private Set<SimpleGrantedAuthority> getAllAuthorities(String role) { return Collections.singleton(new SimpleGrantedAuthority(role)); } }
Klasa konfiguracyjna Spring Security wygląda następująco – należy do konfiguracji dodać wcześniej utworzony filtr:
@Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder getPasswordEncoder(){ return new BCryptPasswordEncoder(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication().withUser(User.builder() .username("admin") .password(getPasswordEncoder().encode("admin")) .roles("ADMIN")); }; // JWT configuration @Override protected void configure(httpecurity http) throws Exception { http.csrf().disable(); http.authorizeRequests() .antMatchers("/getSecuredMsg").hasAnyRole("ADMIN") .and() .addFilter(new JSONWebTokenFilter(authenticationManager())); } }
Testy aplikacji należy zacząć od wygenerowania odpowiedniego tokena na stronie:
http://jwt.io/
w następujący sposób:
W aplikacji Advanced Rest Client (lub za pomocą dowolnego klienta REST) wysyłamy żądanie:
Jako, że dane są prawidłowe dostęp jest przyznany. Podanie nieprawidłowego tokena zakończy się błędem:
{ "timestamp": "2019-10-31T08:42:55.946+0000", "status": 500, "error": "Internal Server Error", "message": "JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.", "path": "/getSecuredMsg" }
z kolei brak nagłówka Authorization zakończy się błędem:
{ "timestamp": "2019-10-31T08:44:49.156+0000", "status": 500, "error": "Internal Server Error", "message": "No message available", "path": "/getSecuredMsg" }
Dodajmy dodatkowy filtr który będzie służył do wyświetlania statusu odpowiedzi dla każdego żądania:
@Component public class MyFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { httpervletRequest req = (httpervletRequest) request; httpervletResponse res = (httpervletResponse) response; System.out.println("Request URI is: " + req.getRequestURI()); System.out.println("Response Status Code is: " + res.getStatus()); chain.doFilter(request, response); } }
przy dostępie do każdego zasobu otrzymamy na konsoli przykładowe wyniki:
Request URI is: /getSecuredMsg Response Status Code is: 200
Request URI is: /getMsg Response Status Code is: 200
Leave a comment