JWT w Spring Boot 2.7

Stworzymy proste REST API umożliwiające zalogowanie się użytkownika do serwisu oraz udostępniające kilka endpointów, do których dostęp będzie wymagał autoryzacji.

Po udanej autoryzacji zostanie zwrócony JSON Web Token, którego przesłanie w nagłówku żądania HTTP będzie wymagane podczas dostępu do chronionych endpointów.

Kod źródłowy projektu znajdziesz na GitHubie: https://github.com/javascratches/jwt-rest-api

Zdefniujmy kontroler udostępniający endpoint logowania

@RestController
@RequestMapping("/login")
@RequiredArgsConstructor
public class LoginController {

    private final AuthenticationManager authenticationManager;
    private final TokenService tokenService;

    @PostMapping
    public TokenResponse login(@RequestBody LoginRequest loginRequest) {
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                loginRequest.getUsername(),
                loginRequest.getPassword());
        authenticationManager.authenticate(authentication);

        return new TokenResponse(tokenService.generateToken(loginRequest.getUsername()));
    }

}

Użytkownik przekazany w parametrach żądania jest autoryzowany za pomocą authenticationManager. W przypadku udanej autoryzacji generowany jest token za pomocą metody tokenService.generateToken. Token zawiera nazwę użytkownika, datę utworzenia i ważności. Podpisywany jest kluczem, którego wartość ustawiono w konfiguracji.

public String generateToken(String username) {
    Map<String, Object> claims = new HashMap<>();
    return doGenerateToken(claims, username);
}

private String doGenerateToken(Map<String, Object> claims, String subject) {
    long currentTime = System.currentTimeMillis();
    return Jwts.builder()
            .setClaims(claims)
            .setSubject(subject)
            .setIssuedAt(new Date(currentTime))
            .setExpiration(new Date(currentTime + validity * 1000))
            .signWith(SignatureAlgorithm.HS512, secret)
            .compact();

Konfiguracja Spring Security oparta jest o klasy konfiguracyjne (począwszy od wersji Spring Boot 2.7, konfiguracja z wykorzystaniem WebSecurityConfigurerAdapter jest deprecated).

Bean AuthenticationManager, wstrzykiwany w kontrolerze dostarczany jest przez klasę konfiguracyjną:

@Configuration
public class AuthenticationManagerConfig {

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

}

Manager użytkowników oparty na InMemoryUserDetailsManager:

@Configuration
@RequiredArgsConstructor
public class UserDetailsManagerConfig {

    private final PasswordEncoder passwordEncoder;

    @Bean
    public InMemoryUserDetailsManager userDetailsManager() {
        UserDetails user = User.withUsername("user")
                .password(passwordEncoder.encode("sekret1"))
                .roles("USER")
                .build();
        UserDetails admin = User.withUsername("admin")
                .password(passwordEncoder.encode("sekret2"))
                .roles("ADMIN")
                .build();

        return new InMemoryUserDetailsManager(user, admin);
    }
}

Konfiguracja dostępu do endpointów i filtrowania:

@Configuration
@RequiredArgsConstructor
public class SpringSecurityConfig {

    private final JwtRequestFilter jwtRequestFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authz -> authz
                        .antMatchers("/login").permitAll()
                        .anyRequest().authenticated())
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
    
}

Jak widzisz, wszystkie endpointy oprócz /login są chronione autoryzacją. Dodany jest również filtr zdefiniowany w jwtRequestFilter. Filtr ten odpowiedzialny jest za sprawdzenie poprawności przesyłanego tokenu.

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtRequestFilter extends OncePerRequestFilter {

    private final TokenService tokenService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        String requestTokenHeader = request.getHeader("Authorization");

        String username = null;
        String jwtToken = null;

        // Pobieramy token z nagłówka
        if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
            jwtToken = requestTokenHeader.substring(7);
            try {
                username = tokenService.getUsernameFromToken(jwtToken);
            } catch (IllegalArgumentException e) {
                log.error("Unable to get JWT Token");
            } catch (ExpiredJwtException e) {
                log.error("JWT Token has expired");
            }
        }

        // Walidacja poprawności tokena
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            if (tokenService.validateToken(jwtToken, userDetails)) {
                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                        userDetails, userDetails.getPassword(), userDetails.getAuthorities());
                usernamePasswordAuthenticationToken
                        .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }

        chain.doFilter(request, response);
    }

}

Walidacja polega na pobraniu i sprawdzeniu poprawności nazwy użytkownika przesłanej w tokenie. Metoda tokenService.validateToken sprawdza również, czy token jest nadal ważny.

Pozostał nam jeszcze kontroler udostępniający prosty endpoint, którego zadaniem będzie zwrócenie komunikatu dla zalogowanego użytkownika:

@RestController
@RequestMapping("/hello")
public class HelloController {

    @GetMapping
    public HelloResponse hello(@AuthenticationPrincipal User user) {
        return new HelloResponse("Hello " + user.getUsername());
    }
}

Przetestujmy endpoint /hello bez zalogowania się.

curl -i -X GET http://localhost:8080/hello

HTTP/1.1 403
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Sun, 29 May 2022 09:11:10 GMT

Dostaliśmy błąd 403 Forbidden.

Zalogujmy się jako użytkownik admin:

curl -i -X POST http://localhost:8080/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"sekret2"}'

Odpowiedź zawiera token:

HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 29 May 2022 09:13:58 GMT
{"token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTY1MzgxNTY5OCwiaWF0IjoxNjUzODE1NjM4fQ.rK4imjlT4WSzClMTNtBQkl29CQvK-NFXG3CZ3ltaQeMgLMYMEaFzlNRxMBnoYH28R3f1_g4SX4J-UwNRgOGDQg"}

Otrzymany token dołączamy do nagłówka żądania:

curl -i -X GET http://localhost:8080/hello \
  -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTY1MzgxNTgzOCwiaWF0IjoxNjUzODE1Nzc4fQ.1YfHz6jmjl1UsX3AGznMMJGxp4wtn7Y11ltSVquWBV0gGHRn7U1L686gWGJWSdmRiIisgizMmJXfBftFVPt_Lg'

Tym razem otrzymujemy status 200:

HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 29 May 2022 09:16:36 GMT

{"message":"Hello admin"}

Znając klucz, możesz wygenerować token na stronie jwt.io

Generując token pamiętaj aby ustawić właściwy czas wygenerowania iat i ważności tokena exp. Pole sub zawiera nazwę użytkownika. Czas podajemy w formacie epoch – możesz skorzystać ze strony https://www.epochconverter.com/