Autenticando servicios REST con OAuth2

Autenticando servicios REST con OAuth2

Juan Carlos González, Backend Software Engineer at Sngular

Juan Carlos González

Backend Software Engineer at Sngular

3 de agosto de 2021

1. Introducción

A la hora de añadir la autorización para llamar a servicios protegidos, nos damos cuenta no sólo de que la configuración cambia dependiendo del framework que vayamos a utilizar, sino que para cada cliente HTTP que utilicemos, debemos configurar OAuth2 de forma diferente.

Por ello, lo más sencillo a la hora de implementar una capa de autorización a través de OAuth2 para llamar a esos servicios, sería externalizar la generación de los tokens a un nuevo cliente personalizado. De esta forma tendríamos una integración mantenible y aislada del cliente REST que estamos utilizando.

Este artículo te guiará en la creación de una sencilla librería que te permitirá otorgar a tus peticiones HTTP el token de autorización necesario, e integrar en tus servicios cualquier cliente que utilices.

Diseno-sin-titulo-8

El flujo de autorización se describe en la imagen anterior incluye:

  1. La solicitud de autorización se envía desde el cliente al servidor OAuth.
  2. El token de acceso se devuelve al cliente.
  3. El token de acceso se envía desde el cliente al servicio API (que actúa como servidor de recursos) en cada solicitud de acceso a un recurso protegido.
  4. El servidor de recursos comprueba el token con el servidor OAuth, para confirmar que el cliente está autorizado a consumir dicho recurso.
  5. El servidor responde con los recursos protegidos solicitados.

2. Configuración de las dependencias necesarias

Necesitaremos algunas bibliotecas para construir nuestro cliente OAuth2 personalizado.

En primer lugar, la biblioteca del cliente HTTP de Apache, que nos proporcionará el cliente HTTP para la integración con el servidor de autorización, así como un conjunto de herramientas para la construcción de peticiones. Esta sería la librería principal de nuestro cliente.

En segundo lugar, encontramos otra biblioteca de Apache, llamada cxf-rt-rs-security-oauth2. En este caso, esta dependencia sería opcional, ya que sólo necesitamos un conjunto de valores predefinidos en la definición del protocolo OAuth2, recogidos en la clase OAuthConstants. También podríamos definir esos valores nosotros mismos, para evitar esta dependencia.Por último, incluimos la librería json. Esta librería es un conjunto de herramientas útiles cuando manejamos datos JSON. Es realmente útil para analizar y manipular JSON en Java.

dependencies

3. Creación de la solicitud OAuth2

Tenemos que construir la petición al servidor que autorizará nuestro servicio como cliente. Para lograr esto, necesitamos definir la configuración de OAuth2 que estamos usando, incluyendo el grant type, la URL del servidor de autorización, las credenciales para el grant type especificado, y el scope para el recurso que estamos solicitando.

class OAuth2Config {
  String grantType;
  String clientId;
  String clientSecret;
  String username;
  String password;
  String accessTokenUri;
  String scope;
}

Una vez iniciados los valores de configuración, podemos usarlos para construir la petición HTTP para el servidor de autorización. Normalmente, el método HTTP utilizado para obtener el token de acceso, será un POST, como se define en la especificación del Protocolo de Autorización OAuth 2.0:

El cliente DEBE utilizar el método HTTP "POST" cuando realice solicitudes de token de acceso.

Dependiendo del grant type que definamos, debemos definir diferentes parámetros en la petición POST. Utilizaremos una lista de NameValuePair para reunir todos esos parámetros necesarios.

HttpUriRequest buildRequest() {
  List<namevaluepair> formData = new ArrayList<>();
  formData.add(new BasicNameValuePair(GRANT_TYPE, config.getGrantType()));

  if (config.getScope() != null && !config.getScope().isBlank()) {
    formData.add(new BasicNameValuePair(SCOPE, config.getScope()));
  }

  if (CLIENT_CREDENTIALS_GRANT.equals(config.getGrantType())) {
    formData.add(new BasicNameValuePair(CLIENT_ID, config.getClientId()));
    formData.add(new BasicNameValuePair(CLIENT_SECRET, config.getClientSecret()));
  }

  if (RESOURCE_OWNER_GRANT.equals(config.getGrantType())) {
    formData.add(new BasicNameValuePair(RESOURCE_OWNER_NAME, config.getUsername()));
    formData.add(new BasicNameValuePair(RESOURCE_OWNER_PASSWORD, config.getPassword()));
  }

  return RequestBuilder.create(HttpPost.METHOD_NAME)
                       .setUri(config.getAccessTokenUri())
                       .setEntity(new UrlEncodedFormEntity(formData, StandardCharsets.UTF_8))
                       .build();
}
</namevaluepair>

4. Ejecutar la solicitud OAuth2

Puesto que estamos construyendo un cliente OAuth2 lo más básico posible, utilizaremos el cliente HTTP por defecto de la librería Apache HTTP para enviar nuestra petición al servidor de autorización.

