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
11 changes: 6 additions & 5 deletions src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import com.digitalsanctuary.spring.user.dto.PasswordDto;
import com.digitalsanctuary.spring.user.dto.PasswordResetRequestDto;
import com.digitalsanctuary.spring.user.dto.PasswordlessRegistrationDto;
import com.digitalsanctuary.spring.user.dto.ResendVerificationDto;
import com.digitalsanctuary.spring.user.dto.SavePasswordDto;
import com.digitalsanctuary.spring.user.dto.SetPasswordDto;
import com.digitalsanctuary.spring.user.dto.UserDto;
Expand Down Expand Up @@ -183,19 +184,19 @@ public ResponseEntity<JSONResponse> registerUserAccount(@Valid @RequestBody User
* Resends the registration token. This is used when the user did not receive
* the initial registration email.
*
* @param userDto the user data transfer object containing user details
* @param resendVerificationDto the DTO containing the email address to resend the verification email to
* @param request the HTTP servlet request
* @return a ResponseEntity containing a JSONResponse with the registration
* result
* @return a ResponseEntity containing a generic JSONResponse that is identical whether or not a
* verification email was actually resent
*/
@PostMapping("/resendRegistrationToken")
public ResponseEntity<JSONResponse> resendRegistrationToken(@Valid @RequestBody UserDto userDto,
public ResponseEntity<JSONResponse> resendRegistrationToken(@Valid @RequestBody ResendVerificationDto resendVerificationDto,
HttpServletRequest request) {
// Anti-enumeration: this endpoint ALWAYS returns the same generic 200 response, regardless of
// whether the email is unknown, already verified, or genuinely awaiting verification. Internally
// we only send the verification email when the account exists AND is still unverified. The true
// outcome is recorded server-side via audit/log events so operators retain visibility.
User user = userService.findUserByEmail(userDto.getEmail());
User user = userService.findUserByEmail(resendVerificationDto.getEmail());
if (user == null) {
log.info("Resend verification requested for unknown email; returning generic response.");
logAuditEvent("Resend Reg Token", "Failure", "Unknown Email", null, request);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.digitalsanctuary.spring.user.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;

/**
* Data Transfer Object for resending a registration verification email.
* <p>
* Contains only the email address needed to resend the verification email. Binding this instead of
* the registration {@link UserDto} keeps the endpoint from requiring name and password fields a
* resend request has no reason to carry.
* </p>
*
* @author Devon Hillard
*/
@Data
public class ResendVerificationDto {

/** The email address to resend the verification email to. */
@NotBlank(message = "Email is required")
@Email(message = "Please provide a valid email address")
@Size(max = 100, message = "Email must not exceed 100 characters")
private String email;
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
import ch.qos.logback.core.read.ListAppender;
import java.util.Collections;
import java.util.Locale;
import java.util.Map;

import com.digitalsanctuary.spring.user.audit.AuditEvent;
import com.digitalsanctuary.spring.user.dto.PasswordDto;
import com.digitalsanctuary.spring.user.dto.ResendVerificationDto;
import com.digitalsanctuary.spring.user.dto.SetPasswordDto;
import com.digitalsanctuary.spring.user.dto.UserDto;
import com.digitalsanctuary.spring.user.dto.UserProfileUpdateDto;
Expand Down Expand Up @@ -343,7 +345,7 @@ void resendRegistrationToken_success() throws Exception {
// When & Then
mockMvc.perform(post("/user/resendRegistrationToken")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(testUserDto))
.content(resendJson(testUserDto.getEmail()))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
Expand All @@ -363,7 +365,7 @@ void resendRegistrationToken_alreadyVerified_returnsUniformResponse() throws Exc
// When & Then - same response as the unverified case, and no email is sent
mockMvc.perform(post("/user/resendRegistrationToken")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(testUserDto))
.content(resendJson(testUserDto.getEmail()))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
Expand All @@ -383,7 +385,7 @@ void resendRegistrationToken_unknownEmail_returnsUniformResponse() throws Except
// When & Then - same uniform 200 response; nothing leaks existence
mockMvc.perform(post("/user/resendRegistrationToken")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(testUserDto))
.content(resendJson(testUserDto.getEmail()))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
Expand All @@ -393,6 +395,80 @@ void resendRegistrationToken_unknownEmail_returnsUniformResponse() throws Except

verify(userEmailService, never()).sendRegistrationVerificationEmail(any(User.class), anyString());
}

@Test
@DisplayName("POST /user/resendRegistrationToken - email-only body with no name or password is accepted")
void resendRegistrationToken_emailOnlyBody_isAccepted() throws Exception {
// Given - the body the resend page actually posts: just an email, no name or password fields
User unverifiedUser = UserTestDataBuilder.aUser()
.withEmail(testUserDto.getEmail())
.disabled()
.build();
when(userService.findUserByEmail(testUserDto.getEmail())).thenReturn(unverifiedUser);
when(appUrlResolver.resolveAppUrl(any())).thenReturn("http://localhost:8080");

// When & Then - no 400 from registration-only validation constraints
mockMvc.perform(post("/user/resendRegistrationToken")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("email", testUserDto.getEmail())))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));

verify(userEmailService).sendRegistrationVerificationEmail(eq(unverifiedUser), anyString());
}

@Test
@DisplayName("POST /user/resendRegistrationToken - blank email is rejected with 400 and sends no email")
void resendRegistrationToken_blankEmail_returnsBadRequest() throws Exception {
mockMvc.perform(post("/user/resendRegistrationToken")
.contentType(MediaType.APPLICATION_JSON)
.content(resendJson(""))
.with(csrf()))
.andExpect(status().isBadRequest());

verify(userEmailService, never()).sendRegistrationVerificationEmail(any(User.class), anyString());
}

@Test
@DisplayName("POST /user/resendRegistrationToken - malformed email is rejected with 400 and sends no email")
void resendRegistrationToken_malformedEmail_returnsBadRequest() throws Exception {
mockMvc.perform(post("/user/resendRegistrationToken")
.contentType(MediaType.APPLICATION_JSON)
.content(resendJson("not-an-email"))
.with(csrf()))
.andExpect(status().isBadRequest());

verify(userEmailService, never()).sendRegistrationVerificationEmail(any(User.class), anyString());
}

@Test
@DisplayName("POST /user/resendRegistrationToken - a legacy full registration payload is still accepted")
void resendRegistrationToken_legacyFullUserDtoPayload_isAccepted() throws Exception {
// Given - clients built against the old signature still post the whole registration UserDto
User unverifiedUser = UserTestDataBuilder.aUser()
.withEmail(testUserDto.getEmail())
.disabled()
.build();
when(userService.findUserByEmail(testUserDto.getEmail())).thenReturn(unverifiedUser);
when(appUrlResolver.resolveAppUrl(any())).thenReturn("http://localhost:8080");

// When & Then - the extra fields are ignored rather than rejected
mockMvc.perform(post("/user/resendRegistrationToken")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(testUserDto))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));

verify(userEmailService).sendRegistrationVerificationEmail(eq(unverifiedUser), anyString());
}

private String resendJson(String email) throws Exception {
ResendVerificationDto dto = new ResendVerificationDto();
dto.setEmail(email);
return objectMapper.writeValueAsString(dto);
}
}

@Nested
Expand Down
Loading