Spring OAuth 2.0 Resource Server - Google Sign In


Última revisión en diciembre del 2023.

El objetivo es implementar un servidor con Spring OAuth 2.0 Resource Server que utiliza el Servidor de autorización de Google Identity Services para autenticar al usuario.

Utilizamos Google Sign-In para generar un ID token en la aplicación del navegador. Este token acompaña sus peticiones a nuestro servidor en forma de autorización Bearer. Nuestro servidor de recursos utiliza dicho token para autenticar al usuario una vez que ha verificado su validez mediante las claves públicas del servidor de autorización.

Podéis encontrar el código completo de este artículo en GitHub.

Aspectos técnicos generales

La parte del back-end se desarrollará con Spring Boot, siguiendo el principio de Convención sobre Configuración en Spring, utilizando Java. Se optará por la versión web no reactiva, que incluye por defecto un servidor Tomcat. Spring Boot generará un fat JAR que incorpora todas las dependencias, lo que lo hace ejecutable en cualquier entorno que disponga del entorno de ejecución Java (JRE) correspondiente.

En nuestro caso, para ejecutar el fat JAR, se utilizará la Plataforma como Servicio de Google, App Engine, aunque también sería válido cualquier contenedor que incluyera un JRE.

En cuanto al front-end, se ha utilizado Vanilla JS sin añadir adornos innecesarios.

Spring utiliza Gradle como su herramienta de automatización, pero en mi caso, he elegido utilizar Apache Maven. La razón principal es que el editor que estoy utilizando gestiona Maven de manera más eficiente. A pesar de esto, es importante destacar que Gradle se reconoce generalmente como una opción más eficiente en términos de rendimiento.

Tecnologías

  • Spring
  • Spring Boot 3.2
  • Spring Security
  • Spring OAuth 2.0 Resource Server
  • Google Sign In
  • Google Appengine
  • Java - JVM
  • Maven
  • Vanilla JS

Autenticando un Usuario con OpenID Connect (OIDC)

OIDC es un protocolo que permite a la aplicación cliente autentificar a los usuarios utilizando un servidor de autorización. El servidor de autorización devuelve un ID Token que contiene información firmada del usuario.

OIDC representa una capa de identidad sobre el estándar OAuth2. Mientras que OAuth2 aborda la autorización mediante el uso de tokens de acceso, no aborda la autenticación del usuario, función que cumple OIDC.

  1. El usuario desea acceder a un recurso protegido, por lo que debe autentificarse con el servidor de autorización.
  2. El servidor de autorización responde proporcionando un ID token que contiene información firmada sobre el usuario.
  3. La aplicación cliente utiliza este ID token para identificarse ante el Servidor del Recurso.
  4. El Servidor del Recurso verifica la firma del ID token con el Servidor de Autorización.

El rol de aplicación cliente del servidor de autorización se puede asignar al frontend (navegador) o al backend (nuestro servidor), teniendo en cuenta que no hay ninguna manera de proteger completamente el ID Token en el navegador.

Terminología según estándar

OAuth2 OIDC
User End-User
Client Relying Party
Authorization Server Provider

Incluimos la tabla anterior para ayudarnos a entender la literatura y el código sobre el tema, ya que son términos que se utilizan de forma cruzada.

Aplicación cliente OAuth2

Registro de la aplicación cliente OAuth2 en Google Cloud

Para utilizar Google Sign-In, primero debemos registrar nuestra aplicación cliente en un proyecto de Google Cloud, donde recibiremos las credenciales para nuestra aplicación OAuth2 en forma de identificación de cliente y secreto de cliente.

En el caso que la aplicación cliente este en el navegador, en JavaScript, sólo es necesario la identificación de cliente para obtener el token de identificación. Sin embargo, en el caso de que la aplicacion de cliente OAuth2 este en un servidor web realizando una authorization code grant, que es la más común, también debe incluir su secreto al intercambiar el código de autorización por el token de autorización con el servidor de autorización.

Aplicación cliente JavaScript - Google Sign In

