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
22 changes: 9 additions & 13 deletions database/08-migrations.sql
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
-- 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
-- Reason: restoreWallet() is an ADMIN action and must be auditable (OWASP A09).
-- 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';
ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'WALLET_RESTORE';
17 changes: 12 additions & 5 deletions docs/IMPLEMENTATION-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand All @@ -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

Expand Down
5 changes: 2 additions & 3 deletions src/main/java/com/wallet/secure/auth/entity/Session.java
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -159,4 +158,4 @@ public void revokeNow() {
public boolean isActive() {
return !revoked && Instant.now().isBefore(expiresAt);
}
}
}
40 changes: 18 additions & 22 deletions src/main/java/com/wallet/secure/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -102,22 +102,19 @@ public ApiResponse<AuthResponse> 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.
* <p>
* 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.
* </p>
* OWASP A07: without DB persistence, logout is just client-side
*/
private AuthResponse generateAndSaveTokens(User user, String ipAddress, String userAgent) {
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(
Expand Down Expand Up @@ -241,7 +238,7 @@ public ApiResponse<AuthResponse> login(LoginRequest request, HttpServletRequest
* </p>
* 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)
*
Expand All @@ -263,13 +260,14 @@ public ApiResponse<AuthResponse> 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
Expand All @@ -286,12 +284,12 @@ public ApiResponse<AuthResponse> refresh(RefreshTokenRequest request) {
// --- Logout

/**
* Revokes the refresh token by removing it from DB
* Revokes the refresh-token session by marking its hash as revoked.
* <p>
* 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
* </p>
Expand All @@ -305,10 +303,8 @@ public ApiResponse<Void> 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
Expand Down
12 changes: 1 addition & 11 deletions src/main/java/com/wallet/secure/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -220,4 +210,4 @@ public void resetFailedLoginAttempts() {
this.failedLoginAttempts = 0;
this.lockedUntil = null;
}
}
}
47 changes: 27 additions & 20 deletions src/test/java/com/wallet/secure/auth/service/AuthServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<AuthResponse> response = authService.register(request, httpRequest);
Expand All @@ -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).
Expand Down Expand Up @@ -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<AuthResponse> response = authService.login(request, httpRequest);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -285,15 +284,15 @@ 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);

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(testUser));

String newAccessToken = "new.access.jwt.token";
when(jwtService.generateAccessToken(TEST_EMAIL)).thenReturn(newAccessToken);
Expand Down Expand Up @@ -326,15 +325,16 @@ 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);
when(jwtService.isRefreshTokenValid(REFRESH_TOKEN)).thenReturn(true);
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
Expand All @@ -344,24 +344,25 @@ 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);
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));

// WHEN / THEN
// OWASP A07: detects stale token reuse attempt
assertThatThrownBy(() -> authService.refresh(request))
.isInstanceOf(InvalidCredentialsException.class)
.hasMessageContaining("revoked");
.hasMessageContaining("Invalid refresh token");
}
}

Expand All @@ -372,23 +373,21 @@ 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
ApiResponse<Void> response = authService.logout(TEST_EMAIL, logoutRequest, httpRequest);

// THEN
assertThat(response.isSuccess()).isTrue();
assertThat(testUser.getRefreshToken()).isNull();
verify(sessionService).revokeByToken(REFRESH_TOKEN);
verify(userRepository, never()).save(any(User.class));
}

@Test
Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading