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 Authenticationwykorzystanie 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 danychJWT 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:

Znalezione obrazy dla zapytania jwt example

[źródło] https://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:

token authentication vs. cookie diagram

[źródło] https://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(HttpSecurity 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(HttpServletRequest request, HttpServletResponse 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(HttpServletRequest 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(HttpSecurity 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:

https://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 {

		HttpServletRequest req  = (HttpServletRequest) request;
		HttpServletResponse res = (HttpServletResponse) 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

Your email address will not be published.


*