Google nos proporciona una librería en JavaScript que nos permite autenticar usuarios y obtener un ID Token, así como autorizar a los usuarios para acceder a las APIs de Google, devolviendo un Access Token. En nuestro caso, utilizaremos exclusivamente el ID Token para autenticar al usuario en el servidor.

En el caso del navegador, la librería Google Sign-In nos proporciona un botón "Sign In with Google". Si el usuario ya está autenticado en el navegador, si así lo configuramos, también se muestra un cuadro de diálogo que le ofrece conectarse a la aplicación Consolidando.

A continuación, se detalla el código del navegador en Javascript donde pasamos la identificación de la aplicación cliente (_GOOGLE_CLIENT_ID), que previamente habremos configurado en algún proyecto en Google Cloud. También le pasamos un callback a través del cual se nos devuelve el ID Token.

 
function onLoadGoogleIdentityInit(buttonId)
{
    return new Promise((resolve, reject) => {
        try {
            google.accounts.id.initialize({
                client_id: _GOOGLE_CLIENT_ID,
                callback: data => resolve(data)
            });

            google.accounts.id.renderButton(
                    document.getElementById(buttonId),
                    {theme: "outline", size: "medium"}
            );

            google.accounts.id.prompt();


        } catch (error) {
            console.error(error);
            reject("Error initializing Google Identity");
        }
    });
}

Servidor de Recursos - Spring OAuth 2.0 Resource Server

Spring está en constante evolución y nos proporciona un extenso framework de seguridad con soluciones para autenticación y autorización, así como múltiples protecciones contra diferentes tipos de ataques, como el Cross-Site Request Forgery.

En el caso de OAuth2, Spring Security ofrece diversas opciones para implementar escenarios específicos. En esta ocasión, vamos a abordar el caso en el que el Cliente OAuth2 está en el navegador y accede directamente a un Servidor de Recursos con un token de Identificación.

Es importante comprender que, desde la perspectiva del Servidor de Recursos, no distingue quién es el Cliente OAuth2. Este cliente podría ser, por ejemplo, un Edge Server que ha intercambiado con éxito el código de autorización por un token de identificación.

Dependencia a incluir en Maven

 
<dependency>
   <groupId>org.springframework.boot</groupId>   
   <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Esta dependencia es la que determina que el servidor Spring actúe como un servidor de recursos OAuth2, si quisiéramos que actuara como cliente OAuth2 tendríamos que substituir el artifactId por spring-boot-starter-oauth2-client.

Configuración del Servidor de Recursos

En Spring Boot, casi todo se puede especificar en application.yml. Hemos incluido la configuración tanto para cuando Spring actúa como cliente de OAuth2 como cuando actúa como servidor de recursos OAuth2.

En la primera parte, que no se utiliza en este proyecto, se registra el servidor Spring como cliente OAuth2, proporcionando su identificación, secreto y el alcance de información deseado del usuario.

En la segunda parte, se configura Spring como el servidor de recursos que recibe un token para autorizar las peticiones. Para ello, se especifica la issuer-uri, que se valida contra el "iss" claim del token, y la jwk-set-uri, donde se encuentran las claves públicas de Google en formato JWK. Estas claves se utilizan para validar la firma del token.

 
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: @client.id@
            client-secret: @client.secret@
            scope: openid,profile,email
      resourceserver:
        jwt:
          issuer-uri: https://accounts.google.com
          jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs

Propiedades en Maven

Maven sobrescribe los valores reales de client-id y client-secret en application.yml al desplegar la aplicación, utilizando variables de entorno env.CLIENT_ID y env.CLIENT_SECRET respectivamente. Protegiendo así los secretos, evitando que estén almacenados en los repositorios.

 
    <properties>
        <java.version>17</java.version>
        <app.deploy.projectId>emp-2022</app.deploy.projectId>
        <app.deploy.version>1</app.deploy.version>
        <client.id>@env.CLIENT_ID@</client.id>
        <client.secret>@env.CLIENT_SECRET@</client.secret>
    </properties>

Notar que la .pom de Maven Spring Boot Start Parent tiene definido el delimitador @ en lugar ${*} y aplica el filtrado de recursos a todas de propiedades del proyecto.

 
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0-SNAPSHOT</version>
        <relativePath/>
    </parent>

El main

En la única clase del proyecto, tenemos la anotación @EnableWebSecurity, lo que indica que estamos sobrescribiendo el filtro de seguridad. Además, encontramos la anotación @RestController, que señala que cualquier mapeo de un recurso REST en esta clase devolverá su resultado directamente en el body de la respuesta http en formato JSON.

 
@SpringBootApplication
@RestController
@EnableWebSecurity
public class GoogleSignInApplication
{
    //...

    public static void main(String[] args)
    {
        SpringApplication.run(GoogleSignInApplication.class, args);
    }
}

El Recurso "users"

Se ha creado un mini recurso que devuelve el objeto Principal, el cual representa al usuario autenticado en el servidor.

 
 @GetMapping("/users")
 public Principal users(Principal principal)
 {
        return principal;
 }

En el método hemos inyectado directamente Principal, pero realmente Spring nos está inyectando Authentication, que extiende Principal.

También podríamos haber usado directamente SecurityContextHolder, que nos permite obtener Authentication. A través de Authentication, podemos realizar un getPrincipal, que nos devuelve un Object que en el caso de OAuth2 es una instancia de Jwt (org.springframework.security.oauth2.jwt).

 
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication != null && authentication.isAuthenticated())
        {
              Object principal = authentication.getPrincipal();

                if (principal instanceof Jwt jwt)
                {
                    tokenEmail = jwt.getClaim("email");

SecurityFilterChain

Por defecto, el servidor de recursos exige que todos los accesos tengan un token válido. Sin embargo, en nuestro caso, modificaremos el filtro de seguridad SecurityFilterChain para que sólo requiera autenticación para los accesos al recurso "users".

Además, especificamos que el servidor sea stateless y que no verifique el token CSRF.

 
@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception
    {
        http
                .authorizeHttpRequests((request)
                        -> request.requestMatchers("/users").authenticated()
                        .anyRequest().permitAll()
                )
                .oauth2ResourceServer((oauth2ResourceServer)
                        -> oauth2ResourceServer.jwt(Customizer.withDefaults())
                )
                .sessionManagement(sessionManagement -> sessionManagement
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .csrf(AbstractHttpConfigurer::disable);

        return http.build();
    }


401 - Unauthorized

Si accedemos a un recurso sin incluir un ID token por defecto el servidor nos devuelve un error status: 401 - Unauthorized que en nuestro caso preferimos convertir en una redirección a la página de bienvenida "/index.html" donde ofrecemos la posibilidad de hacer un Sign In

Debajo incluimos el código a añadir al filtro anterior para conseguir la redirección cuando hay una excepción de autentificación en el servidor

 
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception
    {
        http
                .// ...

                .exceptionHandling(exception
                        -> exception.authenticationEntryPoint(
                        (request, response, authException)
                        -> response.sendRedirect("/index.html?v=2")
                ))

               // ...

        return http.build();
    }

Errors y excepciones - RFC6750 Section 3

El servidor de recursos reporta errores de credenciales usando The WWW-Authenticate Response Header Field y el apropiado http status code.

Debajo tenemos la respuesta del servidor de recursos cuando el token de autorización es inválido.

Stateless

La arquitectura REST no guarda el estado por definición. La regla número 6 del Twelve Factor App Development en GCP establece que los procesos no deberían tener estado, y en caso de tenerlo, se debe guardar de manera que esté disponible en todas las instancias al mismo tiempo.

Cuando se utiliza Spring para crear un cliente OAuth2, se emplea el estado para asociar los tokens a los usuarios. Este estado se debe almacenar en una base de datos de fácil acceso, como Redis.

Cross-Site Request Forgery

Cuando realizamos una transferencia desde el sitio web de un banco, internamente se utiliza un fetch hacia una dirección web con informacion de la transferencia y esta acción tiene asociadas unas cookies que podrían autenticar al usuario. La idea es que un sitio web malicioso en nuestro ordenador realice el mismo fetch al cual el navegador asocia automaticamente nuestras cookies, y en el servidor no pueden distinguir quién ha realizado el fetch, si fue el sitio web del banco o el sitio web malicioso.

Google App Engine

Google App Engine (GAE) es una Platform as a Service (PaaS) que nos permite ejecutar directamente el fat JAR construido por Spring Boot, y solo necesitamos indicar la versión de Java que queremos utilizar.

A continuación, mostramos el archivo app.yaml de GAE, donde especificamos la versión de Java, la forma en que queremos que nuestra aplicación escale y un controlador para servir los archivos estáticos directamente desde GAE sin pasar por Spring.

 
runtime: java17
automatic_scaling:
  max_idle_instances: 1
  max_concurrent_requests: 10
  max_instances: 2

handlers:
- url: (/.*\.(gif|png|jpg|svg|ico|css|js|html))
  static_files: __static__\0
  upload: __NOT_USED__
  require_matching_file: true
  login: optional
  secure: optional
  expiration: 7d
- url: /
  static_files: __static__/index.html
  upload: __NOT_USED__
  require_matching_file: true
  login: optional
  secure: optional
  expiration: 7d

Archivos estáticos

Hemos incluido en Maven el plugin maven-resources-plugin, que utilizamos para mover todos los archivos estáticos al directorio __static__, desde donde se sirven directamente por Google Cloud. Esto hace que sea mucho más rápido que servirlos a través del servidor creado con Spring. También hemos definido en el servidor de archivos estáticos un archivo de bienvenida, index.html, lo que permite que la primera página se cargue casi de inmediato, ya que no es necesario esperar a que el servidor de recursos se inicie.

 
<plugin>
     <artifactId>maven-resources-plugin</artifactId>
      <version>3.3.1</version>
      <executions>
      <execution>
       <id>copy-resources</id>      
       <phase>prepare-package</phase>
       <goals>
         <goal>copy-resources</goal>
       </goals>
      <configuration>
      <outputDirectory>${project.build.directory}/appengine-staging/__static__</outputDirectory>
      <resources>
      <resource>
      <directory>${basedir}/src/main/resources/static</directory>
      <filtering>false</filtering>      
      </resource>
      </resources>
      </configuration>
      </execution>
      </executions>
      <configuration>
      <encoding>${project.build.sourceEncoding}</encoding>
      </configuration>
</plugin>

Cómo Probar el Servidor de Recursos

Para testear nuestro servidor usamos el cliente HTTP WebTestClient que forma parte de Spring Web Flux.

Test Invalid Token

Verificamos que devuelve un status 401 y que nos dice que el token es inválido.


    final String invalidTokenString = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";

    @Test
    public void testInvalidIDToken()
    {

        webTestClient
                .get().uri("/users")
                .headers(http -> http.setBearerAuth(invalidTokenString))
                .exchange()
                .expectStatus().isUnauthorized()
                .expectHeader().valueMatches(HttpHeaders.WWW_AUTHENTICATE,
                        "Bearer error=\"invalid_token\",.*");
    }

Test No Token

Verificamos que redirecciona al fichero de bienvenida.


    @Test
    public void testRedirectionWhenAccessUsersWithoutIDToken()
    {
        webTestClient
                .get().uri("/users")
                .exchange()
                .expectStatus()
                .is3xxRedirection()
                .expectHeader().valueMatches(HttpHeaders.LOCATION, ".*\\/index\\.html\\?v=2$");
    }


Para finalizar

Spring Security facilita considerablemente la creación de un servidor web capaz de asumir roles tanto de cliente como de servidor OAuth2. Además, también permite la fácil creación de un servidor de autorización OAuth2.

El paquete de Spring Security para la creación del cliente OAuth2 ya incluye proveedores predefinidos con los que hacer la autenticación, como Google, GitHub, Okta y Facebook.

Como curiosidad, en https://myaccount.google.com/connections podéis ver las aplicaciones y servicios que han adquirido algún derecho sobre vuestros datos en Google.

El Código

Puedes encontrar el código de este post en el repositorio de GitHub: Consolidando - Spring Boot.

Más información

Si te ha gustado y quieres compartirme


Etiquetas del artículo

Comentarios