Jak działa JSON Web Token w Java Rest API ?

Uwierzytelnianie jest jedną z najważniejszych części każdej aplikacji internetowej. Przez wiele lat rozwiązaniem były pliki cookie. Jednak obsługa uwierzytelniania za pomocą wielu źródeł jednocześnie może być  trudna. Najbardziej znanymi rozwiązaniami problemu uwierzytelnienia dla interfejsów API są OAuth2.0 i JWT czyli JSON Web Token.

Schemat działania tokena JWT

Czym jest JSON Web Token?

Zanim przejdziemy do przedstawienia rozwiązania wyjaśnijmy sobie czym jest dokładnie JWT. JWT to otwarty standard  (RFC 7519), który definiuje kompaktowy i samodzielny sposób bezpiecznego przesyłania informacji między stronami obiektu JSON. Ze względu na stosunkowo mały rozmiar, token JWT może być wysłany za pośrednictwem adresu URL, parametru POST lub wewnątrz nagłówka HTTP. JWT zawiera wszystkie wymagane informacje o encji, aby uniknąć wielokrotnego wysyłania zapytań do bazy danych. Odbiorca tokenu JWT również nie musi wywoływać serwera w celu sprawdzenia poprawności tokenu.

Do czego można wykorzystać Token JWT

  • Uwierzytelnianie: gdy użytkownik pomyślnie loguje się przy użyciu swoich poświadczeń zwracany jest token identyfikacyjny; 
  • Autoryzacja: po pomyślnym zalogowaniu użytkownika aplikacja może zażądać dostępu do usług lub zasobów (np. interfejsów API) w imieniu tego użytkownika. Aby to zrobić w każdym żądaniu musi przekazać token dostępu, który może mieć postać tokena JWT. Logowanie jednokrotne – SSO – szeroko używa JWT ze względu na niewielkie obciążenie formatu i możliwość łatwego używania w różnych domenach.
  • Wymiana informacji: JWT to dobry sposób bezpiecznego przesyłania informacji między stronami, ponieważ można je podpisać, co oznacza że możemy mieć pewność, iż nadawcy są tymi, za kogo się podają. Ponadto struktura tokena JWT umożliwia sprawdzenie czy zawartość nie została naruszona.

Uwierzytelnianie za pomocą JWT 

Czas najwyższy skonfigurować Spring Security do uwierzytelniania bezstanowego za pomocą tokena JWT. Aby dostosować Spring Security, potrzebujemy klasy konfiguracyjnej opatrzonej adnotacją @EnableWebSecurity. Ponadto, aby uprościć proces dostosowywania, platforma udostępnia klasę WebSecurityConfigurerAdapter. Rozszerzymy ten adapter i zastąpimy jego obie funkcje aby:

  1. Skonfigurować menedżera uwierzytelniania z odpowiednim dostawcą.
  2. Skonfigurować zabezpieczenia dla odpowiednich adresów (publiczne, prywatne, autoryzacja itp).
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
}
@Override
protected void configure(HttpSecurity http) throws Exception {
}
}

W naszej przykładowej aplikacji przechowujemy tożsamości użytkowników w bazie danych MongoDB w kolekcji users. Tożsamości te są mapowane przez encję User, a operacje CRUD definiowane przez UserRepo .
Teraz, gdy zaakceptujemy żądanie uwierzytelnienia, musimy pobrać poprawną tożsamość z bazy danych za pomocą dostarczonych danych uwierzytelniających, a następnie ją zweryfikować. W tym celu potrzebujemy implementacji interfejsu UserDetailsService, zdefiniowanego w następujący sposób:

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException;
}

Zwróćmy uwagę, że wymagane jest zwrócenie obiektu, implementowanego przez interfejs UserDetails. Nasza encja User go implementuje (szczegóły implementacji można znaleźć w repozytorium przykładowego projektu). 

Po skonfigurowaniu menedżera uwierzytelniania, musimy teraz skonfigurować zabezpieczenia sieciowe. Wdrażamy REST API i potrzebujemy uwierzytelniania bezstanowego za pomocą tokena JWT, dlatego musimy ustawić następujące opcje:

  • włącz CORS i wyłącz CSRF;
  • zarządzanie sesjami na bezstanowe;
  • obsługa wyjątków nieautoryzowanych żądań;
  • uprawnienia w punktach końcowych;
  • dodaj filtr tokenów JWT.

Tak wygląda przykładowa implementacja interfejsu WebSecurityConfigurerAdapter:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(username -> userRepo
        .findByEmail(username)
        .orElseThrow(
            () -> new UsernameNotFoundException(
                format("User: %s, not found", username)
            )
        )).passwordEncoder(new BCryptPasswordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
    http = http.cors().and().csrf().disable();
    http = http
        .sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and();
    http = http
        .exceptionHandling()
        .authenticationEntryPoint(
            (request, response, ex) -> {
                logger.error("Unauthorized request - {}", ex.getMessage());
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage());
            }
        )
        .and();
    http.authorizeRequests()
        .antMatchers("/").permitAll()
        .antMatchers(format("%s/**", restApiDocPath)).permitAll()
        .antMatchers(format("%s/**", swaggerPath)).permitAll()
        .antMatchers("/api/auth/**").permitAll()
        .anyRequest().permitAll();
    http.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
}

