Securizando un API Rest con JWT y roles

14
41220

Índice de contenidos

  1. Introducción
  2. Entorno
  3. Securizando con roles: diferencia entre @PreAuthorize y @Secured
  4. Vamos al lío
  5. La hora de la verdad
  6. Conclusiones

1. Introducción

Siguiendo la línea de lo que nos contó Álvaro en su tutorial sobre como securizar un API Rest con JWT, vamos a profundizar un poco más en lo que nos ofrece Spring Security para poder proteger nuestra API permitiendo el acceso según los roles asociados a los usuarios.

Como la base es el tutorial anterior, no volveremos a hacer hincapié en lo que Álvaro nos enseñó y lo tomaremos como punto de partida para este tutorial. El ejemplo completo puedes encontrarlo en GitHub.

2. Entorno

  • Hardware: Portátil MacBook Pro 15 pulgadas (2.5 GHz Intel i7, 16GB 1600 Mhz DDR3, 500GB Flash Storage).
  • Sistema Operativo: MacOs Mojave 10.14.2
  • Versiones del software:
    • Java 11
    • Spring Boot 2.1.0.RELEASE
    • Lombok 1.18.4

3. Securizando con roles: diferencia entre @PreAuthorize y @Secured

Los roles nos permiten tener un control más especifico del acceso a nuestra API, pudiendo acotar a un grupo más reducido de usuarios ciertos recursos de nuestro sistema o proteger el acceso a operaciones sensibles o críticas. Por ejemplo, si tenemos una intranet para dar de alta empleados, podríamos permitir el acceso a las altas únicamente a los responsables de RRHH y la consulta de empleados a un perfil más público (como los empleados de la empresa).

Spring Security nos permite de manera sencilla proteger nuestros puntos de API con las etiquetas @PreAuthorize y @Secured indicando los roles que tienen acceso. En función de cómo queramos securizar nuestros endpoints, utilizaremos una anotación u otra:

  • @PreAuthorize es una anotación más nueva que @Secured (disponible desde la versión 3 de Spring Security) y mucho más flexible.
  • @PreAuthorize soporta Spring Expression Language (SpEL) pudiendo acceder a todos métodos y propiedades de la clase SecurityExpressionRoot pudiendo por tanto utilizar expresiones como hasRole, permitAll…mientras que con @Secured indicamos sólo los roles permitidos.
  • Cuando securizamos nuestros endpoints con @Secured indicando varios roles se permite el acceso a cualquier usuario que tenga asociado al menos uno de los roles (equivalente a una expresión OR). Con @PreAuthorize, como admite expresiones (pudiendo usar AND, OR, NOT) podríamos implementar un acceso exclusivo a aquellos usuarios que tengan varios roles asociados:
    • @Secured({«ROLE_ADMIN», «ROLE_USER»}) Acceso a todos usuarios con rol admin o user.
    • @PreAuthorize(«hasRole(‘ROLE_ADMIN’) AND hasRole(‘ROLE_USER’)») Acceso a todos usuarios con rol admin y user.
    • @Secured({«ROLE_ADMIN»}) y @PreAuthorize(«hasRole(‘ROLE_ADMIN’)») son equivalentes.

4. Vamos al lío

4.1 Implementando la API de usuarios

A continuación vamos a ver qué cambios tenemos que hacer en nuestra configuración de Spring Security para securizar nuestra aplicación con roles. La aplicación desarrollada inicializa la base de datos con una serie de roles (admin, user y operational), una serie de usuarios y la configuración de qué roles tienen asociados cada uno de estos usuarios:

  • user1/password1: rol admin y rol usuario
  • user2/password2: rol operational
  • user3/password3: rol usuario

Nuestra aplicación tiene disponible varios endpoints:

  • Accesible para rol usuario:
    • [GET] /users/{id}: Recuperar la información de un usuario por id.
  • Accesible para rol administrador:
    • [POST] /users: Dar de alta usuarios.
    • [GET] /users/{id}: Recuperar la información de un usuario por id.

4.2 Configurando @PreAuthorized

Lo primero que tenemos que hacer es configurar dentro de nuestro WebSecurityConfig que se pueda utilizar el método PreAuthorized para la seguridad:

@Configuration
@EnableWebSecurity
//@EnableGlobalMethodSecurity(securedEnabled = true) // @Secured
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    ...
}

Ahora, protegemos nuestros endpoints para los roles indicados anteriormente, teniendo en cuenta que recuperar el detalle de un usuario estará disponible para para rol admin y user:

