diff --git a/database/02-types.sql b/database/02-types.sql
index 0cf378f..d01bce2 100644
--- a/database/02-types.sql
+++ b/database/02-types.sql
@@ -27,6 +27,7 @@ COMMENT ON TYPE log_severity IS 'INFO=normal, WARNING=alert, ERROR=failed, CRITI
CREATE TYPE audit_action AS ENUM (
'USER_LOGIN',
'USER_LOGOUT',
+ 'TOKEN_REFRESH',
'USER_REGISTER',
'USER_UPDATE',
'USER_DELETE',
@@ -41,4 +42,4 @@ CREATE TYPE audit_action AS ENUM (
'2FA_ENABLE',
'2FA_DISABLE'
);
-COMMENT ON TYPE audit_action IS 'Actions that are recorded in the audit_logs';
\ No newline at end of file
+COMMENT ON TYPE audit_action IS 'Actions that are recorded in the audit_logs';
diff --git a/database/08-migrations.sql b/database/08-migrations.sql
index 68457ff..633bb10 100644
--- a/database/08-migrations.sql
+++ b/database/08-migrations.sql
@@ -16,3 +16,8 @@ ALTER TABLE users
-- Using ALTER TYPE ... ADD VALUE because PostgreSQL enums are append-only.
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'WALLET_RESTORE';
+
+-- Migration: audit refresh-token rotation and replay attempts
+-- Date: 2026-10-27
+-- OWASP A07/A09: a consumed refresh token presented again is a security signal.
+ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'TOKEN_REFRESH';
diff --git a/src/main/java/com/wallet/secure/audit/service/AuditService.java b/src/main/java/com/wallet/secure/audit/service/AuditService.java
index 7907dd7..2f1181e 100644
--- a/src/main/java/com/wallet/secure/audit/service/AuditService.java
+++ b/src/main/java/com/wallet/secure/audit/service/AuditService.java
@@ -108,6 +108,21 @@ public void logLogout(UUID userId, String email, String ipAddress, String userAg
details, ipAddress, userAgent));
}
+ /** Records both successful refresh rotation and rejected reuse without token data. */
+ @Async
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void logTokenRefresh(UUID userId, String email, boolean success,
+ String ipAddress, String userAgent) {
+ String details = buildDetails(
+ "\"email\":\"" + email + "\"",
+ "\"outcome\":\"" + (success ? "SUCCESS" : "FAILURE") + "\"",
+ "\"reason\":\"" + (success ? "Token rotated" : "Rotated, revoked, or unknown token reused") + "\""
+ );
+ save(success
+ ? AuditLog.success(userId, AuditAction.TOKEN_REFRESH, LogSeverity.INFO, details, ipAddress, userAgent)
+ : AuditLog.failure(userId, AuditAction.TOKEN_REFRESH, LogSeverity.WARNING, details, ipAddress, userAgent));
+ }
+
/**
* Logs a new user registration.
* Called by AuthService.register() after user is created.
diff --git a/src/main/java/com/wallet/secure/auth/controller/AuthController.java b/src/main/java/com/wallet/secure/auth/controller/AuthController.java
index e45020d..fe77033 100644
--- a/src/main/java/com/wallet/secure/auth/controller/AuthController.java
+++ b/src/main/java/com/wallet/secure/auth/controller/AuthController.java
@@ -105,9 +105,10 @@ public ResponseEntity> login(@Valid @RequestBody Login
* On failure → InvalidCredentialsException → GlobalExceptionHandler → 401.
*/
@PostMapping("/refresh")
- public ResponseEntity> refresh(@Valid @RequestBody RefreshTokenRequest request) {
+ public ResponseEntity> refresh(@Valid @RequestBody RefreshTokenRequest request,
+ HttpServletRequest httpRequest) {
- ApiResponse response = authService.refresh(request);
+ ApiResponse response = authService.refresh(request, httpRequest);
return ResponseEntity.ok(response);
}
@@ -146,4 +147,4 @@ public ResponseEntity> logout(
logoutRequest, // ← nuevo
httpRequest));
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/wallet/secure/auth/dto/AuthResponse.java b/src/main/java/com/wallet/secure/auth/dto/AuthResponse.java
index 013a9b0..74e0b90 100644
--- a/src/main/java/com/wallet/secure/auth/dto/AuthResponse.java
+++ b/src/main/java/com/wallet/secure/auth/dto/AuthResponse.java
@@ -10,7 +10,7 @@
* Used by:
* - POST /auth/login → returns both tokens
* - POST /auth/register → returns both tokens (user is logged in immediately)
- * - POST /auth/refresh → returns new accessToken (refreshToken unchanged)
+ * - POST /auth/refresh → returns new accessToken and rotated refreshToken
*
* WHY both tokens together:
* The client receives accessToken for API calls (15 min)
@@ -35,9 +35,8 @@ public class AuthResponse {
* Long-lived token to obtain new access tokens silently.
* Sent only to POST /auth/refresh.
* Expires in 7 days (JWT_REFRESH_EXPIRATION=604800000).
- * Stored in DB — invalidated on logout (OWASP A07).
- *
- * NULL on refresh response — client keeps the existing refreshToken.
+ * Stored only as a SHA-256 hash in DB — invalidated on logout (OWASP A07).
+ * Every successful refresh returns a replacement and invalidates the prior one.
*/
private final String refreshToken;
@@ -67,8 +66,7 @@ public static AuthResponse of(String accessToken, String refreshToken, long expi
}
/**
- * Factory method — refresh response (only new accessToken).
- * refreshToken stays NULL — @JsonInclude NON_NULL hides it from response.
+ * Factory method retained for callers that intentionally return only access data.
*/
public static AuthResponse refreshed(String accessToken, long expiresIn) {
return AuthResponse.builder()
@@ -77,4 +75,4 @@ public static AuthResponse refreshed(String accessToken, long expiresIn) {
.tokenType("Bearer")
.build();
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/wallet/secure/auth/repository/SessionRepository.java b/src/main/java/com/wallet/secure/auth/repository/SessionRepository.java
index 7d7c1c6..338e12f 100644
--- a/src/main/java/com/wallet/secure/auth/repository/SessionRepository.java
+++ b/src/main/java/com/wallet/secure/auth/repository/SessionRepository.java
@@ -1,7 +1,9 @@
package com.wallet.secure.auth.repository;
import com.wallet.secure.auth.entity.Session;
+import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -30,6 +32,16 @@ public interface SessionRepository extends JpaRepository {
*/
Optional findByTokenHash(String tokenHash);
+ /**
+ * Looks up one refresh-token session while holding a row lock.
+ * A rotation must validate and revoke the old token in one critical section:
+ * otherwise two simultaneous refresh requests could both succeed.
+ * OWASP A07: one-time refresh-token use.
+ */
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("SELECT s FROM Session s WHERE s.tokenHash = :tokenHash")
+ Optional findByTokenHashWithLock(@Param("tokenHash") String tokenHash);
+
/**
* Returns all active (non-revoked, non-expired) sessions for a user.
* Used by: GET /sessions → "show me all devices where I'm logged in"
@@ -92,4 +104,4 @@ void revokeAllActiveSessionsForUser(
long countActiveSessionsByUserId(
@Param("userId") UUID userId,
@Param("now") Instant now);
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/wallet/secure/auth/security/JwtService.java b/src/main/java/com/wallet/secure/auth/security/JwtService.java
index 134c96b..318b3aa 100644
--- a/src/main/java/com/wallet/secure/auth/security/JwtService.java
+++ b/src/main/java/com/wallet/secure/auth/security/JwtService.java
@@ -13,6 +13,7 @@
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
+import java.util.UUID;
/**
* Handles all JWT operations: generate, validate, extract claims.
@@ -121,6 +122,9 @@ private String buildToken(String email, long lifetimeMs, String tokenType) {
.issuedAt(Date.from(now)) // when it was created
.expiration(Date.from(expiry)) // when it expires
.claim("type", tokenType) // "access" or "refresh"
+ // OWASP A07: iat has second precision in JWT. A random ID prevents
+ // two refresh tokens for the same user in that second being identical.
+ .id(UUID.randomUUID().toString())
.signWith(signingKey) // HMAC-SHA256 signature
.compact(); // serialize to string
}
@@ -237,4 +241,4 @@ private Claims extractAllClaims(String token) {
.parseSignedClaims(token) // parse + verify
.getPayload(); // return claims
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/wallet/secure/auth/service/AuthService.java b/src/main/java/com/wallet/secure/auth/service/AuthService.java
index a420c98..4dc2f19 100644
--- a/src/main/java/com/wallet/secure/auth/service/AuthService.java
+++ b/src/main/java/com/wallet/secure/auth/service/AuthService.java
@@ -238,15 +238,15 @@ public ApiResponse login(LoginRequest request, HttpServletRequest
*
* Flow:
* 1. Validate JWT signature and expiration of refresh token
- * 2. Check the refresh-token hash exists in user_sessions (not revoked by logout)
- * 3. Generate new access token
- * 4. Return new access token (refresh token stays the same)
+ * 2. Lock and consume the matching active session
+ * 3. Generate and store a replacement refresh token in the same transaction
+ * 4. Return both replacements; the old token is now invalid
*
* @param request DTO with refreshToken
* @return ApiResponse with new access token (refresh token not rotated here)
*/
@Transactional
- public ApiResponse refresh(RefreshTokenRequest request) {
+ public ApiResponse refresh(RefreshTokenRequest request, HttpServletRequest httpRequest) {
String refreshToken = request.getRefreshToken();
@@ -260,22 +260,28 @@ public ApiResponse refresh(RefreshTokenRequest request) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new InvalidCredentialsException("Invalid refresh token"));
- // Step 3 - user_sessions is the only revocation store. Its hash must
- // belong to the same user named in the signed refresh JWT.
- // OWASP A02/A07: never store or compare a raw reusable refresh token.
- var session = sessionService.validateSession(refreshToken);
- if (!session.getUser().getId().equals(user.getId())) {
- log.warn("Refresh token session ownership mismatch for userId={}", user.getId());
- throw new InvalidCredentialsException("Invalid refresh token");
+ // Step 3 - generate a distinct replacement. rotateSession locks the old
+ // row, revokes it, and saves the replacement atomically (OWASP A07).
+ String newRefreshToken = jwtService.generateRefreshToken(email);
+ try {
+ sessionService.rotateSession(user, refreshToken, newRefreshToken,
+ extractIp(httpRequest), extractUserAgent(httpRequest));
+ } catch (InvalidCredentialsException exception) {
+ // OWASP A09: a valid-but-consumed refresh JWT is evidence of replay.
+ // Never put either raw token or its hash into logs/audit details.
+ auditService.logTokenRefresh(user.getId(), user.getEmail(), false,
+ extractIp(httpRequest), extractUserAgent(httpRequest));
+ throw exception;
}
- // Step 4 - generate only a new access token
- // Refresh token stays the same - client already has it
+ // Step 4 - issue the access-token companion only after rotation succeeds.
String newAccessToken = jwtService.generateAccessToken(email);
- AuthResponse response = AuthResponse.refreshed(
- newAccessToken,
+ AuthResponse response = AuthResponse.of(
+ newAccessToken, newRefreshToken,
jwtService.getExpirationInSeconds()
);
+ auditService.logTokenRefresh(user.getId(), user.getEmail(), true,
+ extractIp(httpRequest), extractUserAgent(httpRequest));
log.info("Token refreshed for: userI={}", user.getId());
return ApiResponse.ok("Token refreshed", response);
diff --git a/src/main/java/com/wallet/secure/auth/service/SessionService.java b/src/main/java/com/wallet/secure/auth/service/SessionService.java
index ad60e5d..c4e1c81 100644
--- a/src/main/java/com/wallet/secure/auth/service/SessionService.java
+++ b/src/main/java/com/wallet/secure/auth/service/SessionService.java
@@ -138,6 +138,29 @@ public Session validateSession(String refreshToken) {
return session;
}
+ /**
+ * Atomically consumes an active refresh-token session and creates its replacement.
+ * The pessimistic row lock makes the old token single-use even under concurrent
+ * requests: the second request waits, then observes the revoked row and fails.
+ * OWASP A07: refresh-token rotation limits replay after token theft.
+ */
+ @Transactional
+ public Session rotateSession(User expectedUser, String currentRefreshToken,
+ String replacementRefreshToken, String ipAddress, String userAgent) {
+ Session current = sessionRepository.findByTokenHashWithLock(hashToken(currentRefreshToken))
+ .orElseThrow(() -> new InvalidCredentialsException("Session not found or already revoked"));
+
+ if (!current.getUser().getId().equals(expectedUser.getId()) || !current.isActive()) {
+ throw new InvalidCredentialsException("Session has been revoked or expired");
+ }
+
+ current.revokeNow();
+ sessionRepository.save(current);
+
+ // The replacement is saved in the same transaction as the revocation.
+ return createSession(expectedUser, replacementRefreshToken, ipAddress, userAgent);
+ }
+
// ─── Session Revocation
/**
@@ -299,4 +322,4 @@ public String hashToken(String token) {
throw new IllegalStateException("SHA-256 algorithm not available", e);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/wallet/secure/common/enums/AuditAction.java b/src/main/java/com/wallet/secure/common/enums/AuditAction.java
index 03be828..dbef455 100644
--- a/src/main/java/com/wallet/secure/common/enums/AuditAction.java
+++ b/src/main/java/com/wallet/secure/common/enums/AuditAction.java
@@ -17,6 +17,7 @@ public enum AuditAction {
// User lifecycle
USER_LOGIN,
USER_LOGOUT,
+ TOKEN_REFRESH,
USER_REGISTER,
USER_UPDATE,
USER_DELETE,
@@ -39,4 +40,4 @@ public enum AuditAction {
// Security — 2FA management
TWO_FA_ENABLE, // Maps to DB: '2FA_ENABLE'
TWO_FA_DISABLE // Maps to DB: '2FA_DISABLE'
-}
\ No newline at end of file
+}
diff --git a/src/test/java/com/wallet/secure/auth/service/AuthServiceTest.java b/src/test/java/com/wallet/secure/auth/service/AuthServiceTest.java
index c1f3dea..4a67ef9 100644
--- a/src/test/java/com/wallet/secure/auth/service/AuthServiceTest.java
+++ b/src/test/java/com/wallet/secure/auth/service/AuthServiceTest.java
@@ -114,6 +114,8 @@ void setUp() {
.logRegister(any(), anyString(), anyString(), anyString());
lenient().doNothing().when(auditService)
.logLogout(any(), anyString(), anyString(), anyString());
+ lenient().doNothing().when(auditService)
+ .logTokenRefresh(any(), anyString(), anyBoolean(), anyString(), anyString());
lenient().when(auditService.countRecentFailedLogins(any(), anyInt())).thenReturn(0L);
}
@@ -281,8 +283,8 @@ void login_wrongPassword_throwsBadCredentials() {
class RefreshTests {
@Test
- @DisplayName("returns new access token with valid refresh token")
- void refresh_validToken_returnsNewAccessToken() {
+ @DisplayName("rotates refresh token and returns both replacements")
+ void refresh_validToken_returnsRotatedTokens() {
// GIVEN
RefreshTokenRequest request = mock(RefreshTokenRequest.class);
when(request.getRefreshToken()).thenReturn(REFRESH_TOKEN);
@@ -291,18 +293,20 @@ void refresh_validToken_returnsNewAccessToken() {
when(jwtService.extractEmail(REFRESH_TOKEN)).thenReturn(TEST_EMAIL);
when(userRepository.findByEmail(TEST_EMAIL))
.thenReturn(Optional.of(testUser));
- when(sessionService.validateSession(REFRESH_TOKEN))
- .thenReturn(activeSessionFor(testUser));
-
String newAccessToken = "new.access.jwt.token";
+ String newRefreshToken = "new.refresh.jwt.token";
when(jwtService.generateAccessToken(TEST_EMAIL)).thenReturn(newAccessToken);
+ when(jwtService.generateRefreshToken(TEST_EMAIL)).thenReturn(newRefreshToken);
// WHEN
- ApiResponse response = authService.refresh(request);
+ ApiResponse response = authService.refresh(request, httpRequest);
// THEN
assertThat(response.isSuccess()).isTrue();
assertThat(response.getData().getAccessToken()).isEqualTo(newAccessToken);
+ assertThat(response.getData().getRefreshToken()).isEqualTo(newRefreshToken);
+ verify(sessionService).rotateSession(eq(testUser), eq(REFRESH_TOKEN), eq(newRefreshToken), anyString(), anyString());
+ verify(auditService).logTokenRefresh(eq(testUser.getId()), eq(TEST_EMAIL), eq(true), anyString(), anyString());
}
@Test
@@ -315,7 +319,7 @@ void refresh_invalidSignature_throwsException() {
// WHEN / THEN
// Validation fails at JWT signature level — DB is never queried
- assertThatThrownBy(() -> authService.refresh(request))
+ assertThatThrownBy(() -> authService.refresh(request, httpRequest))
.isInstanceOf(InvalidCredentialsException.class)
.hasMessageContaining("Invalid or expired refresh token");
@@ -334,35 +338,33 @@ void refresh_revokedToken_throwsException() {
when(userRepository.findByEmail(TEST_EMAIL))
.thenReturn(Optional.of(testUser));
doThrow(new InvalidCredentialsException("Session has been revoked or expired"))
- .when(sessionService).validateSession(REFRESH_TOKEN);
+ .when(sessionService).rotateSession(eq(testUser), eq(REFRESH_TOKEN), anyString(), anyString(), anyString());
// WHEN / THEN
// OWASP A07: token revoked after logout — cannot be reused
- assertThatThrownBy(() -> authService.refresh(request))
+ assertThatThrownBy(() -> authService.refresh(request, httpRequest))
.isInstanceOf(InvalidCredentialsException.class)
.hasMessageContaining("revoked");
}
@Test
- @DisplayName("throws exception when token session belongs to another user — OWASP A01")
- void refresh_tokenMismatch_throwsException() {
- // GIVEN — a valid token hash must still belong to the JWT subject.
- User anotherUser = User.builder().id(UUID.randomUUID()).email("other@test.com").build();
-
+ @DisplayName("audits a rejected rotated token reuse — OWASP A07/A09")
+ void refresh_rotatedTokenReuse_auditsFailure() {
RefreshTokenRequest request = mock(RefreshTokenRequest.class);
when(request.getRefreshToken()).thenReturn(REFRESH_TOKEN);
when(jwtService.isRefreshTokenValid(REFRESH_TOKEN)).thenReturn(true);
when(jwtService.extractEmail(REFRESH_TOKEN)).thenReturn(TEST_EMAIL);
when(userRepository.findByEmail(TEST_EMAIL))
.thenReturn(Optional.of(testUser));
- when(sessionService.validateSession(REFRESH_TOKEN))
- .thenReturn(activeSessionFor(anotherUser));
+ doThrow(new InvalidCredentialsException("Session has been revoked or expired"))
+ .when(sessionService).rotateSession(eq(testUser), eq(REFRESH_TOKEN), anyString(), anyString(), anyString());
// WHEN / THEN
// OWASP A07: detects stale token reuse attempt
- assertThatThrownBy(() -> authService.refresh(request))
+ assertThatThrownBy(() -> authService.refresh(request, httpRequest))
.isInstanceOf(InvalidCredentialsException.class)
- .hasMessageContaining("Invalid refresh token");
+ .hasMessageContaining("revoked");
+ verify(auditService).logTokenRefresh(eq(testUser.getId()), eq(TEST_EMAIL), eq(false), anyString(), anyString());
}
}
diff --git a/src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java b/src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java
index 55a87cc..447bfd0 100644
--- a/src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java
+++ b/src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java
@@ -237,6 +237,48 @@ void validateSession_expiredSession_throwsException() {
}
}
+ // ─── rotateSession()
+
+ @Nested
+ @DisplayName("rotateSession()")
+ class RotateSessionTests {
+
+ @Test
+ @DisplayName("consumes old session and saves a hashed replacement — OWASP A07")
+ void rotateSession_activeSession_revokesAndCreatesReplacement() {
+ // GIVEN
+ Session current = buildActiveSession(RAW_TOKEN);
+ when(sessionRepository.findByTokenHashWithLock(sessionService.hashToken(RAW_TOKEN)))
+ .thenReturn(Optional.of(current));
+ when(sessionRepository.save(any(Session.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ when(jwtService.getRefreshExpirationMs()).thenReturn(86_400_000L);
+
+ // WHEN
+ sessionService.rotateSession(testUser, RAW_TOKEN, OTHER_TOKEN, TEST_IP, TEST_UA);
+
+ // THEN
+ assertThat(current.isRevoked()).isTrue();
+ verify(sessionRepository).save(current);
+ verify(sessionRepository).save(argThat(session ->
+ session.getTokenHash().equals(sessionService.hashToken(OTHER_TOKEN))));
+ }
+
+ @Test
+ @DisplayName("rejects consumed session while holding its lock — OWASP A07")
+ void rotateSession_revokedSession_throwsException() {
+ // GIVEN
+ Session consumed = buildRevokedSession(RAW_TOKEN);
+ when(sessionRepository.findByTokenHashWithLock(sessionService.hashToken(RAW_TOKEN)))
+ .thenReturn(Optional.of(consumed));
+
+ // WHEN / THEN
+ assertThatThrownBy(() -> sessionService.rotateSession(testUser, RAW_TOKEN, OTHER_TOKEN, TEST_IP, TEST_UA))
+ .isInstanceOf(InvalidCredentialsException.class)
+ .hasMessageContaining("revoked or expired");
+ verify(sessionRepository, never()).save(any());
+ }
+ }
+
// ─── revokeByToken()
@Nested