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