From f97a67c7d81770d0bd35316514b4513f8ad081b0 Mon Sep 17 00:00:00 2001 From: DJAngel973 <189556164+DJAngel973@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:29:18 -0500 Subject: [PATCH 1/2] fix(auth): store refresh tokens only as session hashes --- database/08-migrations.sql | 22 ++++----- docs/IMPLEMENTATION-PLAN.md | 17 +++++-- .../wallet/secure/auth/entity/Session.java | 5 +- .../secure/auth/service/AuthService.java | 40 +++++++--------- .../com/wallet/secure/user/entity/User.java | 12 +---- .../secure/auth/service/AuthServiceTest.java | 47 +++++++++++-------- .../auth/service/SessionServiceTest.java | 16 +++++++ 7 files changed, 85 insertions(+), 74 deletions(-) diff --git a/database/08-migrations.sql b/database/08-migrations.sql index 1ba226d..68457ff 100644 --- a/database/08-migrations.sql +++ b/database/08-migrations.sql @@ -1,17 +1,13 @@ --- Migration: add refresh_token column to users table --- Date: 2026-03-28 --- Reason: Support JWT refresh token invalidation on logout (OWASP A07) +-- Migration: remove raw refresh_token from users +-- Date: 2026-10-25 +-- Reason: user_sessions.token_hash is the single source of truth. A raw JWT +-- must never be stored because a database disclosure could reuse it. +-- OWASP A02/A07: revoke sessions by SHA-256 hash, not by a reusable token. -ALTER TABLE users - ADD COLUMN IF NOT EXISTS refresh_token VARCHAR(512); - --- Index for fast lookup on refresh token validation -CREATE INDEX IF NOT EXISTS idx_users_refresh_token - ON users(refresh_token) - WHERE refresh_token IS NOT NULL; +DROP INDEX IF EXISTS idx_users_refresh_token; -COMMENT ON COLUMN users.refresh_token IS - 'Active refresh token. NULL = logged out. Invalidated on logout.'; +ALTER TABLE users + DROP COLUMN IF EXISTS refresh_token; -- Migration: add WALLET_RESTORE to audit_action enum -- Date: 2026-05-02 @@ -19,4 +15,4 @@ COMMENT ON COLUMN users.refresh_token IS -- All other wallet admin ops (suspend, close) already have their own action. -- Using ALTER TYPE ... ADD VALUE because PostgreSQL enums are append-only. -ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'WALLET_RESTORE'; \ No newline at end of file +ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'WALLET_RESTORE'; diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index a7aaacc..5bb4122 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -123,7 +123,7 @@ The former public seed is no longer used and a controlled bootstrap now exists. **Current problem** -`SessionService` stores a SHA-256 token hash in `user_sessions`, but `AuthService` also assigns the original refresh JWT to `User.refreshToken`. This contradicts the requirement that reusable tokens are not stored directly. +`SessionService` stores a SHA-256 token hash in `user_sessions`, but `AuthService` also assigned the original refresh JWT to `User.refreshToken`. This duplicate raw-token store was removed; `user_sessions` is now the only refresh-session source of truth. **Expected behavior** @@ -143,10 +143,17 @@ The former public seed is no longer used and a controlled bootstrap now exists. **Acceptance criteria** -- [ ] No database column stores the original refresh JWT. -- [ ] Login, refresh, logout, single-session revocation, and all-session revocation still work. -- [ ] Tests prove that a stored session value cannot be sent as a refresh token. -- [ ] Schema migration preserves or explicitly invalidates existing sessions safely. +- [x] No database column stores the original refresh JWT. +- [x] Login, refresh, logout, single-session revocation, and all-session revocation still work. +- [x] Tests prove that a stored session value cannot be sent as a refresh token. +- [x] Schema migration preserves existing sessions safely. + +**Verification note — 2026-10-25** + +- The migration removed `users.refresh_token` and its index while preserving the three existing `user_sessions` rows. +- A real local flow returned `201` on registration, `200` on refresh, `200` on logout and `401` when the revoked refresh token was reused. +- The resulting `user_sessions.token_hash` value had 64 characters (SHA-256 hex), and the raw-token column was absent. +- `./scripts/test.sh` passed: 112 tests, 0 failures and 0 errors. ### SEC-004 — Implement and verify refresh-token rotation diff --git a/src/main/java/com/wallet/secure/auth/entity/Session.java b/src/main/java/com/wallet/secure/auth/entity/Session.java index 1eaef65..4eaf580 100644 --- a/src/main/java/com/wallet/secure/auth/entity/Session.java +++ b/src/main/java/com/wallet/secure/auth/entity/Session.java @@ -32,8 +32,7 @@ * A SHA-256 hash of a JWT cannot be reversed to obtain the token. * OWASP A02: sensitive tokens never stored in plain text in DB. * - * WHY this table exists alongside refreshToken in User entity: - * User.refreshToken → simple single-device session (current behavior) + * This table is the single source of truth for refresh-token sessions: * user_sessions → multi-device session management * → "Show me all devices where I'm logged in" * → "Log out from my phone but keep my laptop session" @@ -159,4 +158,4 @@ public void revokeNow() { public boolean isActive() { return !revoked && Instant.now().isBefore(expiresAt); } -} \ 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 eaa7305..a420c98 100644 --- a/src/main/java/com/wallet/secure/auth/service/AuthService.java +++ b/src/main/java/com/wallet/secure/auth/service/AuthService.java @@ -64,7 +64,7 @@ public class AuthService { * Flow: * 1. Delegate account creation to UserService (validates email, hashes password) * 2. Generate access + refresh tokens - * 3. Save refresh token in DB + * 3. Store only the SHA-256 refresh-token hash in user_sessions * 4. Return AuthResponse * * @param request DTO with email and password (validated by @Valid at controller) @@ -102,11 +102,11 @@ public ApiResponse register(RegisterRequest request, HttpServletRe // --- Private Helpers /** - * Generates both tokens and persists the refresh token in DB. + * Generates both tokens and stores only the refresh-token hash in user_sessions. * Single method for register and login - avoids duplication. *

- * Why persist the refresh token: - * Enables real logout - setRefreshToken(null) = session revoked. + * Why persist a session hash: + * Enables real logout - the matching user_sessions row is revoked. *

* OWASP A07: without DB persistence, logout is just client-side */ @@ -114,10 +114,7 @@ private AuthResponse generateAndSaveTokens(User user, String ipAddress, String u String accessToken = jwtService.generateAccessToken(user.getEmail()); String refreshToken = jwtService.generateRefreshToken(user.getEmail()); - // Persist refresh token - enable revocation on logout - user.setRefreshToken(refreshToken); - userRepository.save(user); - + // OWASP A02: SessionService persists SHA-256(refreshToken), never the raw JWT. sessionService.createSession(user, refreshToken, ipAddress, userAgent); return AuthResponse.of( @@ -241,7 +238,7 @@ public ApiResponse login(LoginRequest request, HttpServletRequest *

* Flow: * 1. Validate JWT signature and expiration of refresh token - * 2. Check refresh token exists in DB (not revoked by logout) + * 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) * @@ -263,13 +260,14 @@ public ApiResponse refresh(RefreshTokenRequest request) { User user = userRepository.findByEmail(email) .orElseThrow(() -> new InvalidCredentialsException("Invalid refresh token")); - // Step 3 - verify token matches DB (not revoked) - // OWASP A07: token revocation - logout deletes the DB token - if (!refreshToken.equals(user.getRefreshToken())) { - log.warn("Refresh token mismatch for userId= {} - possible token reuse after logout", user.getId()); - throw new InvalidCredentialsException("Refresh token has been revoked"); + // 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"); } - sessionService.validateSession(refreshToken); // Step 4 - generate only a new access token // Refresh token stays the same - client already has it @@ -286,12 +284,12 @@ public ApiResponse refresh(RefreshTokenRequest request) { // --- Logout /** - * Revokes the refresh token by removing it from DB + * Revokes the refresh-token session by marking its hash as revoked. *

- * Why logout only removes the refresh token (not access token): + * Why logout revokes only the refresh-token session (not the access token): * Access tokens are stateless - they cannot be "revoked" without a blacklist. * But they expire in 15 minutes - acceptable window. - * Removing the refresh token ensures: + * Revoking the refresh-token session ensures: * -> The user cannot silently obtain a new access token * -> After 15 min the access token expires and the session truly ends *

@@ -305,10 +303,8 @@ public ApiResponse logout(String email, LogoutRequest logoutRequest, HttpS User user = userRepository.findByEmail(email) .orElseThrow(() -> new InvalidCredentialsException("User not found")); - // Remove refresh token from DB - future refresh calls will fail - user.setRefreshToken(null); - userRepository.save(user); - + // SessionService hashes the presented token, finds its session and revokes it. + // No raw refresh JWT is persisted in users. sessionService.revokeByToken(logoutRequest.getRefreshToken()); //OWASP A09: log every logout - session termination must be auditable diff --git a/src/main/java/com/wallet/secure/user/entity/User.java b/src/main/java/com/wallet/secure/user/entity/User.java index 7d7dafa..3f9aacc 100644 --- a/src/main/java/com/wallet/secure/user/entity/User.java +++ b/src/main/java/com/wallet/secure/user/entity/User.java @@ -68,16 +68,6 @@ public class User { @Column(name = "password_hash", nullable = false) private String passwordHash; - /** - * Active refresh token for this user. - * NULL = user is logged out or has never logged in. - * Set on login, cleared on logout. - * OWASP A07: token revocation — logout truly invalidates the session. - * OWASP A02: stored as BCrypt hash, never the raw token. - */ - @Column(name = "refresh_token", length = 512) - private String refreshToken; - /** * Role-based access control. * USER = standard client, ADMIN = superadmin, MANAGER = support. @@ -220,4 +210,4 @@ public void resetFailedLoginAttempts() { this.failedLoginAttempts = 0; this.lockedUntil = null; } -} \ 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 ec06b36..c1f3dea 100644 --- a/src/test/java/com/wallet/secure/auth/service/AuthServiceTest.java +++ b/src/test/java/com/wallet/secure/auth/service/AuthServiceTest.java @@ -5,6 +5,7 @@ import com.wallet.secure.auth.dto.LoginRequest; import com.wallet.secure.auth.dto.LogoutRequest; import com.wallet.secure.auth.dto.RefreshTokenRequest; +import com.wallet.secure.auth.entity.Session; import com.wallet.secure.auth.security.JwtService; import com.wallet.secure.common.exception.InvalidCredentialsException; import com.wallet.secure.common.response.ApiResponse; @@ -29,6 +30,7 @@ import java.util.Optional; import java.util.UUID; +import java.time.Instant; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -134,7 +136,6 @@ void register_validRequest_returnsTokens() { // After registration, AuthService loads the user by email to generate tokens when(userRepository.findByEmail(TEST_EMAIL)) .thenReturn(Optional.of(testUser)); - when(userRepository.save(any(User.class))).thenReturn(testUser); // WHEN ApiResponse response = authService.register(request, httpRequest); @@ -144,8 +145,9 @@ void register_validRequest_returnsTokens() { assertThat(response.getData().getAccessToken()).isEqualTo(ACCESS_TOKEN); assertThat(response.getData().getRefreshToken()).isEqualTo(REFRESH_TOKEN); - // Refresh token was persisted in DB — enables future revocation on logout - verify(userRepository, times(1)).save(any(User.class)); + // The raw token is not stored on User; SessionService stores only its SHA-256 hash. + verify(userRepository, never()).save(any(User.class)); + verify(sessionService).createSession(eq(testUser), eq(REFRESH_TOKEN), anyString(), anyString()); // Registration audit references users.id: the user must be flushed // before AuditService joins the same transaction (OWASP A09). @@ -192,7 +194,6 @@ void login_validCredentials_returnsTokens() { when(userRepository.findByEmail(TEST_EMAIL)) .thenReturn(Optional.of(testUser)); - when(userRepository.save(any(User.class))).thenReturn(testUser); // WHEN ApiResponse response = authService.login(request, httpRequest); @@ -217,7 +218,6 @@ void login_success_resetsFailedAttempts() { .thenReturn(mock(Authentication.class)); when(userRepository.findByEmail(TEST_EMAIL)) .thenReturn(Optional.of(testUser)); - when(userRepository.save(any(User.class))).thenReturn(testUser); // WHEN authService.login(request, httpRequest); @@ -241,7 +241,6 @@ void login_success_updatesLastLoginAt() { .thenReturn(mock(Authentication.class)); when(userRepository.findByEmail(TEST_EMAIL)) .thenReturn(Optional.of(testUser)); - when(userRepository.save(any(User.class))).thenReturn(testUser); // WHEN authService.login(request, httpRequest); @@ -285,8 +284,6 @@ class RefreshTests { @DisplayName("returns new access token with valid refresh token") void refresh_validToken_returnsNewAccessToken() { // GIVEN - testUser.setRefreshToken(REFRESH_TOKEN); - RefreshTokenRequest request = mock(RefreshTokenRequest.class); when(request.getRefreshToken()).thenReturn(REFRESH_TOKEN); @@ -294,6 +291,8 @@ 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"; when(jwtService.generateAccessToken(TEST_EMAIL)).thenReturn(newAccessToken); @@ -326,8 +325,7 @@ void refresh_invalidSignature_throwsException() { @Test @DisplayName("throws exception when refresh token was revoked after logout — OWASP A07") void refresh_revokedToken_throwsException() { - // GIVEN — user logged out → refreshToken in DB is null - testUser.setRefreshToken(null); + // GIVEN — user logged out → the session hash is marked revoked RefreshTokenRequest request = mock(RefreshTokenRequest.class); when(request.getRefreshToken()).thenReturn(REFRESH_TOKEN); @@ -335,6 +333,8 @@ void refresh_revokedToken_throwsException() { when(jwtService.extractEmail(REFRESH_TOKEN)).thenReturn(TEST_EMAIL); when(userRepository.findByEmail(TEST_EMAIL)) .thenReturn(Optional.of(testUser)); + doThrow(new InvalidCredentialsException("Session has been revoked or expired")) + .when(sessionService).validateSession(REFRESH_TOKEN); // WHEN / THEN // OWASP A07: token revoked after logout — cannot be reused @@ -344,11 +344,10 @@ void refresh_revokedToken_throwsException() { } @Test - @DisplayName("throws exception when token does not match DB — token reuse attack") + @DisplayName("throws exception when token session belongs to another user — OWASP A01") void refresh_tokenMismatch_throwsException() { - // GIVEN — attacker uses an old refresh token - // the user already has a new one in DB (token rotation scenario) - testUser.setRefreshToken("different.token.in.db"); + // GIVEN — a valid token hash must still belong to the JWT subject. + User anotherUser = User.builder().id(UUID.randomUUID()).email("other@test.com").build(); RefreshTokenRequest request = mock(RefreshTokenRequest.class); when(request.getRefreshToken()).thenReturn(REFRESH_TOKEN); @@ -356,12 +355,14 @@ void refresh_tokenMismatch_throwsException() { 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)); // WHEN / THEN // OWASP A07: detects stale token reuse attempt assertThatThrownBy(() -> authService.refresh(request)) .isInstanceOf(InvalidCredentialsException.class) - .hasMessageContaining("revoked"); + .hasMessageContaining("Invalid refresh token"); } } @@ -372,14 +373,12 @@ void refresh_tokenMismatch_throwsException() { class LogoutTests { @Test - @DisplayName("clears refresh token from DB on logout — OWASP A07") - void logout_authenticatedUser_clearsRefreshToken() { + @DisplayName("revokes hashed refresh-token session on logout — OWASP A07") + void logout_authenticatedUser_revokesSession() { // GIVEN LogoutRequest logoutRequest = mock(LogoutRequest.class); when(logoutRequest.getRefreshToken()).thenReturn(REFRESH_TOKEN); - testUser.setRefreshToken(REFRESH_TOKEN); when(userRepository.findByEmail(TEST_EMAIL)).thenReturn(Optional.of(testUser)); - when(userRepository.save(any(User.class))).thenReturn(testUser); doNothing().when(sessionService).revokeByToken(anyString()); // WHEN @@ -387,8 +386,8 @@ void logout_authenticatedUser_clearsRefreshToken() { // THEN assertThat(response.isSuccess()).isTrue(); - assertThat(testUser.getRefreshToken()).isNull(); verify(sessionService).revokeByToken(REFRESH_TOKEN); + verify(userRepository, never()).save(any(User.class)); } @Test @@ -405,4 +404,12 @@ void logout_userNotFound_throwsException() { verify(userRepository, never()).save(any()); } } + + private Session activeSessionFor(User user) { + return Session.builder() + .user(user) + .tokenHash("a".repeat(64)) + .expiresAt(Instant.now().plusSeconds(60)) + .build(); + } } 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 26c137c..55a87cc 100644 --- a/src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java +++ b/src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java @@ -191,6 +191,22 @@ void validateSession_sessionNotFound_throwsException() { .hasMessageContaining("revoked"); } + @Test + @DisplayName("rejects a stored token hash when presented as a refresh token — OWASP A02") + void validateSession_storedHashCannotBeUsedAsRefreshToken() { + // GIVEN — an attacker obtained the database value, not the raw JWT. + String storedHash = sessionService.hashToken(RAW_TOKEN); + String lookupHash = sessionService.hashToken(storedHash); + when(sessionRepository.findByTokenHash(lookupHash)).thenReturn(Optional.empty()); + + // WHEN / THEN — validateSession hashes the supplied value again. + assertThatThrownBy(() -> sessionService.validateSession(storedHash)) + .isInstanceOf(InvalidCredentialsException.class) + .hasMessageContaining("revoked"); + + verify(sessionRepository).findByTokenHash(lookupHash); + } + @Test @DisplayName("throws when session is revoked — user already logged out") void validateSession_revokedSession_throwsException() { From 32d256bc7cefe6dfaa22e5b369fd69b30e9b81a3 Mon Sep 17 00:00:00 2001 From: DJAngel973 <189556164+DJAngel973@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:04:49 -0500 Subject: [PATCH 2/2] feat(auth): rotate refresh tokens atomically --- database/02-types.sql | 3 +- database/08-migrations.sql | 5 +++ .../secure/audit/service/AuditService.java | 15 +++++++ .../auth/controller/AuthController.java | 7 ++-- .../wallet/secure/auth/dto/AuthResponse.java | 12 +++--- .../auth/repository/SessionRepository.java | 14 ++++++- .../secure/auth/security/JwtService.java | 6 ++- .../secure/auth/service/AuthService.java | 36 +++++++++------- .../secure/auth/service/SessionService.java | 25 ++++++++++- .../secure/common/enums/AuditAction.java | 3 +- .../secure/auth/service/AuthServiceTest.java | 38 +++++++++-------- .../auth/service/SessionServiceTest.java | 42 +++++++++++++++++++ 12 files changed, 158 insertions(+), 48 deletions(-) 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