diff --git a/gradox2/src/main/java/com/example/gradox2/persistence/entities/User.java b/gradox2/src/main/java/com/example/gradox2/persistence/entities/User.java index 67f5941..a06d8bb 100644 --- a/gradox2/src/main/java/com/example/gradox2/persistence/entities/User.java +++ b/gradox2/src/main/java/com/example/gradox2/persistence/entities/User.java @@ -81,6 +81,10 @@ public class User implements UserDetails{ @Column(nullable = false) private boolean enabled = false; + @Builder.Default + @Column(name = "token_version", nullable = false) + private int tokenVersion = 1; + @Override public boolean isEnabled() { return enabled; diff --git a/gradox2/src/main/java/com/example/gradox2/security/JwtAuthFilter.java b/gradox2/src/main/java/com/example/gradox2/security/JwtAuthFilter.java index 2d4c94c..9179012 100644 --- a/gradox2/src/main/java/com/example/gradox2/security/JwtAuthFilter.java +++ b/gradox2/src/main/java/com/example/gradox2/security/JwtAuthFilter.java @@ -45,11 +45,20 @@ protected void doFilterInternal(HttpServletRequest request, try { if (jwtUtils.isTokenValid(token)) { final String username = jwtUtils.extractUsername(token); + final Integer tokenVersion = jwtUtils.extractVersion(token); - if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + // Tolerancia cero: los tokens legacy (sin claim `ver`) se rechazan y fuerzan re-login. + if (username == null || tokenVersion == null) { + filterChain.doFilter(request, response); + return; + } + + if (SecurityContextHolder.getContext().getAuthentication() == null) { User user = userRepository.findByUsername(username).orElse(null); - if (user != null) { + // El JWT debe pertenecer a un usuario existente, habilitado y con la versión + // de token vigente (revocación global vía token_version). + if (user != null && user.isEnabled() && user.getTokenVersion() == tokenVersion) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); diff --git a/gradox2/src/main/java/com/example/gradox2/security/JwtUtils.java b/gradox2/src/main/java/com/example/gradox2/security/JwtUtils.java index d6ea33d..7e3c4bd 100644 --- a/gradox2/src/main/java/com/example/gradox2/security/JwtUtils.java +++ b/gradox2/src/main/java/com/example/gradox2/security/JwtUtils.java @@ -36,6 +36,7 @@ public String generateToken(User user) { return Jwts.builder() .setSubject(user.getUsername()) .claim("role", user.getRole().name()) + .claim("ver", user.getTokenVersion()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + jwtExpirationMs)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) // Corregido: Se pasan la clave y el algoritmo por separado. @@ -46,6 +47,25 @@ public String extractUsername(String token) { return parseClaims(token).getSubject(); } + /** + * Devuelve el claim de versión del token ({@code ver}), o null si el claim no existe. + * Un token sin {@code ver} se considera legacy (pre-despliegue) y debe ser rechazado. + */ + public Integer extractVersion(String token) { + Claims claims = parseClaims(token); + Object ver = claims.get("ver"); + if (ver == null) { + return null; + } + if (ver instanceof Integer) { + return (Integer) ver; + } + if (ver instanceof Number) { + return ((Number) ver).intValue(); + } + return Integer.parseInt(ver.toString()); + } + public boolean isTokenValid(String token) { try { parseClaims(token); diff --git a/gradox2/src/main/java/com/example/gradox2/service/implementation/AuthServiceImpl.java b/gradox2/src/main/java/com/example/gradox2/service/implementation/AuthServiceImpl.java index ae5ffc2..5af5ce8 100644 --- a/gradox2/src/main/java/com/example/gradox2/service/implementation/AuthServiceImpl.java +++ b/gradox2/src/main/java/com/example/gradox2/service/implementation/AuthServiceImpl.java @@ -160,6 +160,7 @@ public void resetPassword(PasswordResetConfirmRequest request) { User user = resetToken.getUser(); user.setPasswordHash(passwordEncoder.encode(request.getNewPassword())); + user.setTokenVersion(user.getTokenVersion() + 1); user.getRefreshTokens().clear(); userRepository.save(user); passwordResetTokenRepository.delete(resetToken); diff --git a/gradox2/src/main/java/com/example/gradox2/service/implementation/UserServiceImpl.java b/gradox2/src/main/java/com/example/gradox2/service/implementation/UserServiceImpl.java index 64f5b60..5317a5d 100644 --- a/gradox2/src/main/java/com/example/gradox2/service/implementation/UserServiceImpl.java +++ b/gradox2/src/main/java/com/example/gradox2/service/implementation/UserServiceImpl.java @@ -176,6 +176,7 @@ public void banUser(Long id) { User user = userRepository.findById(id) .orElseThrow(() -> new NotFoundException("Usuario no encontrado")); user.setEnabled(false); + user.setTokenVersion(user.getTokenVersion() + 1); userRepository.save(user); } diff --git a/gradox2/src/main/resources/application-local.properties b/gradox2/src/main/resources/application-local.properties index 42e69ef..2a55c90 100644 --- a/gradox2/src/main/resources/application-local.properties +++ b/gradox2/src/main/resources/application-local.properties @@ -17,7 +17,7 @@ spring.jpa.show-sql=true spring.flyway.enabled=true jwt.secret=${JWT_SECRET:local-dev-jwt-secret-min-32-bytes-long} -jwt.expiration=${JWT_EXPIRATION:86400000} +jwt.expiration=${JWT_EXPIRATION:900000} spring.mail.host=${SPRING_MAIL_HOST:localhost} spring.mail.port=${SPRING_MAIL_PORT:1025} diff --git a/gradox2/src/main/resources/application-prod.properties b/gradox2/src/main/resources/application-prod.properties index 706ac3a..c9ee737 100644 --- a/gradox2/src/main/resources/application-prod.properties +++ b/gradox2/src/main/resources/application-prod.properties @@ -17,7 +17,7 @@ spring.jpa.show-sql=false spring.flyway.enabled=true jwt.secret=${JWT_SECRET} -jwt.expiration=${JWT_EXPIRATION:86400000} +jwt.expiration=${JWT_EXPIRATION:900000} spring.mail.host=${SPRING_MAIL_HOST} spring.mail.port=${SPRING_MAIL_PORT} diff --git a/gradox2/src/main/resources/application-test.properties b/gradox2/src/main/resources/application-test.properties index 86c23aa..9a51788 100644 --- a/gradox2/src/main/resources/application-test.properties +++ b/gradox2/src/main/resources/application-test.properties @@ -24,7 +24,7 @@ s3.presign.ttl-seconds=120 # JWT configuration for tests jwt.secret=test-jwt-secret-that-is-long-enough-for-hs256 -jwt.expiration=3600000 +jwt.expiration=900000 # Disable mail for tests spring.mail.host=localhost diff --git a/gradox2/src/main/resources/db/migration/V5__add_token_version.sql b/gradox2/src/main/resources/db/migration/V5__add_token_version.sql new file mode 100644 index 0000000..784bf07 --- /dev/null +++ b/gradox2/src/main/resources/db/migration/V5__add_token_version.sql @@ -0,0 +1,5 @@ +-- V5: revocación global de sesiones JWT vía token_version. +-- Cada evento de seguridad (reset de contraseña, baneo) incrementa esta columna +-- y el JwtAuthFilter rechaza los tokens con un `ver` inferior al actual. + +alter table users add column token_version int not null default 1; diff --git a/gradox2/src/test/java/com/example/gradox2/AuthSessionIntegrationTest.java b/gradox2/src/test/java/com/example/gradox2/AuthSessionIntegrationTest.java index af803c1..7fd8a49 100644 --- a/gradox2/src/test/java/com/example/gradox2/AuthSessionIntegrationTest.java +++ b/gradox2/src/test/java/com/example/gradox2/AuthSessionIntegrationTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -42,10 +43,16 @@ import com.example.gradox2.utils.EmailService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import java.security.Key; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.Date; @SpringBootTest @AutoConfigureMockMvc @@ -261,6 +268,64 @@ void resetPasswordShouldRejectUnknownToken() throws Exception { .andExpect(jsonPath("$.errorCode").value("UNAUTHENTICATED_ACCESS")); } + @Test + void accessTokenShouldBeRejectedAfterPasswordResetBumpsTokenVersion() throws Exception { + createEnabledUser("versionreset", "versionreset@rai.usc.es", "SecurePass1!", UserRole.USER); + String accessToken = login("versionreset", "SecurePass1!").get("token").asText(); + + mockMvc.perform(get("/users/me") + .header("Authorization", bearer(accessToken))) + .andExpect(status().isOk()); + + mockMvc.perform(post("/api/auth/password/reset-request") + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of("email", "versionreset@rai.usc.es")))) + .andExpect(status().isOk()); + + PasswordResetToken token = passwordResetTokenRepository.findAll().stream().findFirst().orElseThrow(); + + mockMvc.perform(post("/api/auth/password/reset") + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of("token", token.getToken(), "newPassword", "NewSecurePass1!")))) + .andExpect(status().isOk()); + + mockMvc.perform(get("/users/me") + .header("Authorization", bearer(accessToken))) + .andExpect(status().isForbidden()); + } + + @Test + void accessTokenShouldBeRejectedForDisabledUserEvenAtSameTokenVersion() throws Exception { + createEnabledUser("disableduser", "disabled@rai.usc.es", "SecurePass1!", UserRole.USER); + String accessToken = login("disableduser", "SecurePass1!").get("token").asText(); + + User user = userRepository.findByUsername("disableduser").orElseThrow(); + user.setEnabled(false); + userRepository.save(user); + + mockMvc.perform(get("/users/me") + .header("Authorization", bearer(accessToken))) + .andExpect(status().isForbidden()); + } + + @Test + void legacyAccessTokenWithoutVersionClaimShouldBeRejected() throws Exception { + createEnabledUser("legacyuser", "legacy@rai.usc.es", "SecurePass1!", UserRole.USER); + + Key key = Keys.hmacShaKeyFor("test-jwt-secret-that-is-long-enough-for-hs256".getBytes(StandardCharsets.UTF_8)); + String legacyToken = Jwts.builder() + .setSubject("legacyuser") + .claim("role", UserRole.USER.name()) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 900000)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + mockMvc.perform(get("/users/me") + .header("Authorization", bearer(legacyToken))) + .andExpect(status().isForbidden()); + } + private User createEnabledUser(String username, String email, String password, UserRole role) { User user = User.builder() .username(username) @@ -286,6 +351,10 @@ private JsonNode login(String username, String password) throws Exception { return objectMapper.readTree(result.getResponse().getContentAsString()); } + private String bearer(String token) { + return "Bearer " + token; + } + private String json(Object value) throws Exception { return objectMapper.writeValueAsString(value); }