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 @@ -3,6 +3,7 @@
import com.empress.usermanagementapi.entity.User;
import com.empress.usermanagementapi.service.UserService;
import com.empress.usermanagementapi.service.PasswordResetService;
import com.empress.usermanagementapi.util.LoggingUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
Expand All @@ -15,6 +16,8 @@
public class ForgotPasswordController {

private static final Logger log = LoggerFactory.getLogger(ForgotPasswordController.class);
private static final String RESET_REQUEST_MESSAGE =
"If an account with those details exists, a reset link has been sent.";

private final UserService userService;
private final PasswordResetService passwordResetService;
Expand Down Expand Up @@ -46,24 +49,29 @@ public String handleForm(@RequestParam String username,
return "forgot-password";
}

// Check username + email combo
Optional<User> opt = userService.findByUsernameAndEmail(trimmedUsername, trimmedEmail);

if (opt.isEmpty()) {
// This is the part you were missing: explicit feedback
model.addAttribute("error", "No account found with that username and email.");
log.info("Password reset requested for non-matching account - username: {}, email: {}",
trimmedUsername,
LoggingUtil.maskEmail(trimmedEmail));
model.addAttribute("success", RESET_REQUEST_MESSAGE);
return "forgot-password";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make reset request paths indistinguishable

When POST /forgot-password gets a username/email pair that does not match an account, this branch returns immediately, while the matching-account branch calls passwordResetService.createTokenAndSendResetEmail, which creates a token and synchronously sends/retries an external email before returning. In the account-recovery flow this patch is trying to protect, a client can still distinguish valid pairs by measuring response time even though the displayed message is generic; make the missing-account path indistinguishable from the real path, e.g. by queueing email work asynchronously or doing equivalent dummy work before responding.

Useful? React with 👍 / 👎.

}

try {
passwordResetService.createTokenAndSendResetEmail(trimmedEmail);
log.info("Password reset email sent - username: {}, email: {}",
trimmedUsername,
LoggingUtil.maskEmail(trimmedEmail));
} catch (Exception e) {
log.error("Failed to send password reset email to: {}", trimmedEmail, e);
model.addAttribute("error", "Failed to send password reset email. Please try again later.");
return "forgot-password";
log.error("Failed to send password reset email - username: {}, email: {}",
trimmedUsername,
LoggingUtil.maskEmail(trimmedEmail),
e);
}

model.addAttribute("success", "Password reset link has been sent to your email.");
model.addAttribute("success", RESET_REQUEST_MESSAGE);
return "forgot-password";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.empress.usermanagementapi.controller;

import com.empress.usermanagementapi.entity.User;
import com.empress.usermanagementapi.service.PasswordResetService;
import com.empress.usermanagementapi.service.UserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ui.Model;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class ForgotPasswordControllerTest {

private static final String GENERIC_MESSAGE =
"If an account with those details exists, a reset link has been sent.";

@Mock
private UserService userService;

@Mock
private PasswordResetService passwordResetService;

@Mock
private Model model;

private ForgotPasswordController controller;

@BeforeEach
void setUp() {
controller = new ForgotPasswordController(userService, passwordResetService);
}

@Test
void handleForm_WhenNoAccountMatches_ReturnsGenericSuccessWithoutSendingEmail() {
when(userService.findByUsernameAndEmail("missing", "missing@example.com"))
.thenReturn(Optional.empty());

String view = controller.handleForm(" missing ", " missing@example.com ", model);

assertEquals("forgot-password", view);
verify(model).addAttribute("success", GENERIC_MESSAGE);
verify(model, never()).addAttribute(eq("error"), any());
verify(passwordResetService, never()).createTokenAndSendResetEmail(anyString());
}

@Test
void handleForm_WhenAccountMatches_SendsEmailAndReturnsGenericSuccess() {
User user = new User();
when(userService.findByUsernameAndEmail("user", "user@example.com"))
.thenReturn(Optional.of(user));

String view = controller.handleForm(" user ", " user@example.com ", model);

assertEquals("forgot-password", view);
verify(passwordResetService).createTokenAndSendResetEmail("user@example.com");
verify(model).addAttribute("success", GENERIC_MESSAGE);
verify(model, never()).addAttribute(eq("error"), any());
}

@Test
void handleForm_WhenEmailDeliveryFails_StillReturnsGenericSuccess() {
User user = new User();
when(userService.findByUsernameAndEmail("user", "user@example.com"))
.thenReturn(Optional.of(user));
doThrow(new RuntimeException("email delivery failed"))
.when(passwordResetService)
.createTokenAndSendResetEmail("user@example.com");

String view = controller.handleForm(" user ", " user@example.com ", model);

assertEquals("forgot-password", view);
verify(model).addAttribute("success", GENERIC_MESSAGE);
verify(model, never()).addAttribute(eq("error"), any());
}
}
Loading