Ostatnio tak wciągnął mnie temat aplikacji do monitorowania statków, że nie robię nic innego w wolnym czasie poza jej implementacją. Nawet seria związana z AnimalShelter poszła w odstawkę, chociaż mam nadzieję, że nie na długo. Na tą chwilę w BarentsWatch, bo tak nazwałem swój projekt, mam działającą część serwerową i teraz wszystkie siły rzuciłem na frontend. Postanowiłem, że będzie to aplikacja webowa przeznaczona tylko dla użytkowników PC. Jednak zmierzając do sedna tytułu tego artykułu to zamierzam przedstawić w tym wpisie problem jaki napotkałem podczas implementacji uwierzytelnienia z wykorzystaniem JWT.

Informowanie użytkownika co się stało z tokenem

Postawiłem sobie za cel, żeby poinformować użytkownika o tym co jest nie tak z jego tokenem. Na samym początku stworzyłem odpowiedni endpoint do generowania tokenów. Do obsługi żądania niezbędne jest podanie danych w postaci emaila oraz hasła. Nie chciałem umieszczać logiki weryfikującej czy dany użytkownik jest obecny w bazie danych w kontrolerze. Z tego powodu zdecydowałem się na zaimplementowanie adnotacji @VerifyClient delegując to zadanie do niej. Dzięki temu kod wygląda następująco.

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
import io.smallrye.jwt.build.Jwt;

import javax.inject.Inject;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("/barentswatch/client")
public class ClientResource {

  @Inject
  ClientRepository clientRepository;

  @POST
  @Consumes(MediaType.APPLICATION_JSON)
  @Produces(MediaType.APPLICATION_JSON)
  @Path("/token")
  @VerifyClient
  public TokenResponse generateAccessToken(LoginRequest request) {
    Client client = clientRepository.find("email", request.email()).firstResult();

    String token = Jwt.subject(client.email)
        .groups(client.role)
        .sign();

    return new TokenResponse(token);
  }
}

record LoginRequest(String email, String password) {}

record TokenResponse(String token) {}
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
import io.quarkus.elytron.security.common.BcryptUtil;

import javax.inject.Inject;
import javax.interceptor.AroundInvoke;
import javax.interceptor.Interceptor;
import javax.interceptor.InvocationContext;

@VerifyClient
@Interceptor
public class VerifyClientInterceptor {

  @Inject
  ClientRepository clientRepository;

  @AroundInvoke
  Object verifyClientInvocation(InvocationContext context) throws Exception {
    LoginRequest request = (LoginRequest) context.getParameters()[0];
    Client client = clientRepository.find("email", request.email()).firstResult();

    if (client == null || !BcryptUtil.matches(request.password(), client.password)) {
      throw new FailedClientVerificationException("invalid_client");
    }

    return context.proceed();
  }
}

Przejdźmy teraz krok po kroku jak wygląda przebieg obsługi takiego żądania. Po pierwsze sprawdzam czy istnieje użytkownik o podanym emailu i czy jego hasło pasuje do przesłanego. Jeśli nie to rzucany jest wyjątek, który także ma swoją obsługę w osobnej klasie (zwracany jest status HTTP 400). W innym przypadku przechodzimy dalej i generujemy token umiejscawiając w nim podmiot oraz role, a także emitenta oraz czas wygaśnięcia (dwa ostatnie są zdefiniowane w application.properties). Jeśli teraz wywołam odpowiedni endpoint podlegający uwierzytelnieniu i coś zmienię w tokenie to otrzymam niewiele mówiącą odpowiedź 401. Nie mam wiedzy czy jest on niewłaściwy, przeterminowany czy ma nieodpowiednią strukturę.

Nic niemówiąca odpowiedź HTTP 401
Odpowiedź z serwera nic nam nie mówi poza statusem HTTP 401

Nawet w logach aplikacji nie ma żadnej informacji o tym co poszło nie tak. Dopiero ponowne uruchomienie aplikacji z logami na poziomie DEBUG daje nam pogląd na sytuację. Jeśli np. token wygaśnie to otrzymamy taki oto komunikat.

Wyjątek informujący o nieważności tokenu
Wyjątek informujący o nieważności tokenu ukazuje się dopiero w trybie DEBUG

Co można, więc zrobić w takiej sytuacji? Oczywiście dodałem klasę przechwytującą odpowiedni wyjątek przed przekazaniem informacji zwrotnej użytkownikowi (coś jak @ControllerAdvice i @ExceptionHandler w Spring).

Implementacja AuthenticationFailedHandler

W celu zbadania na jaki dokładnie wyjątek powinienem się zapiąć uruchomiłem debugger i wysyłałem żądanie z nieprawidłowym tokenem. Po długiej analizie dotarłem do tego, że musi to być AuthenticationFailedException. Ucieszony tym odkryciem od razu zabrałem się za implementację i stworzyłem następujący kod.

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import io.quarkus.arc.Priority;
import io.quarkus.security.AuthenticationFailedException;
import io.smallrye.jwt.auth.principal.ParseException;
import org.jose4j.jwt.consumer.InvalidJwtException;

import javax.ws.rs.Priorities;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Provider
@Priority(Priorities.AUTHENTICATION)
public class AuthenticationFailedHandler implements ExceptionMapper<AuthenticationFailedException> {

  @Override
  public Response toResponse(AuthenticationFailedException exception) {
    var response = Response.status(Response.Status.UNAUTHORIZED)
        .header("WWW-Authenticate", "Bearer error=\"invalid_token\"");

    if (exception.getCause() instanceof ParseException parseException) {
      if (parseException.getCause() instanceof InvalidJwtException invalidJwtException) {
        List<String> errorMessages = invalidJwtException.getErrorDetails().stream()
            .map(error -> ErrorCode.findErrorCodeBy(error.getErrorCode()))
            .filter(Objects::nonNull)
            .map(errorCode -> errorCode.message)
            .collect(Collectors.toList());
        response.entity(new AuthenticationResponse(errorMessages));
      }
    }

    return response.build();
  }
}

record AuthenticationResponse(List<String> errors) {}

enum ErrorCode {
  INVALID_TOKEN(9, "invalid_token"),
  EXPIRED_TOKEN(1, "expired_token"),
  UNPARSABLE_TOKEN(17, "unparsable_token");

  ErrorCode(int errorCode, String message) {
    this.errorCode = errorCode;
    this.message = message;
  }

  int errorCode;
  String message;

  static ErrorCode findErrorCodeBy(int code) {
    return Stream.of(values())
        .filter(errorCode -> errorCode.errorCode == code)
        .findFirst()
        .orElse(null);
  }
}

Zaimplementowałem własny typ wyliczeniowy z odpowiednimi kodami błędów (zdefiniowane są w klasie org.jose4j.jwt.consumer.ErrorCodes) i odpowiadającymi im wiadomościami dla użytkownika. Analizując strukturę zawierania się błędów powstały dwa sprawdzenia: czy pierwszym zagnieżdżonym błędem jest ParseException, a następnym InvalidJwtException. Potem pobierane są zaistniałe kody błędów, które mapowane są na enum ErrorCode i generowana jest lista wiadomości dla użytkownika. Wszystko wygląda pięknie jednak po uruchomieniu aplikacji i ponownym wysłaniu nieprawidłowego tokenu dalej uzyskujemy tylko status HTTP 401 bez żadnej informacji zwrotnej. Czy o czymś zapomniałem?

Jak trwoga to do… dokumentacji

Uznałem, że to najwyższa pora, aby zajrzeć na stronę Quarkusa. Po lekturze jednej z części dostępnego tam przewodnika dowiedziałem się, że zapomniałem o istotnym szczególe. Należało ustawić w application.properties następującą właściwość quarkus.http.auth.proactive=false i wszystko zaczęło działać jak należy!

Odpowiedź z serwera o statusie HTTP 401 i odpowiedzią o przedawnionym tokenie
Odpowiedź z serwera o statusie HTTP 401 i odpowiedzią o przedawnionym tokenie

Czym w sumie jest ten parametr? Quarkus domyślnie ma uruchomione proaktywne uwierzytelnienie. Oznacza to, że jeśli przychodzące żądanie posiada dane logowania to zawsze będzie podlegało uwierzytelnieniu, nawet pomimo tego, że docelowy endpoint jej nie wymaga. Jeśli natomiast wyłączymy wcześniej przedstawioną flagę to proces ten uruchomi się tylko w przypadku, gdy będzie wymagana weryfikacja tożsamości. Jednak jak to się ma do naszego przypadku?

Sprawdzenie uwierzytelnienia wykonuje się domyślnie przed łańcuchem wywołań JAX-RS. Natomiast wyłączenie proaktywności zamienia ten proces miejscami. Taka kolejność umożliwia użycie ExceptionMapper do złapania wyjątków uwierzytelnienia Quarkusa takich właśnie jak AuthenticationFailedException.

Podsumowanie

Uczymy się cały czas. Niby człowiek wie, że należy czytać dokładnie o narzędziach, z których się korzysta, a jednak i tak często tego nie robi. Może to wynika z tego, że wpada się w wir pracy i po prostu mknie się do przodu, aby tylko coś nowego zaimplementować. Ma to na pewno dobre i złe strony. Jak widać czasem można się zapędzić i nie zauważyć drobnego szczegółu, który jest nad wyraz istotny. Mam nadzieję, że ten wpis pomoże komuś kto natknął się na podobny problem.