Table of Contents
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 library-api-sec que hay disponible en el repositorio de 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
Añadir las dependencias de Spring Boot Security al proyecto
. . . <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency> . . .
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<SecurityContext> 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<String> 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<GrantedAuthority> authorities = getUserAuthority(user.getRoles()); return buildUserForAuthentication(user, authorities); } private List<GrantedAuthority> getUserAuthority(Set<Role> userRoles) { Set<GrantedAuthority> roles = new HashSet<>(); userRoles.forEach(role -> roles.add(new SimpleGrantedAuthority(role.getName()))); return new ArrayList<>(roles); } private UserDetails buildUserForAuthentication(User user, List<GrantedAuthority> 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<String> roles = userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); return ResponseEntity.ok(new JwtResponse(jwt, userDetails.getUsername(), roles)); }
Funcionamiento
Registro de usuarios
Solicitud de token
Ejecutar operaciones con autenticación
Ejecutar operaciones sin autenticación
Ejemplo
Existe un ejemplo de API securizada con JWT tal y como se explica en este apartado. Está en el repositorio spring-web bajo el nombre library-api-sec
© 2022-2024 Santiago Faci