Implementando OAuth 2 con Spring y Java based config

La intención de este artículo es lograr que la curva de aprendizaje no sea extremadamente violenta configurando una aplicación de autenticación y autorización. Happy hacking!

¿Qué vamos a hacer?

Básicamente esto, basándonos en esto, y no, tranqui, la idea no es leer todo eso sino sólo lo necesario para empezar a jugar, somos programadores.

A modo de resumen el flujo es el que figura en la próxima imagen:

OAuth2 Authorization Code grant flow

La idea de tener un servicio de OAuth2 es poder separar la lógica de login de los micro-servicios que sólo están destinados al negocio. Logrando el SSO (single sign on) para cada uno de los micro-servicios que queramos que estén disponibles. Para simplificar el ejemplo, vamos a utilizar un único usuario final con el rol ADMIN, pero en un escenario real podrías tener tantos roles como necesites. Y en cuanto al client, creamos solo a client_api con el scope reads y el flujo authorize code para que puedan ver en detalle como funcionan las re-direcciones.

Bien, double click, vamos a desarrollar una aplicación web basada en spring-boot, con spring-security y con spring-oauth2, (si realmente no tenés ni idea de que estoy hablando te recomiendo empezar por los links anteriores). Para esta aplicación vamos a utilizar dos conceptos Resource Server (micro-servicio que contiene los recursos que queremos segurizar) y Authorization Server (micro-servicio con el cual nos vamos a autenticar para poder acceder a los recursos seguros). En este link, hay más info sobre la documentación de OAuth2.

Para simplificarlo, generaremos una única a aplicación que contenga ambos conceptos teniendo en cuenta que el diseño final debería contemplar componentes separados.

Iré dejando código de ejemplo basándome en el repositorio que les deje anteriormente y con el cual haremos haciendo las pruebas.


Dependencias

El proyecto usa gradle como manejador de dependencias, pero siéntanse libres de usar lo que prefieran. La idea es poder explicar qué dependencias usamos y porqué.

build.gradle

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

Esta dependencia es necesaria para agregar todos los filtros de seguridad básica, el viejo y conocido usuario y password.

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

Esta dependencia es para poder utilizar el templating de spring, es donde vamos a presentar nuestras pantallas de login.

implementation('org.springframework.cloud:spring-cloud-starter-oauth2')

Esta dependencia es la que nos habilita que nuestro proyecto sea segurizado por OAuth2.


Configuración

Esta aplicación se basa en configuración via clases (Java Based Configuration). Vamos a separar la configuración en varias secciones:

  • Spring

com/redbee/oauth2/Oauth2Application.java

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

Este bean lo usaremos mas adelante para poder encodear las password que necesitemos para probar

  • Spring security

com/redbee/oauth2/configuration/WebSecurityConfiguration.java

Dentro de esta clase vamos a configurar todo lo correspondiente a Spring Security, sin OAuth, donde autenticamos a los clientes finales.

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user")
.password(passwordEncoder().encode("user"))
.roles("ADMIN");
}

Lo más sencillo es arrancar creando usuarios de prueba en memoria. En esta caso creamos un usuario con el user and pwd “user”. En este lugar crearemos otro tipo de configuración como JDBC, LDAP, etc.

