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
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
public interface EmailVerificationTokenRepository
extends JpaRepository<EmailVerificationToken, Long> {

Optional<EmailVerificationToken> findByToken(String token);
Optional<EmailVerificationToken> findByTokenHash(String tokenHash);

Optional<EmailVerificationToken> findByUser(User user);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, Long> {

Optional<PasswordResetToken> findByToken(String token);
Optional<PasswordResetToken> findByTokenHash(String tokenHash);

// new: so we can overwrite the same row for the same user
Optional<PasswordResetToken> findByUser(User user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
Expand All @@ -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);
Expand All @@ -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);

Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,21 +25,33 @@ 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;

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

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

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

Expand All @@ -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.";
Expand Down Expand Up @@ -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");
Expand Down
38 changes: 38 additions & 0 deletions src/main/java/com/empress/usermanagementapi/util/TokenHasher.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -59,15 +59,15 @@ 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());

// 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
Expand All @@ -88,15 +88,15 @@ 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());

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

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