Ú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.
- El usuario desea acceder a un recurso protegido, por lo que debe autentificarse con el servidor de autorización.
- El servidor de autorización responde proporcionando un ID token que contiene información firmada sobre el usuario.
- La aplicación cliente utiliza este ID token para identificarse ante el Servidor del Recurso.
- 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@EnableWebSecuritypublic 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: java17automatic_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
- Google Identity
- Spring - OAuth 2.0 Resource Server
- The OAuth 2.0 Authorization Framework
- OpenID Connect
- JSON Web Tokens - jwt.io
- IETF - RFC 6750 - The OAuth 2.0 Authorization Framework: Bearer Token Usage
- IETF - RFC 7519 - JSON Web Token (JWT)
Comentarios
Publicar un comentario