Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
20 changes: 20 additions & 0 deletions gradox2/src/main/java/com/example/gradox2/security/JwtUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion gradox2/src/main/resources/application-local.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion gradox2/src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion gradox2/src/main/resources/application-test.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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);
}
Expand Down
Loading