20 jun 2019

Securizando APIs: SpringBoot + Security + JWT + Auth en Base de datos

1. Introducción

Dentro de la cantidad de "sobreinformación" que existe actualmente, voy a intentar dar una píldoras resumidas de los pasos a seguir para securizar vuestra API creada con SringBoot.
Vamos a usar JSON Web Tokens (JWT), activaremos Spring Security con la configuración adecuada, y vamos a securizarlo usando una Base de datos H2 como ejemplo de uso de JPA (A modo de ejemplo debería bastar)
Las configuraciones en detalle de cada uno de las piezas, las dejo a discreción del lector en profundizar en ellas.

Os dejo el código fuente de este tutorial en github para vuestra consulta.

2. JWT

Cogiendo la definición de la wikipedia, no vamos a repetirla, destacar solamente:
La mayor ventaja de los tokens sobre otros sistemas es el hecho de que no tenga estado (stateless). El backend no necesita mantener un registro de los tokens. Cada token es compacto y auto contenido. Contiene todos los datos necesarios para comprobar su validez, así como la información del usuario para las diferentes peticiones.

3. Vamos a por el código

La estructura del proyecto lo he dividido en la parte que pega al modelo (la cual está asociada con JPA a un ejemplo con H2 y se debería cambiar por el sistema que tenga cada uno) y la otra parte de Security que es la que sería común a cualquier microservicio, incluídos aquellos que tengan que hacer uso del JWT cuando quedan expuestos.

En la parte de Security no vamos a hacer uso de ningún controller, realmente no nos hace falta, si no que a través de los Filters que nos proporciona el propio framework de Spring y de Security podemos ser capaces de  tener las piezas necesarias para devolver y analizar un JWT.

Por ultimo tenemos una serie de ficheros de configuración (appication.properties, SecurityConfig y SecurityConstants)

4. A programar!


4.1 Autenticación

Lo primero que vamos a analizar es la Authenticación (Respondemos a la pregunta de ¿quién? ).
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

 private AuthenticationManager authenticationManager;

 public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
  this.authenticationManager = authenticationManager;

  // Sin esta parte la url que atendería sería la de /login
  setFilterProcessesUrl(SecurityConstants.SIGNIN_URL);

 }

 @Override
 public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
   throws AuthenticationException {
  try {
   Usuario credenciales = new ObjectMapper().readValue(request.getInputStream(), Usuario.class);

   return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
     credenciales.getUsername(), credenciales.getPassword(), new ArrayList<>()));
  } catch (IOException e) {
   throw new RuntimeException(e);
  }
 }

 @Override
 protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
   Authentication auth) throws IOException, ServletException {

  String token = Jwts.builder().setIssuedAt(new Date()).setIssuer(SecurityConstants.ISSUER_INFO)
    .setSubject(((User) auth.getPrincipal()).getUsername()).claim("roles", "user") 
    .setExpiration(new Date(System.currentTimeMillis() + SecurityConstants.TOKEN_EXPIRATION_TIME))
    .signWith(SignatureAlgorithm.HS512, SecurityConstants.SUPER_SECRET_KEY).compact();

  response.addHeader(SecurityConstants.HEADER_AUTHORIZACION_KEY,
    SecurityConstants.TOKEN_BEARER_PREFIX + " " + token);

  response.setContentType("application/json");
  response.setCharacterEncoding("UTF-8");
  response.getWriter().write("{\"" + SecurityConstants.TOKEN_PREFIX + "\":\"" + token + "\"}");
 }
}



Vamos a resaltar las siguientes partes:
// Sin esta parte la url que atendería sería la de /login
setFilterProcessesUrl(SecurityConstants.SIGNIN_URL);

Por otro lado nos fijamos que en el proceso de authenticate le pasamos como queremos hacer la autenticación:
new UsernamePasswordAuthenticationToken()

Y en caso de tener éxito generamos el token. Este se devolverá en la cabecera y además en el body (no es necesario pero es más cómodo).

4.2 Autorización

Ahora vamos a responder ¿Para qué tengo permiso?.
public class JWTAuthorizationFilter extends BasicAuthenticationFilter {

 public JWTAuthorizationFilter(AuthenticationManager authManager) {
  super(authManager);
 }

 @Override
 protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
   throws IOException, ServletException {
  String header = req.getHeader(SecurityConstants.HEADER_AUTHORIZACION_KEY);
  if (header == null || !header.startsWith(SecurityConstants.TOKEN_BEARER_PREFIX)) {
   chain.doFilter(req, res);
   return;
  }
  UsernamePasswordAuthenticationToken authentication = getAuthentication(req);
  SecurityContextHolder.getContext().setAuthentication(authentication);
  chain.doFilter(req, res);
 }

 private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
  String token = request.getHeader(SecurityConstants.HEADER_AUTHORIZACION_KEY);
  if (token != null) {
   // Se procesa el token y se recupera el usuario.
   String user = Jwts.parser().setSigningKey(SecurityConstants.SUPER_SECRET_KEY)
     .parseClaimsJws(token.replace(SecurityConstants.TOKEN_BEARER_PREFIX, "")).getBody().getSubject();

   if (user != null) {
    return new UsernamePasswordAuthenticationToken(user, null, new ArrayList<>());
   }
   return null;
  }
  return null;
 }
}


*Este filtro podría cambiarse por OnePerRequest, o por un Generic, etc ...

Como vemos no es muy complejo de entender, simplemente buscamos en la cabecera el token. Si lo encontramos lo validamos, y si todo es correcto devolvemos UsernamePasswordAuthenticationToken(), pero en este caso sin la PASS porque ya no nos hace falta.

4.3 La "magia" de Spring

Una de las bondades de Spring es que muchas cosas ya las da intrínsecamente el propio framework. Es algo que está muy bien, aunque en mi caso me suele poner de los nervios y prefiero tener el control, pero que en la mayoría de los caso nos ahorra mucho trabajo.

@Service
public class UsuarioDetailsServiceImpl implements UserDetailsService {

 private UsuarioRespository usuarioRepository;

 public UsuarioDetailsServiceImpl(UsuarioRespository usuarioRepository) {
  this.usuarioRepository = usuarioRepository;
 }

 @Override
 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
  Usuario usuario = usuarioRepository.findByUsername(username);
  if (usuario == null) {
   throw new UsernameNotFoundException(username);
  }
  return new User(usuario.getUsername(), usuario.getPassword(), emptyList());
 }
}


El mero hecho de implementar esta Interfaz, le da a Security la capacidad de saber cómo debe hacer para recuperar un usuario de la Base de Datos.

4.4 Configuración


El último paso es la parte de configuración de los filtros y de security. La que aquí pongo es muy elemental y se debe se bastante más exquisito en las cuestiones a añadir.
@Configuration
// @EnableGlobalMethodSecurity(securedEnabled = true) // Este es el método 1 de
// securización a usar: @Secured
@EnableGlobalMethodSecurity(prePostEnabled = true) // Este es el método 2 de securización a usar: @PreAuthorize
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    protected UserDetailsService userDetailsService;

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.cors().and().csrf().disable().authorizeRequests().antMatchers("/h2-console/**").permitAll().anyRequest()
                .authenticated().and().addFilter(new JWTAuthenticationFilter(authenticationManager()))
                .addFilter(new JWTAuthorizationFilter(authenticationManager())).sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.headers().frameOptions().disable();

    }

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        // Se define la clase que recupera los usuarios y el algoritmo para procesar las
        // passwords
        // Se podría usar un método custom en vez de bCryptPasswordEncoder()

        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());
        return source;
    }

}



Como podemos ver, se ha usado un  método para desencriptar la contraseña (cada implementación debería sobreescribir este método y adaptarlo a sus necesidades.

Y por ultimo enlazamos "la magia":
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());


5. Resumiendo


No he pretendido ser muy exquisito con todo el código aquí explicado, pero sí dejar "subrayado" aquellas partes que en otros lenguajes o frameworks (como Node) es más fácil de ver y quizá en Spring está algo más oculto.



Share This!



No hay comentarios:

Publicar un comentario

Nota: solo los miembros de este blog pueden publicar comentarios.