diff --git a/src/main/java/com/empress/usermanagementapi/entity/BaseTokenEntity.java b/src/main/java/com/empress/usermanagementapi/entity/BaseTokenEntity.java index 3bbd9f0..606597a 100644 --- a/src/main/java/com/empress/usermanagementapi/entity/BaseTokenEntity.java +++ b/src/main/java/com/empress/usermanagementapi/entity/BaseTokenEntity.java @@ -10,8 +10,10 @@ public abstract class BaseTokenEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false, unique = true) - private String token; + // The Java property is the SHA-256 hash of the issued token; the column keeps + // its historical "token" name so the database schema stays compatible. + @Column(name = "token", nullable = false, unique = true) + private String tokenHash; @OneToOne(fetch = FetchType.EAGER) @JoinColumn(nullable = false, name = "user_id") @@ -27,12 +29,12 @@ public Long getId() { return id; } - public String getToken() { - return token; + public String getTokenHash() { + return tokenHash; } - public void setToken(String token) { - this.token = token; + public void setTokenHash(String tokenHash) { + this.tokenHash = tokenHash; } public User getUser() { diff --git a/src/main/java/com/empress/usermanagementapi/entity/PasswordResetToken.java b/src/main/java/com/empress/usermanagementapi/entity/PasswordResetToken.java index b523048..89c05c7 100644 --- a/src/main/java/com/empress/usermanagementapi/entity/PasswordResetToken.java +++ b/src/main/java/com/empress/usermanagementapi/entity/PasswordResetToken.java @@ -8,8 +8,8 @@ public class PasswordResetToken extends BaseTokenEntity { public PasswordResetToken() {} - public PasswordResetToken(String token, User user, LocalDateTime expiryDate) { - this.setToken(token); + public PasswordResetToken(String tokenHash, User user, LocalDateTime expiryDate) { + this.setTokenHash(tokenHash); this.setUser(user); this.setExpiryDate(expiryDate); this.setUsed(false); diff --git a/src/main/java/com/empress/usermanagementapi/repository/EmailVerificationTokenRepository.java b/src/main/java/com/empress/usermanagementapi/repository/EmailVerificationTokenRepository.java index f7e97f3..0db32f0 100644 --- a/src/main/java/com/empress/usermanagementapi/repository/EmailVerificationTokenRepository.java +++ b/src/main/java/com/empress/usermanagementapi/repository/EmailVerificationTokenRepository.java @@ -14,7 +14,7 @@ public interface EmailVerificationTokenRepository extends JpaRepository { - Optional findByToken(String token); + Optional findByTokenHash(String tokenHash); Optional findByUser(User user); diff --git a/src/main/java/com/empress/usermanagementapi/repository/PasswordResetTokenRepository.java b/src/main/java/com/empress/usermanagementapi/repository/PasswordResetTokenRepository.java index fc4130e..129c45c 100644 --- a/src/main/java/com/empress/usermanagementapi/repository/PasswordResetTokenRepository.java +++ b/src/main/java/com/empress/usermanagementapi/repository/PasswordResetTokenRepository.java @@ -13,7 +13,7 @@ public interface PasswordResetTokenRepository extends JpaRepository { - Optional findByToken(String token); + Optional findByTokenHash(String tokenHash); // new: so we can overwrite the same row for the same user Optional findByUser(User user); diff --git a/src/main/java/com/empress/usermanagementapi/service/EmailVerificationService.java b/src/main/java/com/empress/usermanagementapi/service/EmailVerificationService.java index 662e4dc..255545b 100644 --- a/src/main/java/com/empress/usermanagementapi/service/EmailVerificationService.java +++ b/src/main/java/com/empress/usermanagementapi/service/EmailVerificationService.java @@ -5,6 +5,7 @@ import com.empress.usermanagementapi.repository.EmailVerificationTokenRepository; import com.empress.usermanagementapi.repository.UserRepository; import com.empress.usermanagementapi.util.LoggingUtil; +import com.empress.usermanagementapi.util.TokenHasher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -22,19 +23,23 @@ public class EmailVerificationService { private final EmailVerificationTokenRepository tokenRepo; private final UserRepository userRepo; private final EmailService emailService; + private final TokenHasher tokenHasher; @Value("${app.base-url}") private String baseUrl; public EmailVerificationService(EmailVerificationTokenRepository tokenRepo, UserRepository userRepo, - EmailService emailService) { + EmailService emailService, + TokenHasher tokenHasher) { this.tokenRepo = tokenRepo; this.userRepo = userRepo; this.emailService = emailService; + this.tokenHasher = tokenHasher; } - // create or refresh a token for this user and return the token string + // create or refresh a token for this user and return the raw token string; + // only its SHA-256 hash is persisted public String createTokenForUser(User user) { LoggingUtil.setActionType("EMAIL_VERIFICATION_TOKEN_CREATE"); LoggingUtil.setUserId(user.getId()); @@ -45,11 +50,12 @@ public String createTokenForUser(User user) { tokenRepo.findByUser(user).orElse(null); String newTokenValue = UUID.randomUUID().toString(); + String tokenHash = tokenHasher.hash(newTokenValue); LocalDateTime expiry = LocalDateTime.now().plusHours(TOKEN_EXPIRY_HOURS); if (existing != null) { log.debug("Refreshing existing email verification token - userId: {}", user.getId()); - existing.setToken(newTokenValue); + existing.setTokenHash(tokenHash); existing.setExpiryDate(expiry); existing.setUsed(false); tokenRepo.save(existing); @@ -61,7 +67,7 @@ public String createTokenForUser(User user) { log.debug("Creating new email verification token - userId: {}", user.getId()); EmailVerificationToken token = new EmailVerificationToken(); token.setUser(user); - token.setToken(newTokenValue); + token.setTokenHash(tokenHash); token.setExpiryDate(expiry); token.setUsed(false); @@ -103,7 +109,14 @@ public String verifyToken(String tokenValue) { log.info("Email verification attempt - tokenLength: {}", tokenValue != null ? tokenValue.length() : 0); - var opt = tokenRepo.findByToken(tokenValue); + if (tokenValue == null || tokenValue.isEmpty()) { + log.warn("Email verification failed - invalid token"); + LoggingUtil.clearActionType(); + LoggingUtil.clearUserId(); + return "The provided verification link is invalid. Please check the link or request a new one."; + } + + var opt = tokenRepo.findByTokenHash(tokenHasher.hash(tokenValue)); if (opt.isEmpty()) { log.warn("Email verification failed - invalid token"); LoggingUtil.clearActionType(); diff --git a/src/main/java/com/empress/usermanagementapi/service/PasswordResetService.java b/src/main/java/com/empress/usermanagementapi/service/PasswordResetService.java index e3234f3..15ea845 100644 --- a/src/main/java/com/empress/usermanagementapi/service/PasswordResetService.java +++ b/src/main/java/com/empress/usermanagementapi/service/PasswordResetService.java @@ -5,6 +5,7 @@ import com.empress.usermanagementapi.repository.PasswordResetTokenRepository; import com.empress.usermanagementapi.repository.UserRepository; import com.empress.usermanagementapi.util.LoggingUtil; +import com.empress.usermanagementapi.util.TokenHasher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -24,6 +25,7 @@ public class PasswordResetService { private final UserRepository userRepo; private final PasswordEncoder passwordEncoder; private final EmailService emailService; + private final TokenHasher tokenHasher; @Value("${app.base-url}") private String baseUrl; @@ -31,14 +33,25 @@ public class PasswordResetService { public PasswordResetService(PasswordResetTokenRepository tokenRepo, UserRepository userRepo, PasswordEncoder passwordEncoder, - EmailService emailService) { + EmailService emailService, + TokenHasher tokenHasher) { this.tokenRepo = tokenRepo; this.userRepo = userRepo; this.passwordEncoder = passwordEncoder; this.emailService = emailService; + this.tokenHasher = tokenHasher; } - public PasswordResetToken createPasswordResetTokenForEmail(String email) { + /** + * Issues a password reset token for the given email. + * + * Only the SHA-256 hash of the token is persisted; the returned raw token + * exists solely so the caller can place it in the reset link, and is never + * stored or logged. + * + * @return the raw reset token to embed in the emailed link + */ + public String createPasswordResetTokenForEmail(String email) { LoggingUtil.setActionType("PASSWORD_RESET_TOKEN_CREATE"); log.info("Password reset token request - email: {}", LoggingUtil.maskEmail(email)); @@ -54,7 +67,7 @@ public PasswordResetToken createPasswordResetTokenForEmail(String email) { log.info("Creating password reset token - userId: {}, username: {}", user.getId(), user.getUsername()); - String token = UUID.randomUUID().toString(); + String rawToken = UUID.randomUUID().toString(); LocalDateTime expiry = LocalDateTime.now().plusHours(TOKEN_EXPIRY_HOURS); // Reuse existing row for this user if it exists, otherwise create a new one @@ -66,15 +79,15 @@ public PasswordResetToken createPasswordResetTokenForEmail(String email) { return t; }); - prt.setToken(token); + prt.setTokenHash(tokenHasher.hash(rawToken)); prt.setExpiryDate(expiry); prt.setUsed(false); - PasswordResetToken saved = tokenRepo.save(prt); + tokenRepo.save(prt); log.info("Password reset token created successfully - userId: {}", user.getId()); LoggingUtil.clearActionType(); LoggingUtil.clearUserId(); - return saved; + return rawToken; } /** @@ -85,8 +98,8 @@ public PasswordResetToken createPasswordResetTokenForEmail(String email) { * to propagate so the controller can show the user that the reset email was not sent. */ public void createTokenAndSendResetEmail(String email) { - PasswordResetToken tokenEntity = createPasswordResetTokenForEmail(email); - String resetLink = baseUrl + "/reset-password?token=" + tokenEntity.getToken(); + String rawToken = createPasswordResetTokenForEmail(email); + String resetLink = baseUrl + "/reset-password?token=" + rawToken; emailService.sendPasswordResetEmail(email, resetLink); } @@ -97,7 +110,7 @@ public String validatePasswordResetToken(String token) { return "The reset password token is invalid. Ensure you copied the entire link."; } - var opt = tokenRepo.findByToken(cleanToken); + var opt = tokenRepo.findByTokenHash(tokenHasher.hash(cleanToken)); if (opt.isEmpty()) { return "The reset password token is invalid. Ensure you copied the entire link."; @@ -130,7 +143,7 @@ public String resetPassword(String token, String newPassword) { return "The reset password token is invalid. Ensure you copied the entire link."; } - var opt = tokenRepo.findByToken(cleanToken); + var opt = tokenRepo.findByTokenHash(tokenHasher.hash(cleanToken)); if (opt.isEmpty()) { log.warn("Password reset failed - token not found"); diff --git a/src/main/java/com/empress/usermanagementapi/util/TokenHasher.java b/src/main/java/com/empress/usermanagementapi/util/TokenHasher.java new file mode 100644 index 0000000..875582c --- /dev/null +++ b/src/main/java/com/empress/usermanagementapi/util/TokenHasher.java @@ -0,0 +1,38 @@ +package com.empress.usermanagementapi.util; + +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * Computes deterministic SHA-256 hashes for one-time tokens (email verification + * and password reset). + * + * The raw token is sent to the user and only its hash is persisted, so a leaked + * token table cannot be replayed directly. A deterministic hash (rather than a + * salted scheme like BCrypt) is required because tokens are looked up by value; + * SHA-256 is sufficient here since the input is high-entropy random data, not a + * user-chosen password. + */ +@Component +public class TokenHasher { + + /** + * @return the SHA-256 digest of the token, encoded as lowercase hexadecimal + */ + public String hash(String rawToken) { + if (rawToken == null) { + throw new IllegalArgumentException("rawToken must not be null"); + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hashed); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 MessageDigest is not available in this JVM", e); + } + } +} diff --git a/src/test/java/com/empress/usermanagementapi/entity/BaseTokenEntitySchemaTest.java b/src/test/java/com/empress/usermanagementapi/entity/BaseTokenEntitySchemaTest.java index 69deca0..538c329 100644 --- a/src/test/java/com/empress/usermanagementapi/entity/BaseTokenEntitySchemaTest.java +++ b/src/test/java/com/empress/usermanagementapi/entity/BaseTokenEntitySchemaTest.java @@ -50,7 +50,7 @@ void testEmailVerificationTokenInheritance() { // Create and save email verification token EmailVerificationToken token = new EmailVerificationToken(); - token.setToken("test-token-123"); + token.setTokenHash("test-token-123"); token.setUser(user); token.setExpiryDate(LocalDateTime.now().plusDays(1)); token.setUsed(false); @@ -59,7 +59,7 @@ void testEmailVerificationTokenInheritance() { // Verify all inherited fields are properly persisted assertNotNull(saved.getId()); - assertEquals("test-token-123", saved.getToken()); + assertEquals("test-token-123", saved.getTokenHash()); assertEquals(user.getId(), saved.getUser().getId()); assertNotNull(saved.getExpiryDate()); assertFalse(saved.isUsed()); @@ -67,7 +67,7 @@ void testEmailVerificationTokenInheritance() { // Verify the token can be retrieved EmailVerificationToken retrieved = emailTokenRepository.findById(saved.getId()).orElse(null); assertNotNull(retrieved); - assertEquals(saved.getToken(), retrieved.getToken()); + assertEquals(saved.getTokenHash(), retrieved.getTokenHash()); } @Test @@ -88,7 +88,7 @@ void testPasswordResetTokenInheritance() { // Verify all inherited fields are properly persisted assertNotNull(saved.getId()); - assertEquals("reset-token-456", saved.getToken()); + assertEquals("reset-token-456", saved.getTokenHash()); assertEquals(user.getId(), saved.getUser().getId()); assertNotNull(saved.getExpiryDate()); assertFalse(saved.isUsed()); @@ -96,7 +96,7 @@ void testPasswordResetTokenInheritance() { // Verify the token can be retrieved PasswordResetToken retrieved = passwordTokenRepository.findById(saved.getId()).orElse(null); assertNotNull(retrieved); - assertEquals(saved.getToken(), retrieved.getToken()); + assertEquals(saved.getTokenHash(), retrieved.getTokenHash()); } @Test @@ -111,7 +111,7 @@ void testTokenUpdate() { // Create and save token EmailVerificationToken token = new EmailVerificationToken(); - token.setToken("original-token"); + token.setTokenHash("original-token"); token.setUser(user); token.setExpiryDate(LocalDateTime.now().plusDays(1)); token.setUsed(false); @@ -120,14 +120,14 @@ void testTokenUpdate() { Long tokenId = saved.getId(); // Update the token - saved.setToken("updated-token"); + saved.setTokenHash("updated-token"); saved.setUsed(true); emailTokenRepository.save(saved); // Verify update worked EmailVerificationToken updated = emailTokenRepository.findById(tokenId).orElse(null); assertNotNull(updated); - assertEquals("updated-token", updated.getToken()); + assertEquals("updated-token", updated.getTokenHash()); assertTrue(updated.isUsed()); } @@ -150,7 +150,7 @@ void testMultipleTokensForDifferentUsers() { // Create tokens for both users EmailVerificationToken token1 = new EmailVerificationToken(); - token1.setToken("token-user1"); + token1.setTokenHash("token-user1"); token1.setUser(user1); token1.setExpiryDate(LocalDateTime.now().plusDays(1)); emailTokenRepository.save(token1); @@ -162,11 +162,11 @@ void testMultipleTokensForDifferentUsers() { assertEquals(1, emailTokenRepository.count()); assertEquals(1, passwordTokenRepository.count()); - EmailVerificationToken retrievedEmail = emailTokenRepository.findByToken("token-user1").orElse(null); + EmailVerificationToken retrievedEmail = emailTokenRepository.findByTokenHash("token-user1").orElse(null); assertNotNull(retrievedEmail); assertEquals(user1.getId(), retrievedEmail.getUser().getId()); - PasswordResetToken retrievedPassword = passwordTokenRepository.findByToken("token-user2").orElse(null); + PasswordResetToken retrievedPassword = passwordTokenRepository.findByTokenHash("token-user2").orElse(null); assertNotNull(retrievedPassword); assertEquals(user2.getId(), retrievedPassword.getUser().getId()); } diff --git a/src/test/java/com/empress/usermanagementapi/service/EmailVerificationServiceTest.java b/src/test/java/com/empress/usermanagementapi/service/EmailVerificationServiceTest.java index 81e668e..0e7179e 100644 --- a/src/test/java/com/empress/usermanagementapi/service/EmailVerificationServiceTest.java +++ b/src/test/java/com/empress/usermanagementapi/service/EmailVerificationServiceTest.java @@ -5,16 +5,21 @@ import com.empress.usermanagementapi.entity.User; import com.empress.usermanagementapi.repository.EmailVerificationTokenRepository; import com.empress.usermanagementapi.repository.UserRepository; +import com.empress.usermanagementapi.util.TokenHasher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; /** * Test class for EmailVerificationService error messages. @@ -39,6 +44,12 @@ class EmailVerificationServiceTest { @Autowired private UserRepository userRepository; + @Autowired + private TokenHasher tokenHasher; + + @MockBean + private EmailService emailService; + private User testUser; @BeforeEach @@ -65,7 +76,7 @@ void testVerifyToken_InvalidToken_ReturnsUserFriendlyMessage() { @Test void testVerifyToken_UsedToken_ReturnsUserFriendlyMessage() { EmailVerificationToken token = new EmailVerificationToken(); - token.setToken("used-token"); + token.setTokenHash(tokenHasher.hash("used-token")); token.setUser(testUser); token.setExpiryDate(LocalDateTime.now().plusDays(1)); token.setUsed(true); @@ -80,7 +91,7 @@ void testVerifyToken_UsedToken_ReturnsUserFriendlyMessage() { @Test void testVerifyToken_ExpiredToken_ReturnsUserFriendlyMessage() { EmailVerificationToken token = new EmailVerificationToken(); - token.setToken("expired-token"); + token.setTokenHash(tokenHasher.hash("expired-token")); token.setUser(testUser); token.setExpiryDate(LocalDateTime.now().minusDays(1)); token.setUsed(false); @@ -95,7 +106,7 @@ void testVerifyToken_ExpiredToken_ReturnsUserFriendlyMessage() { @Test void testVerifyToken_ValidToken_ReturnsNull() { EmailVerificationToken token = new EmailVerificationToken(); - token.setToken("valid-token"); + token.setTokenHash(tokenHasher.hash("valid-token")); token.setUser(testUser); token.setExpiryDate(LocalDateTime.now().plusDays(1)); token.setUsed(false); @@ -110,7 +121,42 @@ void testVerifyToken_ValidToken_ReturnsNull() { assertTrue(updatedUser.isVerified()); // Verify token is marked as used - EmailVerificationToken updatedToken = tokenRepository.findByToken("valid-token").orElseThrow(); + EmailVerificationToken updatedToken = tokenRepository.findByTokenHash(tokenHasher.hash("valid-token")).orElseThrow(); assertTrue(updatedToken.isUsed()); } + + @Test + void testCreateTokenForUser_StoresHashAndReturnsRawToken() { + String rawToken = emailVerificationService.createTokenForUser(testUser); + + assertNotNull(rawToken); + EmailVerificationToken saved = tokenRepository.findByUser(testUser).orElseThrow(); + assertEquals(tokenHasher.hash(rawToken), saved.getTokenHash()); + assertNotEquals(rawToken, saved.getTokenHash()); + } + + @Test + void testCreateTokenAndSendVerificationEmail_EmailLinkContainsRawTokenNotHash() { + emailVerificationService.createTokenAndSendVerificationEmail(testUser); + + ArgumentCaptor linkCaptor = ArgumentCaptor.forClass(String.class); + verify(emailService).sendVerificationEmail(eq("test@example.com"), linkCaptor.capture()); + + String verifyLink = linkCaptor.getValue(); + String rawToken = verifyLink.substring(verifyLink.indexOf("token=") + "token=".length()); + EmailVerificationToken saved = tokenRepository.findByUser(testUser).orElseThrow(); + assertEquals(tokenHasher.hash(rawToken), saved.getTokenHash()); + assertFalse(verifyLink.contains(saved.getTokenHash())); + } + + @Test + void testVerifyToken_RawTokenIssuedByService_IsHashedForLookup() { + String rawToken = emailVerificationService.createTokenForUser(testUser); + + String error = emailVerificationService.verifyToken(rawToken); + + assertNull(error); + User updatedUser = userRepository.findById(testUser.getId()).orElseThrow(); + assertTrue(updatedUser.isVerified()); + } } diff --git a/src/test/java/com/empress/usermanagementapi/service/PasswordResetServiceTest.java b/src/test/java/com/empress/usermanagementapi/service/PasswordResetServiceTest.java index 9b963c9..64b4636 100644 --- a/src/test/java/com/empress/usermanagementapi/service/PasswordResetServiceTest.java +++ b/src/test/java/com/empress/usermanagementapi/service/PasswordResetServiceTest.java @@ -5,10 +5,13 @@ import com.empress.usermanagementapi.entity.User; import com.empress.usermanagementapi.repository.PasswordResetTokenRepository; import com.empress.usermanagementapi.repository.UserRepository; +import com.empress.usermanagementapi.util.TokenHasher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.TestPropertySource; import org.springframework.transaction.annotation.Transactional; @@ -16,6 +19,8 @@ import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; /** * Test class for PasswordResetService error messages. @@ -43,6 +48,12 @@ class PasswordResetServiceTest { @Autowired private PasswordEncoder passwordEncoder; + @Autowired + private TokenHasher tokenHasher; + + @MockBean + private EmailService emailService; + private User testUser; @BeforeEach @@ -85,7 +96,7 @@ void testValidatePasswordResetToken_InvalidToken_ReturnsUserFriendlyMessage() { @Test void testValidatePasswordResetToken_UsedToken_ReturnsUserFriendlyMessage() { PasswordResetToken token = new PasswordResetToken(); - token.setToken("used-token"); + token.setTokenHash(tokenHasher.hash("used-token")); token.setUser(testUser); token.setExpiryDate(LocalDateTime.now().plusDays(1)); token.setUsed(true); @@ -100,7 +111,7 @@ void testValidatePasswordResetToken_UsedToken_ReturnsUserFriendlyMessage() { @Test void testValidatePasswordResetToken_ExpiredToken_ReturnsUserFriendlyMessage() { PasswordResetToken token = new PasswordResetToken(); - token.setToken("expired-token"); + token.setTokenHash(tokenHasher.hash("expired-token")); token.setUser(testUser); token.setExpiryDate(LocalDateTime.now().minusDays(1)); token.setUsed(false); @@ -115,7 +126,7 @@ void testValidatePasswordResetToken_ExpiredToken_ReturnsUserFriendlyMessage() { @Test void testValidatePasswordResetToken_ValidToken_ReturnsNull() { PasswordResetToken token = new PasswordResetToken(); - token.setToken("valid-token"); + token.setTokenHash(tokenHasher.hash("valid-token")); token.setUser(testUser); token.setExpiryDate(LocalDateTime.now().plusDays(1)); token.setUsed(false); @@ -153,7 +164,7 @@ void testResetPassword_InvalidToken_ReturnsUserFriendlyMessage() { @Test void testResetPassword_UsedToken_ReturnsUserFriendlyMessage() { PasswordResetToken token = new PasswordResetToken(); - token.setToken("used-token"); + token.setTokenHash(tokenHasher.hash("used-token")); token.setUser(testUser); token.setExpiryDate(LocalDateTime.now().plusDays(1)); token.setUsed(true); @@ -168,7 +179,7 @@ void testResetPassword_UsedToken_ReturnsUserFriendlyMessage() { @Test void testResetPassword_ExpiredToken_ReturnsUserFriendlyMessage() { PasswordResetToken token = new PasswordResetToken(); - token.setToken("expired-token"); + token.setTokenHash(tokenHasher.hash("expired-token")); token.setUser(testUser); token.setExpiryDate(LocalDateTime.now().minusDays(1)); token.setUsed(false); @@ -183,7 +194,7 @@ void testResetPassword_ExpiredToken_ReturnsUserFriendlyMessage() { @Test void testResetPassword_ValidToken_ReturnsNullAndUpdatesPassword() { PasswordResetToken token = new PasswordResetToken(); - token.setToken("valid-token"); + token.setTokenHash(tokenHasher.hash("valid-token")); token.setUser(testUser); token.setExpiryDate(LocalDateTime.now().plusDays(1)); token.setUsed(false); @@ -198,7 +209,51 @@ void testResetPassword_ValidToken_ReturnsNullAndUpdatesPassword() { assertTrue(passwordEncoder.matches("newpassword", updatedUser.getPassword())); // Verify token is marked as used - PasswordResetToken updatedToken = tokenRepository.findByToken("valid-token").orElseThrow(); + PasswordResetToken updatedToken = tokenRepository.findByTokenHash(tokenHasher.hash("valid-token")).orElseThrow(); assertTrue(updatedToken.isUsed()); } + + @Test + void testCreatePasswordResetTokenForEmail_StoresHashAndReturnsRawToken() { + String rawToken = passwordResetService.createPasswordResetTokenForEmail("test@example.com"); + + assertNotNull(rawToken); + PasswordResetToken saved = tokenRepository.findByUser(testUser).orElseThrow(); + assertEquals(tokenHasher.hash(rawToken), saved.getTokenHash()); + assertNotEquals(rawToken, saved.getTokenHash()); + } + + @Test + void testCreateTokenAndSendResetEmail_EmailLinkContainsRawTokenNotHash() { + passwordResetService.createTokenAndSendResetEmail("test@example.com"); + + ArgumentCaptor linkCaptor = ArgumentCaptor.forClass(String.class); + verify(emailService).sendPasswordResetEmail(eq("test@example.com"), linkCaptor.capture()); + + String resetLink = linkCaptor.getValue(); + String rawToken = resetLink.substring(resetLink.indexOf("token=") + "token=".length()); + PasswordResetToken saved = tokenRepository.findByUser(testUser).orElseThrow(); + assertEquals(tokenHasher.hash(rawToken), saved.getTokenHash()); + assertFalse(resetLink.contains(saved.getTokenHash())); + } + + @Test + void testValidatePasswordResetToken_RawTokenIssuedByService_IsAccepted() { + String rawToken = passwordResetService.createPasswordResetTokenForEmail("test@example.com"); + + assertNull(passwordResetService.validatePasswordResetToken(rawToken)); + } + + @Test + void testResetPassword_RawTokenIssuedByService_IsHashedForLookup() { + String rawToken = passwordResetService.createPasswordResetTokenForEmail("test@example.com"); + + String error = passwordResetService.resetPassword(rawToken, "brand-new-password"); + + assertNull(error); + User updatedUser = userRepository.findById(testUser.getId()).orElseThrow(); + assertTrue(passwordEncoder.matches("brand-new-password", updatedUser.getPassword())); + PasswordResetToken saved = tokenRepository.findByTokenHash(tokenHasher.hash(rawToken)).orElseThrow(); + assertTrue(saved.isUsed()); + } } diff --git a/src/test/java/com/empress/usermanagementapi/service/TokenCleanupServiceTest.java b/src/test/java/com/empress/usermanagementapi/service/TokenCleanupServiceTest.java index 175320f..25ce59a 100644 --- a/src/test/java/com/empress/usermanagementapi/service/TokenCleanupServiceTest.java +++ b/src/test/java/com/empress/usermanagementapi/service/TokenCleanupServiceTest.java @@ -73,14 +73,14 @@ void testCleanupExpiredEmailVerificationTokens() { // Create expired token EmailVerificationToken expiredToken = new EmailVerificationToken(); - expiredToken.setToken("expired-token"); + expiredToken.setTokenHash("expired-token"); expiredToken.setUser(testUser); expiredToken.setExpiryDate(LocalDateTime.now().minusDays(1)); emailVerificationTokenRepository.save(expiredToken); // Create non-expired token EmailVerificationToken validToken = new EmailVerificationToken(); - validToken.setToken("valid-token"); + validToken.setTokenHash("valid-token"); validToken.setUser(validUser); validToken.setExpiryDate(LocalDateTime.now().plusDays(1)); emailVerificationTokenRepository.save(validToken); @@ -94,8 +94,8 @@ void testCleanupExpiredEmailVerificationTokens() { // Verify only expired token was deleted assertEquals(1, deletedCount); assertEquals(1, emailVerificationTokenRepository.count()); - assertTrue(emailVerificationTokenRepository.findByToken("valid-token").isPresent()); - assertFalse(emailVerificationTokenRepository.findByToken("expired-token").isPresent()); + assertTrue(emailVerificationTokenRepository.findByTokenHash("valid-token").isPresent()); + assertFalse(emailVerificationTokenRepository.findByTokenHash("expired-token").isPresent()); } @Test @@ -110,14 +110,14 @@ void testCleanupExpiredPasswordResetTokens() { // Create expired token PasswordResetToken expiredToken = new PasswordResetToken(); - expiredToken.setToken("expired-reset-token"); + expiredToken.setTokenHash("expired-reset-token"); expiredToken.setUser(testUser); expiredToken.setExpiryDate(LocalDateTime.now().minusDays(1)); passwordResetTokenRepository.save(expiredToken); // Create non-expired token PasswordResetToken validToken = new PasswordResetToken(); - validToken.setToken("valid-reset-token"); + validToken.setTokenHash("valid-reset-token"); validToken.setUser(validUser); validToken.setExpiryDate(LocalDateTime.now().plusDays(1)); passwordResetTokenRepository.save(validToken); @@ -131,8 +131,8 @@ void testCleanupExpiredPasswordResetTokens() { // Verify only expired token was deleted assertEquals(1, deletedCount); assertEquals(1, passwordResetTokenRepository.count()); - assertTrue(passwordResetTokenRepository.findByToken("valid-reset-token").isPresent()); - assertFalse(passwordResetTokenRepository.findByToken("expired-reset-token").isPresent()); + assertTrue(passwordResetTokenRepository.findByTokenHash("valid-reset-token").isPresent()); + assertFalse(passwordResetTokenRepository.findByTokenHash("expired-reset-token").isPresent()); } @Test @@ -154,13 +154,13 @@ void testCleanupWhenNoExpiredTokens() { // Create only non-expired tokens EmailVerificationToken emailToken = new EmailVerificationToken(); - emailToken.setToken("valid-email-token"); + emailToken.setTokenHash("valid-email-token"); emailToken.setUser(emailUser); emailToken.setExpiryDate(LocalDateTime.now().plusDays(1)); emailVerificationTokenRepository.save(emailToken); PasswordResetToken passwordToken = new PasswordResetToken(); - passwordToken.setToken("valid-password-token"); + passwordToken.setTokenHash("valid-password-token"); passwordToken.setUser(passwordUser); passwordToken.setExpiryDate(LocalDateTime.now().plusDays(1)); passwordResetTokenRepository.save(passwordToken); @@ -209,25 +209,25 @@ void testCleanupExpiredTokensIntegration() { // Create a mix of expired and valid tokens EmailVerificationToken expiredEmail = new EmailVerificationToken(); - expiredEmail.setToken("expired-email"); + expiredEmail.setTokenHash("expired-email"); expiredEmail.setUser(expiredEmailUser); expiredEmail.setExpiryDate(LocalDateTime.now().minusHours(1)); emailVerificationTokenRepository.save(expiredEmail); PasswordResetToken expiredPassword = new PasswordResetToken(); - expiredPassword.setToken("expired-password"); + expiredPassword.setTokenHash("expired-password"); expiredPassword.setUser(expiredPasswordUser); expiredPassword.setExpiryDate(LocalDateTime.now().minusHours(2)); passwordResetTokenRepository.save(expiredPassword); EmailVerificationToken validEmail = new EmailVerificationToken(); - validEmail.setToken("valid-email"); + validEmail.setTokenHash("valid-email"); validEmail.setUser(validEmailUser); validEmail.setExpiryDate(LocalDateTime.now().plusHours(1)); emailVerificationTokenRepository.save(validEmail); PasswordResetToken validPassword = new PasswordResetToken(); - validPassword.setToken("valid-password"); + validPassword.setTokenHash("valid-password"); validPassword.setUser(validPasswordUser); validPassword.setExpiryDate(LocalDateTime.now().plusHours(2)); passwordResetTokenRepository.save(validPassword); @@ -238,7 +238,7 @@ void testCleanupExpiredTokensIntegration() { // Verify only valid tokens remain assertEquals(1, emailVerificationTokenRepository.count()); assertEquals(1, passwordResetTokenRepository.count()); - assertTrue(emailVerificationTokenRepository.findByToken("valid-email").isPresent()); - assertTrue(passwordResetTokenRepository.findByToken("valid-password").isPresent()); + assertTrue(emailVerificationTokenRepository.findByTokenHash("valid-email").isPresent()); + assertTrue(passwordResetTokenRepository.findByTokenHash("valid-password").isPresent()); } } diff --git a/src/test/java/com/empress/usermanagementapi/util/TokenHasherTest.java b/src/test/java/com/empress/usermanagementapi/util/TokenHasherTest.java new file mode 100644 index 0000000..922b815 --- /dev/null +++ b/src/test/java/com/empress/usermanagementapi/util/TokenHasherTest.java @@ -0,0 +1,51 @@ +package com.empress.usermanagementapi.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class TokenHasherTest { + + private final TokenHasher tokenHasher = new TokenHasher(); + + @Test + void testHash_KnownSha256Vector() { + // NIST test vector: SHA-256("abc") + assertEquals( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + tokenHasher.hash("abc")); + } + + @Test + void testHash_EmptyStringVector() { + // SHA-256 of the empty string + assertEquals( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + tokenHasher.hash("")); + } + + @Test + void testHash_SameInputProducesSameHash() { + String token = "9f2c4e61-1b2a-4c3d-8e5f-0a1b2c3d4e5f"; + + assertEquals(tokenHasher.hash(token), tokenHasher.hash(token)); + } + + @Test + void testHash_DifferentInputsProduceDifferentHashes() { + assertNotEquals(tokenHasher.hash("token-one"), tokenHasher.hash("token-two")); + } + + @Test + void testHash_OutputIsLowercaseHex() { + String hash = tokenHasher.hash("any-token"); + + assertEquals(64, hash.length()); + assertTrue(hash.matches("[0-9a-f]{64}")); + } + + @Test + void testHash_NullInputRejected() { + assertThrows(IllegalArgumentException.class, () -> tokenHasher.hash(null)); + } +}