Przed wdrożeniem funkcji API logowania musimy zadbać o jeszcze jeden krok – potrzebujemy dostępu do menedżera uwierzytelniania. Domyślnie nie jest publicznie dostępny i musimy jawnie przedstawić go jako bean w naszej klasie konfiguracyjnej SecurityConfig.

Dlaczego refresh tokenów jest potrzebny

Po pomyślnym zalogowaniu użytkownika odsyłamy token dostępu. Powiedzmy, że ma ważność 15 minut. W tym czasie może być używany przez użytkownika do uwierzytelniania podczas wysyłania różnych żądań do naszego API.

Po upływie czasu i wygaśnięciu tokena użytkownik musi się zalogować ponownie, podając nazwę użytkownika i hasło. Niestety, nie zapewnia to najlepszego doświadczenia użytkownika. Z drugiej strony wydłużenie czasu wygaśnięcia naszego tokena dostępowego może sprawić, że nasze API będzie mniej bezpieczne.

Rozwiązaniem powyższego problemu mogą być  tokeny odświeżania. Podstawowa idea polega na tym, że po udanym logowaniu tworzymy dwa oddzielne tokeny JWT. Jeden to token dostępu, który jest ważny przez 15 minut. Drugi to np. refresh token, który wygasa na przykład co tydzień.

Token JWT może być wysłany za pośrednictwem adresu URL.
JWT może być wysłany za pośrednictwem adresu URL

Jak działa refresh token

Użytkownik zapisuje oba tokeny, ale używa tylko tokena dostępu do uwierzytelniania podczas składania żądań. Działa bez problemów przez 15 minut. Gdy interfejs API stwierdzi, że token dostępu wygaśnie, użytkownik musi wykonać odświeżenie.

Aby odświeżyć token, użytkownik musi wywołać oddzielny punkt końcowy o nazwie  /refresh. Jeśli jest ważny i nie wygasł, użytkownik otrzymuje nowy token dostępu. Dzięki temu nie ma potrzeby ponownego podawania nazwy użytkownika i hasła.

Umożliwienie logowania na wielu urządzeniach

Załóżmy że chcemy udostępnić API, do którego użytkownik chce mieć dostęp z wielu urządzeń jednocześnie telefon, tablet, laptop.

Możemy rozwiązać powyższy problem zapisując w bazie danych zbiór refresh tokenów (jeden refresh token na logowanie), dzięki czemu każde urządzenie nie będzie automatyczne wylogowywane przy logowaniu kolejnego urządzenia.

@Service
@RequiredArgsConstructor
public class RefreshTokenService {
    @Value("${devopsijwt.app.jwtRefreshExpirationMs}")
    private Long refreshTokenDurationMs;
    private final RefreshTokenRepository refreshTokenRepository;
    private final UserRepo userRepository;
    private final JwtTokenUtil jwtTokenUtil;
    public Optional<RefreshToken> findByToken(String token) {
        return refreshTokenRepository.findByToken(token);
    }
    @Transactional
    public RefreshToken createRefreshToken(ObjectId userId) {
        return userRepository.findById(userId).map
            (user -> refreshTokenRepository.save(RefreshToken.builder()
                .expiryDate(Instant.now().plusMillis(refreshTokenDurationMs))
                .userId(user.getId())
                .token(UUID.randomUUID().toString())
                .build())
            )
            .orElseThrow(() -> new NotFoundException("User not found with id " + userId.toHexString()));
    }
    public RefreshToken verifyExpiration(RefreshToken token) {
        if (token.getExpiryDate().compareTo(Instant.now()) < 0) {
            refreshTokenRepository.delete(token);
            throw new TokenRefreshException(token.getToken(), "Refresh token was expired. Please make a new signin request");
        }
        return token;
    }
}

A teraz jesteśmy gotowi do wdrożenia naszej funkcji logowania:

@RestController
@RequestMapping(path = "/api/auth")
@RequiredArgsConstructor
public class AuthApiController {
    private final AuthenticationManager authenticationManager;
    private final JwtTokenUtil jwtTokenUtil;
    private final UserService userService;
    private final RefreshTokenService refreshTokenService;
    @PostMapping("/sign-in")
    public ResponseEntity<JwtResponse> login(@RequestBody @Valid AuthRequest request) {
        try {
            Authentication authenticate = authenticationManager
                .authenticate(new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()));
            User userDetails = (User) authenticate.getPrincipal();
            RefreshToken refreshToken = refreshTokenService.createRefreshToken(userDetails.getId());
            return ResponseEntity.ok()
                .body(new JwtResponse(jwtTokenUtil.generateAccessToken(userDetails), refreshToken.getToken()));
        } catch (BadCredentialsException ex) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
    }
}

Tutaj weryfikujemy podane poświadczenia za pomocą menedżera uwierzytelniania, a w przypadku powodzenia generujemy JSON Web Token i zwracamy go jako nagłówek odpowiedzi wraz z informacjami o tożsamości użytkownika w treści odpowiedzi, oraz refresh token.
W tym artykule starałem się zademonstrować szczegóły konfiguracji i mam nadzieję, że komuś okażą się przydatne. Pełne przykłady kodu można znaleźć w repozytorium Git DevOpsi https://github.com/devopsipl/jwt_example.