====== Securización de APIs con Spring Boot y JWT ====== En este apartado añadiremos la configuración y código necesario para securizar con JWT una API desarrollada con Spring Boot. El punto de partida puede ser cualquier proyecto Spring Boot que funcione como una API REST. Todo el apartado se basa en proyecto [[https://github.com/codeandcoke/spring-web/tree/master/library-api-sec|library-api-sec]] que hay disponible en el repositorio de [[https://github.com/codeandcoke/spring-web|spring-web]] Securizando la API, el flujo al utilizarla se modifica un poco. Ahora nuestra intención es que las operaciones solamente puedan ser invocadas por usuarios identificados (exceptuando los endpoints para registrarse y obtener el token). Asi, nuestra API quedará protegida de forma que solamente los usuarios registrados podan hacer uso de ella (también podemos disponer de operaciones abiertas y otras securizadas, e incluso mediante el uso de roles). Para aquellas operaciones que se encuentren securizadas, será necesario añadir un //Bearer// token (JWT) como valor para la cabecera //Authorization//. Y para ello se deberá disponer de un usuario y contraseña. En la API de ejemplo, añadiremos también un endpoint para el registro de usuario. Asi, podremos registrarnos, solicitar el token e invocar a cualquiera de las operaciones securizadas de esta API. Todo aquello siguiendo el flujo que se muestra a continuación:
{{ jwt.png?550 }} Flujo petición API securizada con JWT (fuente: https://www.positronx.io)
===== JWT ===== [[https://jwt.io/|JSON Web Token]] ===== Añadir las dependencias de Spring Boot Security al proyecto ===== . . . org.springframework.boot spring-boot-starter-security io.jsonwebtoken jjwt 0.9.1 org.springframework.boot spring-boot-starter-oauth2-resource-server . . . ===== Crear los certificados para firmar y decodificar el JWT ===== En este paso crearemos los certificados que se utilizarán para firmar (privado) y para comprobar el token (público). Más adelante, los añadiremos el fichero de propiedades **application.properties** para cargarlos cuando hagan falta en el fichero de configuración global de la API **LibraryConfig.java**. # Crear un par de claves rsa (keypair.pem) openssl genrsa -out keypair.pem 2048 # Crear un certificado público (app.pub) openssl rsa -in keypair.pem -pubout -out app.pub # Crear un certificado privado (app.key) openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in keypair.pem -out app.key ===== Configuración global de seguridad para la API: ApiConfiguration ===== @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity( // securedEnabled = true, // jsr250Enabled = true, prePostEnabled = true) public class LibraryConfig extends WebSecurityConfigurerAdapter { @Value("${key.public}") RSAPublicKey key; @Value("${key.private}") RSAPrivateKey priv; @Autowired private LibraryUserDetailsService libraryUserDetailsService; @Autowired private AuthEntryPointJwt unauthorizedHandler; @Bean public AuthTokenFilter authenticationJwtTokenFilter() { return new AuthTokenFilter(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception { authenticationManagerBuilder.userDetailsService(libraryUserDetailsService).passwordEncoder(passwordEncoder()); } @Override protected void configure(HttpSecurity http) throws Exception { http.cors().and().csrf().disable() .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() .authorizeRequests().antMatchers("/register", "/token", "/h2-console/**").permitAll() .anyRequest().authenticated(); http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean JwtDecoder jwtDecoder() { return NimbusJwtDecoder.withPublicKey(this.key).build(); } @Bean JwtEncoder jwtEncoder() { JWK jwk = new RSAKey.Builder(this.key).privateKey(this.priv).build(); JWKSource jwks = new ImmutableJWKSet<>(new JWKSet(jwk)); return new NimbusJwtEncoder(jwks); } } ===== Utilidades para trabajar con JWT: JwtUtils ===== Contiene los métodos de utilidad **generateJwtToken** para generar un token (para los casos en los que se solicite) y **validateJwtToken** para verificar uno existente (para comprobar el que se indique en cualquier operación). Se apoya en los Beans **JwtEncoder** y **JwtDecoder** que hemos definido en **LibraryConfig.java** @Component @Configuration public class JwtUtils { private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class); @Autowired private JwtEncoder jwtEncoder; @Autowired private JwtDecoder jwtDecoder; public String generateJwtToken(Authentication authentication) { Instant now = Instant.now(); String scope = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(" ")); JwtClaimsSet claims = JwtClaimsSet.builder() .issuer("self") .issuedAt(now) .expiresAt(now.plus(1, ChronoUnit.HOURS)) .subject(authentication.getName()) .claim("scope", scope) .build(); return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); } public String getUserNameFromJwtToken(String token) { return jwtDecoder.decode(token).getSubject(); } public boolean validateJwtToken(String authToken) { try { Jwt jwt = jwtDecoder.decode(authToken); return true; } catch (SignatureException e) { logger.error("Invalid JWT signature: {}", e.getMessage()); } catch (MalformedJwtException e) { logger.error("Invalid JWT token: {}", e.getMessage()); } catch (ExpiredJwtException e) { logger.error("JWT token is expired: {}", e.getMessage()); } catch (UnsupportedJwtException e) { logger.error("JWT token is unsupported: {}", e.getMessage()); } catch (IllegalArgumentException e) { logger.error("JWT claims string is empty: {}", e.getMessage()); } return false; } } ===== Esquema de respuesta JWT: JwtResponse ===== Aqui definimos el formato que queremos para la respuesta a la petición de token. En este caso se devuelve el propio token (JWT), el nombre de usuario de quien lo solicita y los roles que tiene asignados (por ahora ninguno) @Data @AllArgsConstructor @NoArgsConstructor public class JwtResponse { private String token; private String username; private List roles; } ===== Gestión de errores de autenticación: AuthEntryPointJwt ===== En esta clase, en el método **commence** se define cómo se quieren tratar los errores de autenticación. En este caso se registra una traza en en log y se responde con una respuesta vacía y el código de estado 401 Unauthorized. @Component public class AuthEntryPointJwt implements AuthenticationEntryPoint { private static final Logger logger = LoggerFactory.getLogger(AuthEntryPointJwt.class); @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { logger.error("Unauthorized error: {}", authException.getMessage()); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Error: Unauthorized"); } } ===== Procesamiento del JWT: AuthTokenFilter ===== public class AuthTokenFilter extends OncePerRequestFilter { @Autowired private JwtUtils jwtUtils; @Autowired private LibraryUserDetailsService userDetailsService; private static final Logger logger = LoggerFactory.getLogger(AuthTokenFilter.class); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String jwt = parseJwt(request); if (jwt != null && jwtUtils.validateJwtToken(jwt)) { String username = jwtUtils.getUserNameFromJwtToken(jwt); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception exception) { logger.error("Cannot set user authentication: {}", exception); } filterChain.doFilter(request, response); } private String parseJwt(HttpServletRequest request) { String headerAuth = request.getHeader("Authorization"); if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) { return headerAuth.substring(7); } return null; } } ===== Gestión de credenciales de usuario: UserDetailsService ===== @Service public class LibraryUserDetailsService implements UserDetailsService { @Autowired private UserService userService; @Override @Transactional public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userService.findByUsername(username); if (user == null) throw new UsernameNotFoundException("Invalid username/password"); List authorities = getUserAuthority(user.getRoles()); return buildUserForAuthentication(user, authorities); } private List getUserAuthority(Set userRoles) { Set roles = new HashSet<>(); userRoles.forEach(role -> roles.add(new SimpleGrantedAuthority(role.getName()))); return new ArrayList<>(roles); } private UserDetails buildUserForAuthentication(User user, List authorities) { return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), user.isActive(), true, true, true, authorities); } } ===== Fichero de configuración: application.properties ===== spring.jpa.hibernate.ddl-auto=update spring.jpa.properties.hibernate.globally_quoted_identifiers=true server.port=8080 spring.datasource.url=jdbc:h2:file:/Users/santi/library_sec.db spring.datasource.username=sa spring.datasource.password=password spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.h2.console.enabled=true key.private=classpath:app.key key.public=classpath:app.pub ===== Controllers: Registro y de usuarios y solicitud de token ===== ==== Registro de usuarios ==== @RequestMapping(value = "/register", method = RequestMethod.POST) public ResponseEntity saveUser(@RequestBody UserDTO user) throws Exception { return ResponseEntity.ok(userService.addUser(user)); } ==== Endpoint para solicitar token ==== @PostMapping("/token") public ResponseEntity authenticateUser(@Valid @RequestBody UserDTO userDTO) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(userDTO.getUsername(), userDTO.getPassword())); SecurityContextHolder.getContext().setAuthentication(authentication); String jwt = jwtUtils.generateJwtToken(authentication); User userDetails = (User) authentication.getPrincipal(); List roles = userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); return ResponseEntity.ok(new JwtResponse(jwt, userDetails.getUsername(), roles)); } ---- ===== Funcionamiento ===== * [[https://datos.codeandcoke.com/apuntes:jwt#registro_de_usuarios|Registro de usuario]] * [[https://datos.codeandcoke.com/apuntes:jwt#solicitud_de_token|Solicitud de token]] * [[https://datos.codeandcoke.com/apuntes:jwt#ejecutar_operaciones_con_autenticacion|Ejemplo de operación con autenticación: Bearer token]] * [[https://datos.codeandcoke.com/apuntes:jwt#ejecutar_operaciones_sin_autenticacion|Ejemplo de operación no autenticada: Error 401 Unauthorized]] ==== Registro de usuarios ====
{{ register.png }} Registro de usuarios en la API
==== Solicitud de token ====
{{ token.png }} Solicitud de token
==== Ejecutar operaciones con autenticación ====
{{ get_books.png }} Petición con cabecera Authorization y Bearer token
==== Ejecutar operaciones sin autenticación ====
{{ 401.png }} Error 401 al no especificar el token en la cabecera Authorization
===== Ejemplo ===== Existe un ejemplo de API securizada con JWT tal y como se explica en este apartado. Está en el repositorio [[https://github.com/codeandcoke/spring-web|spring-web]] bajo el nombre [[https://github.com/codeandcoke/spring-web/tree/master/library-api-sec|library-api-sec]] ---- (c) 2022-{{date>%Y}} Santiago Faci