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
3 changes: 2 additions & 1 deletion database/02-types.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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';
COMMENT ON TYPE audit_action IS 'Actions that are recorded in the audit_logs';
5 changes: 5 additions & 0 deletions database/08-migrations.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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';
15 changes: 15 additions & 0 deletions src/main/java/com/wallet/secure/audit/service/AuditService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,10 @@ public ResponseEntity<ApiResponse<AuthResponse>> login(@Valid @RequestBody Login
* On failure → InvalidCredentialsException → GlobalExceptionHandler → 401.
*/
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<AuthResponse>> refresh(@Valid @RequestBody RefreshTokenRequest request) {
public ResponseEntity<ApiResponse<AuthResponse>> refresh(@Valid @RequestBody RefreshTokenRequest request,
HttpServletRequest httpRequest) {

ApiResponse<AuthResponse> response = authService.refresh(request);
ApiResponse<AuthResponse> response = authService.refresh(request, httpRequest);

return ResponseEntity.ok(response);
}
Expand Down Expand Up @@ -146,4 +147,4 @@ public ResponseEntity<ApiResponse<Void>> logout(
logoutRequest, // ← nuevo
httpRequest));
}
}
}
12 changes: 5 additions & 7 deletions src/main/java/com/wallet/secure/auth/dto/AuthResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;

Expand Down Expand Up @@ -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()
Expand All @@ -77,4 +75,4 @@ public static AuthResponse refreshed(String accessToken, long expiresIn) {
.tokenType("Bearer")
.build();
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -30,6 +32,16 @@ public interface SessionRepository extends JpaRepository<Session, UUID> {
*/
Optional<Session> 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<Session> 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"
Expand Down Expand Up @@ -92,4 +104,4 @@ void revokeAllActiveSessionsForUser(
long countActiveSessionsByUserId(
@Param("userId") UUID userId,
@Param("now") Instant now);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -237,4 +241,4 @@ private Claims extractAllClaims(String token) {
.parseSignedClaims(token) // parse + verify
.getPayload(); // return claims
}
}
}
36 changes: 21 additions & 15 deletions src/main/java/com/wallet/secure/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -238,15 +238,15 @@ public ApiResponse<AuthResponse> login(LoginRequest request, HttpServletRequest
* </p>
* 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<AuthResponse> refresh(RefreshTokenRequest request) {
public ApiResponse<AuthResponse> refresh(RefreshTokenRequest request, HttpServletRequest httpRequest) {

String refreshToken = request.getRefreshToken();

Expand All @@ -260,22 +260,28 @@ public ApiResponse<AuthResponse> 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);
Expand Down
25 changes: 24 additions & 1 deletion src/main/java/com/wallet/secure/auth/service/SessionService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -299,4 +322,4 @@ public String hashToken(String token) {
throw new IllegalStateException("SHA-256 algorithm not available", e);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public enum AuditAction {
// User lifecycle
USER_LOGIN,
USER_LOGOUT,
TOKEN_REFRESH,
USER_REGISTER,
USER_UPDATE,
USER_DELETE,
Expand All @@ -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'
}
}
38 changes: 20 additions & 18 deletions src/test/java/com/wallet/secure/auth/service/AuthServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
Expand All @@ -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<AuthResponse> response = authService.refresh(request);
ApiResponse<AuthResponse> 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
Expand All @@ -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");

Expand All @@ -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());
}
}

Expand Down
Loading
Loading