Autenticación con OAuth y Keycloak en un servicio REST con JAX-RS y Spring Boot

Escrito por el , actualizado el .
java planeta-codigo programacion
Enlace permanente Comentarios

Keycloak es un proveedor de OAuth que podemos usar en nuestras aplicaciones y servicios para proporcionar autenticación, autorización, SSO y también añadir seguridad a los servicios REST que desarrollemos como muestro en este artículo. OAuth tiene varias ventajas sobre usar autenticación Basic.

Keycloak

Java

Una forma de autenticar a los clientes de un servicio REST es usar autenticación Basic que se basa en añadir una cabecera en la petición en la que se incluye un usuario y contraseña. La autenticación Basic es sencilla pero para que sea segura ha de usar el protocolo seguro HTTPS.

Sin embargo, presenta otros inconvenientes y es que si al servicio van a acceder varios clientes y a uno queremos impedirle el acceso no podremos hacerlo sin cambiar el usuario y contraseña lo que obligará al resto de clientes actualizarse para usar las nuevas credenciales si las comparten, que no siempre es posible sobre todo si esos clientes están fuera de nuestro control. Para solventar el segundo problema tenemos la posibilidad de securizar el servicio REST con el protocolo OAuth.

Teniendo un servicio web REST implementado con JAX-RS y Spring Boot añadirle seguridad con OAuth mediante el proveedor Keycloak es lo que muestro en este artículo.

El servidor de autorización

En el servicio REST bastará usar el adaptador para Spring Boot de Keycloak y añadamos en Keycloak cierta configuración que consistirá en un realm y el registro de un cliente. Para acceder al servicio REST usaremos el flujo client_credentials que nos permitirá obtener un token usando las credenciales del cliente.

Iniciado Keycloak con Docker y Docker Compose accedemos al panel de administración con el navegador, en mi caso en http://localhost:9080 con el usuario admin y contraseña admin según lo indicado en el archivo docker-compose.yml.

1
2
3
4
5
6
7
8
9
services:
  keycloak:
    image: quay.io/keycloak/keycloak
    environment:
      - KEYCLOAK_ADMIN=admin
      - KEYCLOAK_ADMIN_PASSWORD=admin
    ports:
      - "9080:8080"
    command: start-dev
docker-compose.yml
1
2
$ docker-compose up

docker-compose-up.sh

Inicio de sesión en Keycloak Clientes OAuth

Administación de Keycloak

Un servicio REST con OAuth

Una vez realizada la configuración en el servidor de OAuth/Keycloak obtendremos la configuración para el adaptador de Keycloak para el servicio REST desde la pestaña Installation que añadiremos en el fichero de configuración de Spring Boot application.yml. Además, indicaremos que el cliente solo aceptará access tokens mediante la opción bearer-only de modo que no hará redirecciones para autenticar.

Indicaremos también el rol que deberá poseer el cliente para acceder al servicio REST junto que URLs del servicio estarán autenticadas por OAuth. Añadida la configuración al archivo application.yml el servicio REST es totalmente inconsciente de la autenticación que se realizará con OAuth y Keycloak.

1
2
3
4
5
6
7
8
9
server:
  port: 8080

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:9080/realms/my-realm
application.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package io.github.picodotdev.blogbitix.springbootjaxrsoauth;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;

@Component
@Path("/message")
public class MessageResource {

    @Autowired
    private MessageService messageService;

    @GET
    @Produces("application/json")
    public Message message(@QueryParam("message") String message) {
        return messageService.create(message);
    }

}
MessageResource.java

Iniciar el servidor de autorización y el servicio REST

Iniciado Keycloak y el servicio REST con el comando gradlew run podemos iniciar el proceso de obtención de un access token y llamar al servicio proporcionando el access token obtenido y ver que pasa si no proporcionamos token o uno modificado o inválido. Para obtener el access token podemos emplear curl accediendo al endpoint de Keycloak para obtenerlos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ curl -i http://localhost:9080/realms/my-realm/.well-known/openid-configuration
$ curl -i http://localhost:9080/realms/my-realm/protocol/openid-connect/token -d "grant_type=client_credentials&client_id=spring-boot-client&client_secret=Bg1r6mOYsFraDw7u8VCgmGl4JtK8vShX"
HTTP/1.1 200 OK
Referrer-Policy: no-referrer
X-Frame-Options: SAMEORIGIN
Strict-Transport-Security: max-age=31536000; includeSubDomains
Cache-Control: no-store
X-Content-Type-Options: nosniff
Set-Cookie: KC_RESTART=; Version=1; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Max-Age=0; Path=/realms/my-realm/; HttpOnly
Pragma: no-cache
X-XSS-Protection: 1; mode=block
Content-Type: application/json
content-length: 2192

{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJQY1ZFRTN0TEl2bGhhVDFpVE16aWV2ZVN5SnJTMXlZTERNY3YzMTUwd0FrIn0.eyJleHAiOjE2NjMzNDc5OTcsImlhdCI6MTY2MzM0NzkzNywianRpIjoiNTNjY2MxZTQtYjUwNy00ZmQyLWJiYmMtZjY2ZmRlZDYwNzEyIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MDgwL3JlYWxtcy9teS1yZWFsbSIsImF1ZCI6InNwcmluZy1ib290LWNsaWVudCIsInN1YiI6IjYzOTI1NWJlLTU0NGUtNGQ2Zi1iNDhjLWE2NjQ1YmY2OGYyYyIsInR5cCI6IkJlYXJlciIsImF6cCI6InNwcmluZy1ib290LWNsaWVudCIsInNlc3Npb25fc3RhdGUiOiJhYWY3Y2M4OS0wZDVhLTQyMzktYTFkZS1mM2ZkZDU0ZGY4OGYiLCJhY3IiOiIxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm1lc3NhZ2UtcXVlcnkiXX0sInJlc291cmNlX2FjY2VzcyI6eyJzcHJpbmctYm9vdC1jbGllbnQiOnsicm9sZXMiOlsibWVzc2FnZS1xdWVyeS0yIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwic2lkIjoiYWFmN2NjODktMGQ1YS00MjM5LWExZGUtZjNmZGQ1NGRmODhmIiwiY2xpZW50SWQiOiJzcHJpbmctYm9vdC1jbGllbnQiLCJjbGllbnRIb3N0IjoiMTcyLjIzLjAuMSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoic2VydmljZS1hY2NvdW50LXNwcmluZy1ib290LWNsaWVudCIsImNsaWVudEFkZHJlc3MiOiIxNzIuMjMuMC4xIn0.L_iyKmHOZVgRKwKZyokO13hk9GgBqVgLhQFeoI9woomCeynby8AWTolO3pI70fdHfK8yZUdy3kw99KvM2ntPOuIz7i-Rxa_e74BVwLGzAuwXf_XHio48bvHYBJYQOYxAalsRwNgnGp4FFecEYJHR7jFaGEyzrCD2Lr31RxpvZ5dWaVdqIavtXSfyFu91ynkD5kd2t9SgYOgQIRrXOpx82ycxf9rbTUto-njhG0DJknH24d1jtNLXy5moTIGPJCI5P1o8e6oV4A0Ayz36oNvPraBs-sfbv08g3ETYz9rEWE7Ku8182K1fYFIqNbriUXiJBAUHKLiRHva_fuVmkItUQw","expires_in":60,"refresh_expires_in":1800,"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJiNzk4MGU2NC02ZjdhLTQxNTctYmFmNi1lN2FmYmM0OThmNzEifQ.eyJleHAiOjE2NjMzNDk0MjcsImlhdCI6MTY2MzM0NzYyNywianRpIjoiNDMzNjMwMGEtYmMyZS00MDkyLTljYjMtYWM4NzQwYzhjNDg3IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MDgwL3JlYWxtcy9teS1yZWFsbSIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTA4MC9yZWFsbXMvbXktcmVhbG0iLCJzdWIiOiI2MzkyNTViZS01NDRlLTRkNmYtYjQ4Yy1hNjY0NWJmNjhmMmMiLCJ0eXAiOiJSZWZyZXNoIiwiYXpwIjoic3ByaW5nLWJvb3QtY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6ImE1ZTllM2ZlLTMxNWMtNGYyYS04NmU5LTgxMzI2NzcwNWE0MCIsInNjb3BlIjoiZW1haWwgcHJvZmlsZSIsInNpZCI6ImE1ZTllM2ZlLTMxNWMtNGYyYS04NmU5LTgxMzI2NzcwNWE0MCJ9.xPERKQ7F8lf2dcdK_wyYtuufPzpv0KdDw2G9U_zfrYM","token_type":"Bearer","not-before-policy":0,"session_state":"a5e9e3fe-315c-4f2a-86e9-813267705a40","scope":"email profile"}
curl-token.sh

Obtenido el access token si no lo proporcionamos en la llamada al servicio REST observaremos que la respuesta que obtenemos es un código de estado HTTP 401 indicando que se necesitan proporcionar las credenciales que con OAuth es un token.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ curl -ik http://localhost:8080/api/message?message=Hola
HTTP/1.1 401 
Set-Cookie: JSESSIONID=82E6871E2347D74639BD7F98494030CB; Path=/; HttpOnly
WWW-Authenticate: Bearer
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Fri, 16 Sep 2022 17:02:14 GMT
curl-401.sh

Proporcionando el token mediante una cabecera de la petición el servicio nos devolverá los datos que proporciona. Si el token no es válido obtendremos un error HTTP 401.

 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
$ curl -ik -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJQY1ZFRTN0TEl2bGhhVDFpVE16aWV2ZVN5SnJTMXlZTERNY3YzMTUwd0FrIn0.eyJleHAiOjE2NjMzNDc5OTcsImlhdCI6MTY2MzM0NzkzNywianRpIjoiNTNjY2MxZTQtYjUwNy00ZmQyLWJiYmMtZjY2ZmRlZDYwNzEyIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MDgwL3JlYWxtcy9teS1yZWFsbSIsImF1ZCI6InNwcmluZy1ib290LWNsaWVudCIsInN1YiI6IjYzOTI1NWJlLTU0NGUtNGQ2Zi1iNDhjLWE2NjQ1YmY2OGYyYyIsInR5cCI6IkJlYXJlciIsImF6cCI6InNwcmluZy1ib290LWNsaWVudCIsInNlc3Npb25fc3RhdGUiOiJhYWY3Y2M4OS0wZDVhLTQyMzktYTFkZS1mM2ZkZDU0ZGY4OGYiLCJhY3IiOiIxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm1lc3NhZ2UtcXVlcnkiXX0sInJlc291cmNlX2FjY2VzcyI6eyJzcHJpbmctYm9vdC1jbGllbnQiOnsicm9sZXMiOlsibWVzc2FnZS1xdWVyeS0yIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwic2lkIjoiYWFmN2NjODktMGQ1YS00MjM5LWExZGUtZjNmZGQ1NGRmODhmIiwiY2xpZW50SWQiOiJzcHJpbmctYm9vdC1jbGllbnQiLCJjbGllbnRIb3N0IjoiMTcyLjIzLjAuMSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoic2VydmljZS1hY2NvdW50LXNwcmluZy1ib290LWNsaWVudCIsImNsaWVudEFkZHJlc3MiOiIxNzIuMjMuMC4xIn0.L_iyKmHOZVgRKwKZyokO13hk9GgBqVgLhQFeoI9woomCeynby8AWTolO3pI70fdHfK8yZUdy3kw99KvM2ntPOuIz7i-Rxa_e74BVwLGzAuwXf_XHio48bvHYBJYQOYxAalsRwNgnGp4FFecEYJHR7jFaGEyzrCD2Lr31RxpvZ5dWaVdqIavtXSfyFu91ynkD5kd2t9SgYOgQIRrXOpx82ycxf9rbTUto-njhG0DJknH24d1jtNLXy5moTIGPJCI5P1o8e6oV4A0Ayz36oNvPraBs-sfbv08g3ETYz9rEWE7Ku8182K1fYFIqNbriUXiJBAUHKLiRHva_fuVmkItUQw" http://localhost:8080/message?string=Hola
HTTP/1.1 200 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 16 Sep 2022 17:06:00 GMT

{"message":"Hola","date":"2022-09-16T17:06:00.126+00:00"}

$ curl -ik -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJQY1ZFRTN0TEl2bGhhVDFpVE16aWV2ZVN5SnJTMXlZTERNY3YzMTUwd0FrIn0.eyJleHAiOjE2NjMzNDc5OTcsImlhdCI6MTY2MzM0NzkzNywianRpIjoiNTNjY2MxZTQtYjUwNy00ZmQyLWJiYmMtZjY2ZmRlZDYwNzEyIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MDgwL3JlYWxtcy9teS1yZWFsbSIsImF1ZCI6InNwcmluZy1ib290LWNsaWVudCIsInN1YiI6IjYzOTI1NWJlLTU0NGUtNGQ2Zi1iNDhjLWE2NjQ1YmY2OGYyYyIsInR5cCI6IkJlYXJlciIsImF6cCI6InNwcmluZy1ib290LWNsaWVudCIsInNlc3Npb25fc3RhdGUiOiJhYWY3Y2M4OS0wZDVhLTQyMzktYTFkZS1mM2ZkZDU0ZGY4OGYiLCJhY3IiOiIxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm1lc3NhZ2UtcXVlcnkiXX0sInJlc291cmNlX2FjY2VzcyI6eyJzcHJpbmctYm9vdC1jbGllbnQiOnsicm9sZXMiOlsibWVzc2FnZS1xdWVyeS0yIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwic2lkIjoiYWFmN2NjODktMGQ1YS00MjM5LWExZGUtZjNmZGQ1NGRmODhmIiwiY2xpZW50SWQiOiJzcHJpbmctYm9vdC1jbGllbnQiLCJjbGllbnRIb3N0IjoiMTcyLjIzLjAuMSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoic2VydmljZS1hY2NvdW50LXNwcmluZy1ib290LWNsaWVudCIsImNsaWVudEFkZHJlc3MiOiIxNzIuMjMuMC4xIn0.L_iyKmHOZVgRKwKZyokO13hk9GgBqVgLhQFeoI9woomCeynby8AWTolO3pI70fdHfK8yZUdy3kw99KvM2ntPOuIz7i-Rxa_e74BVwLGzAuwXf_XHio48bvHYBJYQOYxAalsRwNgnGp4FFecEYJHR7jFaGEyzrCD2Lr31RxpvZ5dWaVdqIavtXSfyFu91ynkD5kd2t9SgYOgQIRrXOpx82ycxf9rbTUto-njhG0DJknH24d1jtNLXy5moTIGPJCI5P1o8e6oV4A0Ayz36oNvPraBs-sfbv08g3ETYz9rEWE7Ku8182K1fYFIqNbriUXiJBAUHKLiRHva_fuVmkItUQw_tampereed" http://localhost:8080/message?string=Hola
HTTP/1.1 401 
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Fri, 16 Sep 2022 17:08:39 GMT
curl.sh

Para usar Keycloak en una aplicación Java con Spring Boot deberemos añadir algunas dependencias al proyecto que usando Gradle como herramienta de construcción serían las siguientes.

 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
plugins {
	id 'application'
}

repositories {
	mavenCentral()
}

dependencies {
	implementation platform('org.springframework.boot:spring-boot-dependencies:2.7.3')

	implementation('org.springframework.boot:spring-boot-starter')

	implementation('com.squareup.okhttp3:okhttp:4.10.0')

	implementation('javax.json:javax.json-api:1.1')
	implementation('javax.json.bind:javax.json.bind-api:1.0')
	implementation('javax.json.bind:javax.json.bind-api:1.0')
	runtimeOnly('org.eclipse:yasson:1.0')
	runtimeOnly('org.glassfish:javax.json:1.1')
}

application {
    mainClass = 'io.github.picodotdev.blogbitix.springbootjaxrsoauth.client.Main'
	sourceCompatibility = 11
	targetCompatibility = 11
}
build.gradle

Dos buenos libros sobre OAuth que he leído son los siguientes uno explica detalladamente el protocolo OAuth incluidas las varias formas de obtener un token además del mostrado en este artículo usando las credenciales del cliente. El otro libro es sobre el proveedor de identidad Keycloak que implementa autenticación con OpenID Connect.

En el siguiente artículo mostraré un cliente del servicio REST autenticado con OAuth en Java que haga las mismas llamadas que con curl pero usando código Java mediante la librería OkHttp.

Terminal

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando siguiente comando:
./gradle server:run


Comparte el artículo: