Monday, March 29, 2021

Autorização JWT Puro Java: parte 2

Na parte 1 delineamos o fluxo geral que as API que realizam autenticação utilizando JSON Web Token (JWT) deve seguir. Podem haver variações, mas o fluxo básico é aquele.

Neste artigo colocamos em prática o fluxo criando uma API com os seguintes serviços:


Nossa solução utiliza somente a JAX-RS 2.0 API, evitando completamente qualquer solução específica de terceiros, assim sendo, ela deve funcionar qualquer que seja a implementação JAX-RS utilizada (Jersey, Resteasy, Apache CXF, etc).


Projeto

O projeto chamado HelloAuthentication possui a seguinte estrutura:


Em primeiro lugar criamos uma anotação Name Binding chamada @Secured. Name Binding é um decorator para algum interceptor (neste caso um filtro de requests) de modo que todos os endpoints que forem marcados com @Secured terão seu acesso interceptado pelo filtro para checagens de segurança.


@NameBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Secured {

}

Conforme destacado em azul na estrutura do projeto, 3 classes são protagonistas nesse cenário:

1 AuthenticationFilter

Perceba que ela é decorada com a anotação que criamos @Secured:


@Secured
@Provider
@Priority(Priorities.AUTHENTICATION)
public class AuthenticationFilter implements ContainerRequestFilter {

    static final Logger LOG = Logger.getLogger(AuthenticationFilter.class.getName());
    private static final String SCOPE = "scope";
    private static final String AUTHENTICATION_SCHEME = "Bearer";

    @Override
    public void filter(ContainerRequestContext requestContext) {

        LOG.info("authentication filter");

        String authorizationHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);

        if (!isTokenBasedAuthentication(authorizationHeader)) {
            LOG.info("Not a token based authentication! Aborting request...");
            abortWithUnauthorized(requestContext);
            return;
        }

        try {
            validateToken(authorizationHeader);
        }
        catch (Exception e) {
            e.printStackTrace();
            abortWithUnauthorized(requestContext);
        }
    }

    private void validateToken(String authorizationHeader) throws Exception {

        LOG.info("Validating token...");

        String accessToken = authorizationHeader.substring(7);
        JWT jwt = new JWT(accessToken);

        if (!TokenValidation.isValid(jwt))
            throw new Exception("Invalid JWT");

    }

    private void abortWithUnauthorized(ContainerRequestContext requestContext) {
        requestContext.abortWith(
                    Response.status(HttpStatus.SC_UNAUTHORIZED)
                            .header(HttpHeaders.WWW_AUTHENTICATE,
                                                    AUTHENTICATION_SCHEME + " scope = " + SCOPE)
                            .build());
    }

    private boolean isTokenBasedAuthentication(String authorizationHeader) {

        if (authorizationHeader == null)
            return false;

        return authorizationHeader.startsWith(AUTHENTICATION_SCHEME + " ");
    }
}

Toda vez que algum endpoint anotado com @Secured for chamdo, antes, o método filter() será executado. Dentro dele faremos a checagem do token que deve estar contido dentro do header Authorization do request. 

Após a checagem, podemos decidir liberar o request ou abortá-lo.

2 Validação do Token

Verifica se o token não está expirado. Verifica a assinatura do token. 

A verificação do token não requer acesso ao banco de dados.


public class TokenValidation {

    public static boolean isValid(JWT jwt) {

        JSONObject payload = jwt.getPayload();
        boolean isTimeValid = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) < payload.getLong("exp");
        boolean isSignatureValid = isSignatureValid(jwt);

        return isTimeValid && isSignatureValid;
    }

    private static boolean isSignatureValid(JWT jwt) {

        String b64Data = jwt.getB64Header() + "." + jwt.getB64Payload();
        byte[] bytesSignature = TokenFactory.hs256(b64Data);

        String expectedSignature = encode(bytesSignature);

        return expectedSignature.equals(jwt.getSignature());
    }
}

3 TokenFactory

Essa classe é responsavel por gerar novos tokens, seguindo a especificação RFC 7519.

Uma das subtarefas de se gerar o token é gera a assinatura, portanto a classe TokenFactory acessa a chave secreta por meio da qual geramos o Message Authentication Code (MAC)



public class TokenFactory {

    // secrets should never be placed in code
    final static String SECRET_KEY = "qwertyuiopasdfghjklzxcvbnm0123456789";
    final static String ISSUER = "rafael.senior.engineer";
    final static String HEADER = "{\"alg\":\"HS256\", \"typ\":\"jwt\"}";

    public static JWT issueToken(Credentials credentials) {

        JWT jwt = new JWT();
        fillHeader(jwt);
        fillPayload(jwt, credentials);
        fillSignature(jwt);
        return jwt;
    }

    private static void fillSignature(JWT jwt) {

        byte[] encryptedSignature = hs256(jwt.getB64Header() +"."+ jwt.getB64Payload());
        jwt.setSignature(encode(encryptedSignature));
    }

    static byte[] hs256(String data) {

        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            byte[] secretKeyBytes = SECRET_KEY.getBytes(StandardCharsets.UTF_8);
            SecretKeySpec secretKey = new SecretKeySpec(secretKeyBytes, "HmacSHA256");
            mac.init(secretKey);

            byte[] encryptedData = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return encryptedData;
        }
        catch (NoSuchAlgorithmException | InvalidKeyException e) {
            e.printStackTrace();
            return null;
        }
    }

    private static void fillPayload(JWT jwt, Credentials credentials) {

        JSONObject payload = new JSONObject();
        payload.put("iss", ISSUER);
        payload.put("scope", credentials.getScope());
        payload.put("name", credentials.getUsername());
        payload.put("iat", LocalDateTime.now().toEpochSecond(ZoneOffset.UTC));
        payload.put("exp", LocalDateTime.now().plusMinutes(1).toEpochSecond(ZoneOffset.UTC));
        payload.put("jti", UUID.randomUUID().toString());

        jwt.setPayload(payload);
        jwt.setB64Payload(encode(payload.toString()));
        jwt.setExpires_in(String.valueOf(payload.getLong("exp")));
    }

    private static void fillHeader(JWT jwt) {
        jwt.setB64Header(encode(HEADER));
    }

    static String encode(String data) {
        return encode(data.getBytes(StandardCharsets.UTF_8));
    }

    static String encode(byte[] data) {
        return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
    }
}

O processo de verificar a autenticidade do token consiste simplesmente em regerar a assinatura do token recebido no request e comparar o resultado com a assinatura contida no mesmo token. Como ninguém além do servidor tem acesso a chave secreta, qualquer modificação no JWT realizada por terceiros fará com que o resultado da assinatura regerada pelo servidor seja completamente diferente da assinatura atualmente contida no token, dessa forma, invalidando-o.


Testes de Integração

Após publicar a API no container de aplicações (usamos o Wildfly 23), usamos JUnit 5 e RestAssured, para testar os principais cenários.

A classe AuthenticaitonTest testa os mecanismos de autenticação e geração do Token. Ela possui 4 testes, o quais devem ser executados em uma sequencia pré-definida de acordo anotação @Order:



@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class AuthenticationTest {

    private static Credentials credentials;
    private JWT jwt;

    @BeforeAll
    public void init() {
        credentials = new Credentials("daniel", "78910", "read_only");
    }

    @IntegrationTest
    @Order(1)
    public void shouldAuthenticate() {

        String path = "/auth/connect/token";

        Response response =
            given().baseUri(BASE_URI)
                .basePath(path)
                .contentType(ContentType.JSON)
                .request()
                .body(credentials)
                .log().all()
                .when().post()
                .then().log().all()
                .extract().response() ;

        jwt = response.jsonPath().getObject("$", JWT.class);
        Assertions.assertNotNull(jwt);
    }


    @IntegrationTest
    @Order(2)
    public void shouldBeAuthorizedByJWT() {

        given().baseUri(BASE_URI)
                .basePath("/service/{name}")
                .pathParam("name", "hello auth")
                .header(AUTHORIZATION, jwt.toStringForRequest())
                .request()
                .log().all()
                .when()
                .get()
                .peek()
                .then().assertThat().statusCode(SC_OK)
                .log().all();
    }

    @IntegrationTest
    @DisplayName("Should not authorize after modifying the token")
    @Order(3)
    public void shouldNotAuthorize() {

        modifyToken();

        given().baseUri(BASE_URI)
                .basePath("/service/{name}")
                .pathParam("name", "hello auth")
                .header(AUTHORIZATION, jwt.toStringForRequest())
                .request()
                .log().all()
                .when()
                .get()
                .peek()
                .then().assertThat().statusCode(SC_UNAUTHORIZED);
    }

    private void modifyToken() {

        String token = jwt.getAccess_token();
        char[] chars = token.toCharArray();
        chars[65] = 'p';
        token = String.valueOf(chars);
        jwt.setAccess_token(token);
    }

    @IntegrationTest
    @DisplayName("Should not authorize because token has expired")
    @Order(4)
    @Disabled("Disbled by default because this test takes 1 minute long, you can optionally enabled it")
    public void shouldNotAuthorizeDueToExpiration() throws InterruptedException {

        shouldAuthenticate();

        Thread.sleep(60000);

        SamplePayload payload = new SamplePayload(1, "Rafael");

        given().baseUri(BASE_URI)
                .basePath("/service/send")
                .header(AUTHORIZATION, jwt.toStringForRequest())
                .contentType(ContentType.JSON)
                .request().body(payload)
                .log().all()
                .when()
                .post()
                .peek()
                .then().assertThat().statusCode(SC_UNAUTHORIZED)
                .log().all();
    }
 }

Neste teste checamos a geração do token após os envio de credenciais:


Neste outro teste, modificamos o token e tantamos acessar o serviço, o qual deve retornar 401 UNAUTHORIZED



A classe ServiceApiTest testa os serviços criados:


package integration.api;

import annotations.IntegrationTest;
import br.com.app.api.model.SamplePayload;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.TestInstance;

import static io.restassured.RestAssured.given;
import static org.apache.http.HttpHeaders.AUTHORIZATION;
import static org.apache.http.HttpStatus.SC_OK;
import static util.Constants.BASE_URI;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class ServiceApiTest extends AbstractTest {

    @BeforeAll
    public void init() {
        setToken();
    }

    @IntegrationTest
    @DisplayName("should return 200 OK when send payload")
    public void test01() {

        SamplePayload payload = new SamplePayload(1, "Rafael");

        given().baseUri(BASE_URI)
                .basePath("/service/send")
                .header(AUTHORIZATION, jwt.toStringForRequest())
                .contentType(ContentType.JSON)
                .request().body(payload)
                .log().all()
                .when()
                .post()
                .peek()
                .then().assertThat().statusCode(SC_OK)
                .log().all();
    }

    @IntegrationTest
    @DisplayName("should return 200 OK when request with name path")
    public void test02() {

        given().baseUri(BASE_URI)
                .basePath("/service/{name}")
                .pathParam("name", "hello auth")
                .header(AUTHORIZATION, jwt.toStringForRequest())
                .request()
                .log().all()
                .when()
                .get()
                .peek()
                .then().assertThat().statusCode(SC_OK)
                .log().all();
    }
}

Neste print testamos o serviço POST /service/send. Repare que o token deve ser colocado no header AUTHORIZARION da requisição:



Ao todo são 7 testes de integração. Para rodá-los de uma vez utilize o comando  mvn integration-test:



O código completo do projeto está no git-hub.


       

No comments:

Post a Comment