@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/css/**", "/images/**", "/js/**");
}

Esta config es para permitir el contenido estático (JS, CSS, etc.) esté disponible para las páginas de login y el index.

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf()
.and()
.authorizeRequests()
.antMatchers("/","/login","/logout.do", "/anon", "/health").permitAll()
.antMatchers("/**").authenticated()
.and()
.formLogin()
.loginProcessingUrl("/login.do")
.usernameParameter("username")
.passwordParameter("password")
.loginPage("/login")
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/logout.do"));
}

En esta config tenemos varios items:

  • Habilitamos filtro csfr.
  • Permitimos que se pueda acceder al “/” al login y al logout.
  • Habilitamos la seguridad para todas las urls.
  • Agregamos la url de login y nombre de parámetros.

Con esta logramos agregar nuestra página de login custom, así como también el procesamiento del mismo.

  • Resource Server

com/redbee/oauth2/configuration/ResourceServerConfiguration.java

Dentro de esta clase se encuentra toda la configuración relacionada al componente destinado a servir el contenido seguro.

@Override
public void configure(HttpSecurity http) throws Exception{
http
.requestMatchers()
.antMatchers("/api/**")
.and()
.authorizeRequests()
.antMatchers(HttpMethod.GET, "/**").access("#oauth2.hasScope('api') and hasRole('ROLE_ADMIN')")
.anyRequest().authenticated()
.and()
.exceptionHandling()
.accessDeniedHandler(new OAuth2AccessDeniedHandler());
}

Con esta configuración, logramos que todos los request que matcheen con la expresión /api/** queden segurizados bajo el protocolo OAuth2.

Que cualquier recurso REST al que se quiera acceder tiene que estar autenticado, con el scope api y el rol admin. Por último queremos que cuando no se cuenten con eso privilegios devolvamos un 403 Http status code.

  • Authorization server

com/redbee/oauth2/configuration/AuthorzationServerConfiguration.java

En esta clase tenemos toda la configuración relacionada con el componente que seguriza los recursos

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(new InMemoryTokenStore()).authenticationManager(authenticationManager);
}

Con esta config vamos a determinar qué todos los token que se generen sean guardados en memoria. En ambientes productivos o para bien escalar horizontalmente este servicio va a ser necesario utilizar otra forma de storing, que bien podría ser Redis o BD

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients
.inMemory()
.withClient("client_api")
.secret(passwordEncoder.encode("secret"))
.authorizedGrantTypes("authorization_code")
.redirectUris("http://localhost:8080")
.resourceIds("client_api")
.scopes("read");
}

Con este bloque creamos una nueva api, en memoria también, donde habilitamos el flujo de autenticación que vamos a probar, las redirecciones, etc.


Coding

Por último, debemos agregar una par de controllers y html, de manera de poder sobre-escribir la lógica que viene por default en Spring Security, con el fin de poder generar nuestro propio login page o que hacer cuando un usuario se realiza un logout, además agregamos unos controllers para poder usar de ejemplo.

com/redbee/oauth2/HelloWorld.java

@GetMapping(value = "/api/hello")
public Map<String, String> hello() {
HashMap<String, String> result = new HashMap<>();
result.put("Hola", "Mundo");
return result;
}

@GetMapping(value = "/anon")
public Map<String, String> anon() {
HashMap<String, String> result = new HashMap<>();
result.put("Hola", "Anon");
return result;
}

Nada de rocket science, agregamos dos controllers que vamos utilizar para poder hacer pruebas.

com/redbee/oauth2/controllers/LoginController.java

@RequestMapping("/login")
public ModelAndView loginPage() {
return new ModelAndView ("login");
}

@RequestMapping(value="/logout", method = RequestMethod.GET)
public ModelAndView logoutPage (HttpServletRequest request, HttpServletResponse response) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null){
new SecurityContextLogoutHandler().logout(request, response, auth);
}
return new ModelAndView ("redirect:/login?logout");
}

Corresponden a las dos configuraciones que hicimos en WebSecurity, uno para poder autenticarnos y redireccionar a la página de Login y otro para poder hacer el Logout.

Además tenemos login.html donde maquetamos una página más bonita que la que viene por defecto en spring.


Play Time!

Si no hiciste nada extraño, podrás levantar la app en el puerto 8080 corriendo la tarea bootRun desde gradle. Para lo que no están muy acostumbrados a grade también pueden hacer:

./gradlew bootRun

Lo primero que vamos a hacer es tratar de acceder a un recurso que esté segurizado. En este caso dentro de HelloWorld controller tenemos el recurso /anon, recuerden que configuramos nuestros filtros de Spring para que tengan precedencia luego de /api

Si ahora intentamos hacer lo mismo en el recurso /api/hello vamos a ver que nuestra app nos devuelve un error 401 con el siguiente mensaje

¡Perfecto! Lo que necesitamos ahora es poder autenticarnos y obtener un token para acceder a este recurso. Para esto, vamos a abrir un navegador y acceder a la siguiente URL:

http://localhost:8080/oauth/authorize?response_type=code&client_id=client_api&redirect_uri=http://localhost:8080

Con esta URL lo que estamos pidiendo es que el client_id: client_api sea autorizado para generar un código (que posteriormente será cambiado por un token), y luego sea re-dirigido a la uri localhost:8080

En la imagen previa, se ve que al intentar acceder a la url de autorización, automáticamente nos re-dirige a una página de login, esto es para determinar quién es el usuario final que está intentando acceder a esto. Nos logueamos con el usuario user.

Esta última redirección (perdón pero no he maquetado esta página) solicita al usuario final que autorice a la aplicación, en este caso client_api a acceder al scope que solicita. Dando autorizar se ejecuta la redirección final.

Esta última redirección nos entrega el código como query param, que luego la aplicación, con sus credenciales, generará un token para acceder al recurso segurizado. Vamos a eso.

Un par de cosas a tener en cuenta en este request son:

  • Hay un header Authorization donde tiene como valor el usuario y el password del client en base 64
  • El método utilizado es POST
  • El código puede ser utilizado solo una única vez

Y como resultado tenemos el access token y el tiempo de expiración. Si ahora intentamos acceder al recurso obtendremos lo siguiente.

¡Y funciona! Podemos acceder al recurso, ya que contamos con los scopes y los roles que definimos previamente.


¿Que sigue?

Nos queda pendiente seguir haciendo pruebas, temas relacionadas al ROL a los SCOPES.

Otro de los puntos importantes es poder separar el authorization server del resource server, basándonos en micro-servicios.

Escalando horizontalmente vamos a necesitar que los token y códigos que generemos tengan otro tipo de persistencia.

También vamos a querer mantener los clientes que generemos y los usuarios finales que tengamos en la aplicación.

Espero que les haya sido de utilidad y cualquier duda que tengan no duden en consultarme.

¡Saludos!