//@Secured({"ROLE_ADMIN","ROLE_USER"})
@PreAuthorize("hasRole('ROLE_USER') OR hasRole('ROLE_ADMIN')")
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable long id) {
        ...
}
//@Secured("ROLE_ADMIN")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping
public ResponseEntity<User> saveUser(@RequestBody AuthorizationRequest userRequest) {
	...
}

4.3 Añadiendo los roles al token

Para finalizar, nos falta añadir la información de los roles en el token generado para el usuario. En primer lugar, la instancia de UserDetails que contiene la información del usuario autenticado debe contener los authorities asociados:

@Service("userDetailsService")
public class UserServiceImpl implements UserService {
	...
	@Override
	public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
		final User retrievedUser = userRepository.findByName(userName);
		if (retrievedUser == null) {
			throw new UsernameNotFoundException("Invalid username or password");
		}
		return UserDetailsMapper.build(retrievedUser);
	}
        ...
}

Los roles asociados vendrán informados con un prefijo ROLE_ (es buena práctica hacerlo por legibilidad) seguido del rol definido en nuestra base de datos.

public class UserDetailsMapper {
	public static UserDetails build(User user) {
		return new org.springframework.security.core.userdetails.User(user.getName(), user.getPassword(), getAuthorities(user));
	}
	private static Set<? extends GrantedAuthority> getAuthorities(User retrievedUser) {
		Set<Role> roles = retrievedUser.getRoles();
		Set<SimpleGrantedAuthority> authorities = new HashSet<>();
		roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getName())));
		return authorities;
	}
}

De este modo, la instancia de Authentication a la que está asociada el usuario y que maneja la seguridad ya tiene los roles configurados en nuestro sistema, pudiendo devolver el token con esta información dentro de la propiedad claim:

public class TokenProvider {
	...
	public static String generateToken(Authentication authentication) {
		// Genera el token con roles, issuer, fecha, expiración (8h)
		final String authorities = authentication.getAuthorities().stream()
				.map(GrantedAuthority::getAuthority)
				.collect(Collectors.joining(","));
		return Jwts.builder()
				.setSubject(authentication.getName())
				.claim(AUTHORITIES_KEY, authorities)
				.signWith(SignatureAlgorithm.HS256, SIGNING_KEY)
				.setIssuedAt(new Date(System.currentTimeMillis()))
				.setIssuer(ISSUER_TOKEN)
				.setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY_SECONDS*1000))
				.compact();
	}
        ...
}

Ahora nuestro token ya tiene esta información y podemos recuperarla para construir nuestro UsernamePasswordAuthenticationToken que es el utilizado por Spring Security para saber si tiene acceso al endpoint del servicio:

public class JwtAuthorizationFilter extends OncePerRequestFilter {
	...
	@Override
	protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
			FilterChain filterChain) throws ServletException, IOException {
		String authorizationHeader = httpServletRequest.getHeader(Constants.HEADER_AUTHORIZATION_KEY);
		...
		UserDetails user = userService.loadUserByUsername(userName);

		UsernamePasswordAuthenticationToken authenticationToken = TokenProvider.getAuthentication(token, user);
		SecurityContextHolder.getContext().setAuthentication(authenticationToken);
		filterChain.doFilter(httpServletRequest, httpServletResponse);
	}
}
public class TokenProvider {
	...
	public static UsernamePasswordAuthenticationToken getAuthentication(final String token,
			final UserDetails userDetails) {
		final JwtParser jwtParser = Jwts.parser().setSigningKey(SIGNING_KEY);
		final Jws<Claims> claimsJws = jwtParser.parseClaimsJws(token);
		final Claims claims = claimsJws.getBody();
		final Collection<SimpleGrantedAuthority> authorities =
				Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
						.map(SimpleGrantedAuthority::new)
						.collect(Collectors.toList());
		return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
	}
        ...
}

5. La hora de la verdad

Ahora podemos hacer pruebas con nuestros usuarios. Por ejemplo user1 y user3 , con rol de administrador y user respectivamente, pueden ver el detalle de un usuario. Primero obtendremos un token válido con la llamada a /login:

Usamos el token de la respuesta y hacemos una llamada al servicio para ver el detalle del usuario. Esta llamada nos devuelve una respuesta con código 200 (OK) y la información del usuario:

Si hacéis la prueba con user3, veréis que también tiene acceso al detalle de un usuario. Sin embargo con «user2» no podemos ver el detalle porque no tiene un rol válido. La respuesta será de tipo 403 (Forbidden):

Podéis hacer pruebas también con el endpoint de dar de alta un usuario, que es un poco más restrictivo porque sólo user1, al ser administrador, puede acceder a él.

6. Conclusiones

Es muy común el desarrollo de aplicaciones web que puedan ser gestionadas por usuarios con diferentes perfiles, por lo que la securización de nuestra API en función de roles es algo imprescindible si queremos restringir el acceso a ciertos recursos. Los puntos más relevantes en los que hemos profundizado han sido:

  • La securización por roles con @Secured y @PreAuthorize y la potencia de cada una de ellas, viendo cuándo se puede utilizar una u otra.
  • Informar en nuestro JWT token de las authorities/claims del usuario para autorizar el acceso a los recursos ofrecidos. También hemos visto cómo recuperar del token estos roles y cómo asociarlos a la instancia de tipo Authentication que maneja el acceso.

Podéis descargaros el ejemplo completo para jugar con la API que he implementado o implementar la vuestra propia jugando con los roles y los usuarios. Cualquier pregunta o duda que tengáis no dudéis en escribir en la sección de comentarios del tutorial. Intentaré responder lo antes posible.

14 COMENTARIOS

  1. Hay alguna otra forma de agregar restricción a los métodos del back sin utilizar el @PreAuthorize?

    Creo que hay una complicación cuando hay muchos roles que acceden a un mismo método, entonces la lista sería extensa.

    Muchas gracias por la documentación … de gran ayuda

    • Hola Ramiro,

      En el caso de tener una larga lista de roles, igual lo mejor sería agrupar varios de ellos en un único rol o utilizar otro mecanismo de securización. Cuando en una aplicación casi todo el mundo puede hacer de todo hay que darle una vuelta al tema de seguridad.

      Un saludo

    • Hola Roberto,

      Normalmente a los tokens se les establece un tiempo de refresco, para evitar que alguien intercepte un token y pueda hacer llamadas a nuestra API:
      – Obligado en APIs consumidas por recursos web.
      – En el caso de aplicaciones móviles, es recomendado pero muchas de ellas utilizan tokens permanentes cuando el usuario se loga solo la primera vez.

      Con JWT, hay que implementar la política de refresco de tokens en tu servicio de autenticación pero otras alternativas como OAuth ya lo tienen implementado y es cuestión de adaptarlo a la aplicación:
      https://stackoverflow.com/questions/43090518/how-to-properly-handle-a-jwt-refresh

      Normalmente, el tiempo de refresco es pequeño, para evitar que los tokens interceptados puedan sere usados mucho tiempo

  2. !Hola! Antes que nada, muchas gracias por el tutorial

    Me preguntaba si es óptimo o eficiente hacer esto pero con permisos individuales, tales como «INSERTAR_USUARIOS»,»VER_USUARIO» , etc… Y en la base otorgar permisos en una tabla que contenga el idUsuario y el idPermiso. Asumo el JWT se podría extender muchísimo y la serialización del token tambièn ¿ podría afectar el funcionamiento o ser poco práctico en un proyecto real y escalable?

    • Hola Camilo,

      Normalmente si se quieren hacer ese tipo de cosas podría definirse un rol que tuviera distintos tipos de permisos. En nuestras aplicaciones normalmente encontramos usuarios con permisos de lectura, escritura, administrador…:

      – USER_READONLY: solo permisos a los puntos de API de lectura
      – USER_ALL: todos permisos
      – ADMIN: administración

      Tener muchos roles para un mismo punto de API al final es como no tener nada, ya que eso significa que casi todos pueden acceder a todo y hacer a tu API poco segura.

      • Hola, ahora me queda más claro como implementarlo en mi API para limitar los permisos siempre amarrado al rol

        Muchas gracias nuevamente

  3. Excelente post! muy completo y entendible al 100%. Gracias a personas como vos, somos más los que nos vamos sumando a este tipo de tecnologías.
    La única observación que tengo para hacer, es que los datos de los usuario que se encuentra en el script, tendrían que estar en algún lado, porque difirien de los datos de tutorial y me llevó un tiempo poder encontrar la contraseña correcta (Supuse que para el usuario 1, la contraseña iba a ser la misma que la del tutorial. Por el resto de las cosas maravilloso y me hizo entender muchas cosas que tenía a medio camino.

    Saludos!!

    • Hola Martín,

      Gracias por el comentario. Me alegro que te haya sido útil.
      Por supuesto, el código hay que tomarlo como referencia, pero puedes ajustar ciertas cosas a tus necesidades (p.e. el password que van a tener los usuarios, sería cambiar el fichero import.sql).

      Un saludo.

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad