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: