Acceso a Datos

2º DAM - Curso 2023-2024

User Tools

Site Tools


apuntes:jwt

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:

Figure 1: Flujo petición API securizada con JWT (fuente: https://www.positronx.io)

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

Figure 2: Registro de usuarios en la API

Solicitud de token

Figure 3: Solicitud de token

Ejecutar operaciones con autenticación

Figure 4: Petición con cabecera Authorization y Bearer token

Ejecutar operaciones sin autenticación

Figure 5: 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 spring-web bajo el nombre library-api-sec


© 2022-2024 Santiago Faci

apuntes/jwt.txt · Last modified: 22/12/2022 00:48 by Santiago Faci