Hoy vamos a ver como realizar autenticación de una API REST con JWT y Spring Boot. Explicaremos cómo generar el token y como usar ese mismo token para acceder a recursos de la misma API. Paso a paso veremos los componentes claves para realizar el ejemplo completo.
Para el ejemplo utilizaremos las siguientes librerías:
- Spring Boot: 2.6.3
- spring-boot-starter-security
- spring-boot-starter-web
- java-jwt: 3.18.3
El primer paso será crear la configuración de seguridad que nos permita indicar la configuración de nuestra API, en base a los siguientes componentes:
- @EnableWebSecurity: Permite habilitar la seguridad en nuestra API.
- WebSecurityConfigurerAdapter: Nos permite sobrescribir el comportamiento por defecto de la seguridad proporcionada por Spring.
- CORS: Habilitamos la configuración por defecto y que podemos sobrescribir a través del bean CorsFilter.
- SessionCreationPolicy: Define la API como Stateless evitando la creación de HTTPSession y deshabilitamos el uso de cookies.
- CSRF: Al configurar la API como stateless no necesitamos el uso de cookies.
- Configuramos la respuesta de la API en caso de error en la autenticación
- Declaramos como públicas los métodos que permiten el login y la obtención de tokens JWT e implementamos la seguridad para el resto de métodos.
- Añadimos el filtro que compruebe y valide si la petición tiene un token JWT.
@EnableWebSecurity // 1 public class SecurityConfig extends WebSecurityConfigurerAdapter { // 2 @Autowired private JwtTokenFilter jwtTokenFilter; @Override protected void configure(HttpSecurity http) throws Exception { // Enable CORS and disable CSRF. 3 and 5 http = http.cors().and().csrf().disable(); // Set session management to stateless. 4 http = http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and(); // Set unauthorized requests exception handler. 6 http = http.exceptionHandling().authenticationEntryPoint((request, response, ex) -> { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage()); }).and(); // Set permissions on endpoints. 7 http.authorizeRequests() // Our public endpoints .antMatchers("/login", "/refresh-token").permitAll() // Our private endpoints .anyRequest().authenticated(); // Add JWT token filter. 8 http.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class); } // Used by spring security if CORS is enabled. 3 @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.setAllowedOrigins(Arrays.asList("*")); config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE")); config.setAllowedHeaders(Arrays.asList("authorization", "Authorization",
"content-type","Content-Type", "Access-Control-Allow-Origin",
"Access-Control-Allow-Headers", "Access-Control-Allow-Methods"));
source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }
El siguiente paso será crear el filtro JWT que hemos visto en el paso anterior. El filtro permitirá que las llamadas que se realicen a nuestra API que no tengan la cabecera de autenticación con el token JWT sean rechazadas y devuelvan error 401. En el cual se realizarán los siguientes pasos:
- Obtener el token de la request y validarlo con la utilidad JwtTokenUtil.
- Obtener los datos del usuario a través provisto por el propio Token.
- Asociar los datos del usuario al contexto de Spring Security
@Component public class JwtTokenFilter extends OncePerRequestFilter { @Autowired private JwtTokenUtil jwtTokenUtil; @Override protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain chain) throws ServletException, IOException { // Get authorization header and validate final String header = request.getHeader(HttpHeaders.AUTHORIZATION); if (ObjectUtils.isEmpty(header) || !header.startsWith("Bearer ")) { chain.doFilter(request, response); return; } // Get jwt token and validate final String token = header.split(" ")[1].trim(); if (!jwtTokenUtil.validate(token)) { chain.doFilter(request, response); return; } // Obtains UserDetails from the token itself UserDetails userDetails = jwtTokenUtil.getUserDetails(token); // Associated UserDetails to Security Context UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); // Continue with the filter chain chain.doFilter(request, response); } }
Como hemos indicado vamos a utilizar la librería java-jwt, aunque hay muchas otras disponibles y todas funcionan de manera similar debido a que siguen la especificación RFC 7519.
Recordemos que JWT es un token dividido en tres partes: Cabecera, Cuerpo y Firma. Y que la tercera es la encriptación de las dos anteriores. Para realizar esta encriptación utiliza un algoritmo que le indiquemos y encripta el contenido del token con ayuda de una clave secreta. Esta clave secreta estará almacenada en nuestro ejemplo en el fichero jwt-secret.
Además otra de las características del propio token es que nos permite almacenar información en forma de Claims. Las cuales puede contener información de distintos ambitos pero principalmente asociada al usuario. En nuestro caso crearemos un token con las siguientes caracteristicas:
- issuer: Claim por defecto asociado al identificador de nuestra aplicación
- subject: Claim por defecto asociado al nombre de usuario
- id: Identificador del usuario
- roles: Listado de roles asociados al usuario
- IssuedAt: Fecha en la cual se ha generado el token.
- ExpireAt: Fecha a partir de la cual el token no será válido.
public class JwtTokenUtil { private Algorithm algorithmHS; private JWTVerifier verifier; @Value("${jwt.api.issuer}") private String loginApiIssuer; @Value("${jwt.api.jwtExpiration}") private int jwtExpiration; public String generateAccessToken(final Long userId, final String userName, final List<String> roles) { return JWT.create().withIssuer(loginApiIssuer).withSubject(userName).withIssuedAt(getIssuedAt()) .withClaim("id", userId).withClaim("roles", roles).withExpiresAt(getExpireAt()).sign(getAlgorithmHS()); } private Algorithm getAlgorithmHS() { if (algorithmHS == null) { try { File resource = new ClassPathResource("jwt-secret").getFile(); String text = new String(Files.readAllBytes(resource.toPath()), Charset.defaultCharset()); algorithmHS = Algorithm.HMAC512(text); } catch (IOException e) { // Invalid signature/claims log.error("Error getting expiration date: " + e.getMessage(), e); } } return algorithmHS; } public DecodedJWT getDecodedJWT(final String token) { DecodedJWT jwt = null; try { jwt = getVerifier().verify(token); } catch (JWTVerificationException e) { // Invalid signature/claims log.error("Error validating token JWT: " + e.getMessage(), e); } return jwt; } public UserDetails getUserDetails(final String token) { DecodedJWT decoded = getDecodedJWT(token); return JWTUserDetailsImpl.build(decoded.getClaim("id").asLong(), decoded.getSubject(), decoded.getClaim("roles").asList(String.class)); } private JWTVerifier getVerifier() { if (verifier == null) { verifier = JWT.require(getAlgorithmHS()).withIssuer(loginApiIssuer).build(); } return verifier; } public boolean validate(final String token) { return getDecodedJWT(token) != null; } //more utilities }
Ya solo nos queda el método de login con el cual podremos crear el token a través de los datos identificativos del usuario. Para obtener y almacenar los datos de usuario, Spring provée de un par de interfaces, como son UserDetailsService y UserDetails. La información a obtener asociada al usuario, permitirá la autenticación y autorización del mismo. UserDetailsService obtendrá los datos del usuario puede ser una BBDD u otra fuente.
@RestController public class LoginController { @Autowired MockDetailsService mockDetailsService; @Autowired JwtTokenUtil jwtTokenUtil; private ResponseEntity<LoginResponse> getLoginResponseFromUserDetails(final JWTUserDetailsImpl userDetails) { List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); String token = jwtTokenUtil.generateAccessToken(userDetails.getId(),
userDetails.getUsername(), roles); return new ResponseEntity<>(new LoginResponse(token), HttpStatus.OK); } @PostMapping("/login") public ResponseEntity<LoginResponse> login(@RequestBody final LoginRequest request) { JWTUserDetailsImpl userDetails = (JWTUserDetailsImpl) mockDetailsService .loadUserByUsername(request.getUserName()); return getLoginResponseFromUserDetails(userDetails); } @PostMapping("/refresh-token") public ResponseEntity<LoginResponse> refreshToken(@RequestBody final String token) { JWTUserDetailsImpl userDetails = (JWTUserDetailsImpl) jwtTokenUtil.getUserDetails(token); return getLoginResponseFromUserDetails(userDetails); } }
Como ultima aclaración, indicar que en el filtro no necesitaremos volver a recurrir a la fuente de datos para obtener los datos del usuario. Puesto que como ya hemos indicado el token JWT ya contiene la información del usuario y es más eficiente obtener los datos del usuario del propio JWT.
Como siempre, podréis consultar el código aqui.
No hay comentarios:
Publicar un comentario