CloseableHttpResponse doRequest(HttpUriRequest request) {
  CloseableHttpClient httpClient = HttpClients.createDefault();
  try {
    return httpClient.execute(request);
  } catch (IOException e) {
    throw new OAuth2ClientException("An error occurred executing the request.", e);
  }
}

Una vez que recibimos la respuesta, tenemos que procesarla, extrayendo la información que necesitamos para el token de acceso.

class OAuth2Response {
  HttpEntity httpEntity;
}

Debemos comprobar si hay errores antes de analizar el contenido para obtener el token de acceso. Podemos revisar aquí errores en las credenciales que hemos definido, una URL errónea o mal escrita, o cualquier error interno del servidor de autorización.

OAuth2Response execute(HttpUriRequest request) {
  CloseableHttpResponse httpResponse = doRequest(request);
  HttpEntity httpEntity              = httpResponse.getEntity();
  int statusCode                     = httpResponse.getStatusLine()
                                                   .getStatusCode();
  if (statusCode >= 400) {
    throw new OAuth2ClientException(statusCode, httpEntity);
  }
  return new OAuth2Response(httpEntity);
}OAuth2Response execute(HttpUriRequest request) {
  CloseableHttpResponse httpResponse = doRequest(request);
  HttpEntity httpEntity              = httpResponse.getEntity();
  int statusCode                     = httpResponse.getStatusLine()
                                                   .getStatusCode();
  if (statusCode >= 400) {
    throw new OAuth2ClientException(statusCode, httpEntity);
  }
  return new OAuth2Response(httpEntity);
}

No debemos olvidar cerrar el httpResponse, para evitar la fuga de memoria. Pero es bastante importante esperar a que se lea correctamente, ya que contiene un InputStream que quedaría inaccesible una vez lo hayamos cerrado.

Normalmente, el contenido de la respuesta vendrá en formato JSON, con los datos del token de acceso en un esquema clave-valor. Sin embargo, deberíamos contar con un servidor que maneje los datos en un formato diferente, como XML o URL codificado.Para este artículo, consideraremos que nuestro servidor de autorización nos está dando un contenido con formato JSON. La biblioteca org.json:json, la cual incluimos anteriormente, nos ayudará en la deserialización.

JSONObject handleResponse(HttpEntity entity) {
  String content     = extractEntityContent(entity);
  String contentType = Optional.ofNullable(entity.getContentType())
                               .map(Header::getValue)
                               .orElse(APPLICATION_JSON.getMimeType());
  return new JSONObject(content);
}

String extractEntityContent(HttpEntity entity) {
  try {
    return EntityUtils.toString(entity, StandardCharsets.UTF_8);
  } catch (IOException e) {
    throw new OAuth2ClientException("An error occurred while extracting entity content.", e);
  }
}

Si tenemos el JSONObject, se hace mucho más fácil manejar la respuesta, ya que podemos recuperar instantáneamente cada valor que nos interesa.

5. Ponerlo todo junto

El objetivo aquí es obtener un token de acceso para llamar a los servicios seguros que necesitamos. Sin embargo, a veces también necesitamos conocer algunos datos adicionales, como la fecha de caducidad del token, el tipo de token que estamos recibiendo, o el token de refresco en el caso de que el grant type esté definido así.

class AccessToken {
  long expiresIn;
  String tokenType;
  String refreshToken;
  String accessToken;

  AccessToken(JSONObject jsonObject) {
    expiresIn    = jsonObject.optLong(ACCESS_TOKEN_EXPIRES_IN);
    tokenType    = jsonObject.optString(ACCESS_TOKEN_TYPE);
    refreshToken = jsonObject.optString(REFRESH_TOKEN);
    accessToken  = jsonObject.optString(ACCESS_TOKEN);
  }
}

Por último, obtendremos un cliente que recuperará los datos de los tokens de acceso necesarios para realizar nuestras llamadas a los servicios, en función de la configuración que hayamos definido.

AccessToken accessToken() {
  HttpUriRequest request  = buildRequest();
  OAuth2Response response = execute(request);

  return new AccessToken(handleResponse(response.getHttpEntity()));
}

6. Ponerlo en práctica

Pero, ¿cómo podríamos integrar este cliente personalizado en nuestro servicio?

Bueno, como mencionamos al principio del artículo, la idea de este cliente OAuth2 personalizado es que sea independiente del framework y/o del cliente HTTP que estemos usando para consumir los servicios seguros.

Así que vamos a mostrar algunos ejemplos de cómo integrarlo en diferentes entornos de servicios.

6.1. Spring Framework — WebClient

class WebClientConfig {

  @Bean(name = "securedWebClient")
  WebClient fetchWebClient(@Value("${host}") String host,
                           OAuth2Config oAuth2Config) {
    OAuth2Client oAuth2Client = OAuth2Client.withConfig(oAuth2Config).build();
    return WebClient.builder()
                    .filter(new OAuth2ExchangeFilter(oAuth2Client))
                    .baseUrl(host)
                    .build();
  }

  @Bean
  @ConfigurationProperties(prefix = "security.oauth2.config")
  OAuth2Config oAuth2Config() {
    return new OAuth2Config();
  }

  class OAuth2ExchangeFilter implements ExchangeFilterFunction {
    
    OAuth2Client oAuth2Client;

    @Override
    public Mono<clientresponse> filter(ClientRequest request,
                                       ExchangeFunction next) {
      String token = Optional.ofNullable(oAuth2Client.accessToken())
                             .map(AccessToken::getAccessToken)
                             .map("Bearer "::concat)
                             .orElseThrow(() -> new AccessDeniedException());

      ClientRequest newRequest = ClientRequest.from(request)
                                              .header(HttpHeaders.AUTHORIZATION, token)
                                              .build();
      return next.exchange(newRequest);
    }
  }
}
</clientresponse>

6.2. Spring Framework — Feign Client

class FeignClientConfig {

  @Bean
  OAuthRequestInterceptor repositoryClientOAuth2Interceptor(OAuth2Client oAuth2Client) {
    return new OAuthRequestInterceptor(oAuth2Client);
  }

  class OAuthRequestInterceptor implements RequestInterceptor {

    OAuth2Client oAuth2Client;

    @Override
    public void apply(RequestTemplate requestTemplate) {
      String authToken = Optional.ofNullable(oAuth2Client.accessToken())
                                 .map(AccessToken::getAccessToken)
                                 .map("Bearer "::concat)
                                 .orElseThrow(() -> new AccessDeniedException());

      requestTemplate.header(HttpHeaders.AUTHORIZATION, authToken);
    }
  }
}

6.3. Vert.x — Web Client

class ProtectedResourceHandler implements Handler<routingcontext>  {

  OAuth2Config oAuth2Config;

  ProtectedResourceHandler() {
    // Resource handler initialization
    oAuth2Config = oauth2Config(config);
  }

  private OAuth2Config oauth2Config(JsonObject oauth2Properties) {
    
    return OAuth2Config.builder()
        .grantType(oauth2Properties.getString("grantType"))
        .accessTokenUri(oauth2Properties.getString("accessTokenUri"))
        .clientId(oauth2Properties.getString("clientId"))
        .clientSecret(oauth2Properties.getString("clientSecret"))
        .username(oauth2Properties.getString("username"))
        .password(oauth2Properties.getString("password"))
        .scope(oauth2Properties.getString("scope"))
        .build();
  }

  @Override
  public void handle(RoutingContext routingContext) {

    WebClient.create(routingContext.vertx())
        .getAbs(host)
        .uri(endpoint)
        .putHeader(HttpHeaders.AUTHORIZATION.toString(), generateToken())
        .send()
        .onSuccess(httpResponse -> { /* Successful response handler */ })
        .onFailure(err -> { /* Error response handler */ });
  }

  String generateToken() {

    return Optional.of(OAuth2Client.withConfig(oAuth2Config).build())
        .map(OAuth2Client::accessToken)
        .map(AccessToken::getAccessToken)
        .map("Bearer "::concat)
        .orElseThrow(() -> new AccessDeniedException());
  }
}
</routingcontext>

6.4. Quarkus — RESTEasy

@RegisterRestClient
@RegisterClientHeaders(SecurityHeaderFactory.class)
interface DocumentClient {
  
  // External endpoints definition
}

class SecurityHeaderFactory implements ClientHeadersFactory {

  OAuth2Client oAuth2Client;

  @Inject
  SecurityHeaderFactory(OAuth2Properties oAuth2Properties) {
    oAuth2Client = OAuth2Client
        .withConfig(oauth2Config(oAuth2Properties))
        .build();
  }

  @Override
  public MultivaluedMap<string string=""> update(MultivaluedMap<string string=""> incomingHeaders,
                                               MultivaluedMap<string string=""> outgoingHeaders) {
    outgoingHeaders.add(HttpHeaders.AUTHORIZATION.toString(), generateToken());
    return outgoingHeaders;
  }

  String generateToken() {
    return Optional.of(oAuth2Client)
        .map(OAuth2Client::accessToken)
        .map(AccessToken::getAccessToken)
        .map("Bearer "::concat)
        .orElseThrow(() -> new AccessDeniedException());
  }

  OAuth2Config oauth2Config(OAuth2Properties oAuth2Properties) {
    return OAuth2Config.builder()
        .grantType(oAuth2Properties.getGrantType())
        .accessTokenUri(oAuth2Properties.getAccessTokenUri())
        .clientId(oAuth2Properties.getClientId())
        .clientSecret(oAuth2Properties.getClientSecret())
        .username(oAuth2Properties.getUsername())
        .password(oAuth2Properties.getPassword())
        .scope(oAuth2Properties.getScope())
        .build();
  }
}
</string></string></string>

7. Conclusión

En este artículo, hemos visto cómo podemos configurar un simple Cliente OAuth2, y cómo podemos integrarlo en tus llamadas REST, para obtener un recurso protegido de un servicio externo.

Puedes consultar el código utilizado para el Cliente OAuth2, el repositorio está disponible en Github.

Juan Carlos González, Backend Software Engineer at Sngular

Juan Carlos González

Backend Software Engineer at Sngular