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 @@ -54,7 +54,9 @@ public ResponseEntity<SignupResponse> signup(
UUID correlation = correlation(correlationId);
var result = registrationService.signup(
new SignupCommand(request.email(), request.password(), correlation, requestIpPrefix));
verificationDelivery.send(result.accountId(), result.verificationToken(), result.expiresAt());
if (result.verificationToken() != null) {
verificationDelivery.send(result.accountId(), result.verificationToken(), result.expiresAt());
}
return ResponseEntity.accepted().body(new SignupResponse(result.accountId(), true, result.expiresAt()));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.idea2strategy.backend.application.identity.PolicyDecisionRejectedException;
import com.idea2strategy.backend.application.identity.SanctionedAccountAccessException;
import com.idea2strategy.backend.application.identity.VerificationRejectedException;
import com.idea2strategy.backend.application.identity.VerificationRateLimitedException;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -60,6 +61,12 @@ ResponseEntity<Map<String, String>> duplicate(DuplicateEmailException exception)
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("code", "EMAIL_ALREADY_REGISTERED"));
}

@ExceptionHandler(VerificationRateLimitedException.class)
ResponseEntity<Map<String, String>> verificationRateLimited(VerificationRateLimitedException exception) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of("code", "VERIFICATION_REQUEST_RATE_LIMITED"));
}

@ExceptionHandler(AuthenticationRejectedException.class)
ResponseEntity<Map<String, String>> authentication(AuthenticationRejectedException exception) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("code", "AUTHENTICATION_REJECTED"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
Expand All @@ -14,6 +15,7 @@
import com.idea2strategy.backend.application.identity.LoginResult;
import com.idea2strategy.backend.application.identity.SignupResult;
import com.idea2strategy.backend.application.identity.VerificationDelivery;
import com.idea2strategy.backend.application.identity.VerificationRateLimitedException;
import java.time.Instant;
import java.time.Clock;
import java.time.Duration;
Expand Down Expand Up @@ -64,6 +66,27 @@ void signupDeliversVerificationSecretWithoutReturningItInTheApiBody() {
verify(delivery).send(accountId, "raw-verification-secret", expiresAt);
}

@Test
void repeatedPendingSignupDoesNotSendAnotherVerificationEmail() {
var registration = mock(EmailRegistrationService.class);
var delivery = mock(VerificationDeliveryPort.class);
UUID accountId = UUID.randomUUID();
Instant expiresAt = Instant.parse("2026-08-02T12:00:00Z");
when(registration.signup(org.mockito.ArgumentMatchers.any()))
.thenReturn(new SignupResult(accountId, null, expiresAt));
var controller = new IdentityAuthController(
registration, mock(EmailAuthenticationService.class), delivery, jwt(), cookies());

var response = controller.signup(
new IdentityAuthController.SignupRequest("person@example.com", "another valid passphrase!"),
UUID.randomUUID().toString(),
"192.0.2.0/24");

assertThat(response.getStatusCode().value()).isEqualTo(202);
assertThat(response.getBody().accountId()).isEqualTo(accountId);
verifyNoInteractions(delivery);
}

@Test
void verificationLinkActivatesTheAccountAndRedirectsToLogin() throws Exception {
var registration = mock(EmailRegistrationService.class);
Expand Down Expand Up @@ -139,6 +162,26 @@ void resendDeliversTheReplacementWithoutReturningItsSecret() {
verify(delivery).send(accountId, "replacement-secret", expiresAt);
}

@Test
void resendRateLimitReturnsTooManyRequestsWithoutSendingEmail() throws Exception {
var registration = mock(EmailRegistrationService.class);
var delivery = mock(VerificationDeliveryPort.class);
UUID accountId = UUID.randomUUID();
when(registration.resendVerification(org.mockito.ArgumentMatchers.any()))
.thenThrow(new VerificationRateLimitedException());
var controller = new IdentityAuthController(
registration, mock(EmailAuthenticationService.class), delivery, jwt(), cookies());
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new IdentityAuthExceptionHandler())
.build();

mvc.perform(post("/api/v1/auth/resend-verification")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"accountId\":\"" + accountId + "\"}"))
.andExpect(status().isTooManyRequests());
verifyNoInteractions(delivery);
}

private static CustomerJwtCodec jwt() {
return new CustomerJwtCodec(
"0123456789abcdef0123456789abcdef".getBytes(java.nio.charset.StandardCharsets.UTF_8),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.idea2strategy.backend.batch;

import com.idea2strategy.backend.application.identity.PendingRegistrationCleanupService;
import com.idea2strategy.backend.persistence.identity.PendingRegistrationCleanupJpaAdapter;
import java.time.Clock;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration(proxyBeanMethods = false)
@EnableScheduling
@ConditionalOnProperty(
name = "idea2strategy.batch.pending-registration-cleanup.enabled",
havingValue = "true",
matchIfMissing = true)
@Import(PendingRegistrationCleanupJpaAdapter.class)
class PendingRegistrationCleanupBatchConfiguration {
@Bean
PendingRegistrationCleanupService pendingRegistrationCleanupService(
PendingRegistrationCleanupJpaAdapter cleanup,
@Value("${idea2strategy.batch.pending-registration-cleanup.retention:P7D}") Duration retention) {
return new PendingRegistrationCleanupService(cleanup, Clock.systemUTC(), retention);
}

@Bean
PendingRegistrationCleanupBatchRunner pendingRegistrationCleanupBatchRunner(
PendingRegistrationCleanupService cleanup,
@Value("${idea2strategy.batch.pending-registration-cleanup.batch-size:250}") int batchSize) {
return new PendingRegistrationCleanupBatchRunner(cleanup, batchSize);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.idea2strategy.backend.batch;

import com.idea2strategy.backend.application.identity.PendingRegistrationCleanupService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;

class PendingRegistrationCleanupBatchRunner {
private static final Logger log = LoggerFactory.getLogger(PendingRegistrationCleanupBatchRunner.class);
private final PendingRegistrationCleanupService cleanup;
private final int batchSize;

PendingRegistrationCleanupBatchRunner(PendingRegistrationCleanupService cleanup, int batchSize) {
if (batchSize < 1) {
throw new IllegalArgumentException("batchSize must be positive");
}
this.cleanup = cleanup;
this.batchSize = batchSize;
}

@Scheduled(fixedDelayString = "${idea2strategy.batch.pending-registration-cleanup.fixed-delay:PT1H}")
void run() {
int purged = cleanup.purgeExpired(batchSize);
log.info("Pending registration cleanup batch completed: purged={}", purged);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
+ "org.springframework.boot.jooq.autoconfigure.JooqAutoConfiguration",
"idea2strategy.batch.expired-bot-stop.enabled=false",
"idea2strategy.batch.account-dormancy.enabled=false",
"idea2strategy.batch.pending-registration-cleanup.enabled=false",
"idea2strategy.batch.room-schedule-transition.enabled=false",
"idea2strategy.batch.room-evaluation-start.enabled=false",
"idea2strategy.batch.private-continuation-transition.enabled=false",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.idea2strategy.backend.batch;

import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.idea2strategy.backend.application.identity.PendingRegistrationCleanupService;
import org.junit.jupiter.api.Test;

class PendingRegistrationCleanupBatchRunnerTest {
@Test
void delegatesTheConfiguredBatchSize() {
var service = org.mockito.Mockito.mock(PendingRegistrationCleanupService.class);
when(service.purgeExpired(250)).thenReturn(4);

new PendingRegistrationCleanupBatchRunner(service, 250).run();

verify(service).purgeExpired(250);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public SignupResult signup(SignupCommand command) {
validateEmail(email.normalized());
var existing = queryPort.findEmailRegistration(email.comparisonFingerprints());
if (existing.isPresent()) {
return continuePendingRegistration(existing.orElseThrow(), command);
return continuePendingRegistration(existing.orElseThrow());
}
if (queryPort.emailExists(email.lookupHmac())) {
throw new DuplicateEmailException();
Expand All @@ -95,31 +95,18 @@ public SignupResult signup(SignupCommand command) {
} catch (DuplicateEmailException duplicate) {
var racedRegistration = queryPort.findEmailRegistration(email.comparisonFingerprints());
if (racedRegistration.isPresent()) {
return continuePendingRegistration(racedRegistration.orElseThrow(), command);
return continuePendingRegistration(racedRegistration.orElseThrow());
}
throw duplicate;
}
return new SignupResult(accountId, token.rawToken(), expiresAt);
}

private SignupResult continuePendingRegistration(
ExistingEmailRegistration existing, SignupCommand command) {
private SignupResult continuePendingRegistration(ExistingEmailRegistration existing) {
if (!existing.awaitingVerification()) {
throw new DuplicateEmailException();
}
var now = clock.instant();
var expiresAt = now.plus(VERIFICATION_LIFETIME);
VerificationToken token = tokenIssuer.issue();
commandPort.replacePendingRegistration(new PendingRegistrationReplacement(
UUID.randomUUID(),
existing.accountId(),
passwordHasher.hash(command.password()),
token.digest(),
now,
expiresAt,
command.correlationId(),
command.requestIpPrefix()));
return new SignupResult(existing.accountId(), token.rawToken(), expiresAt);
return new SignupResult(existing.accountId(), null, existing.verificationExpiresAt());
}

public void verify(VerifyEmailCommand command) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
package com.idea2strategy.backend.application.identity;

import java.time.Instant;
import java.util.Objects;
import java.util.UUID;

public record ExistingEmailRegistration(
UUID accountId,
AccountLifecycleStatus lifecycleStatus,
EmailStatus emailStatus) {
EmailStatus emailStatus,
Instant verificationExpiresAt) {
public ExistingEmailRegistration(
UUID accountId,
AccountLifecycleStatus lifecycleStatus,
EmailStatus emailStatus) {
this(accountId, lifecycleStatus, emailStatus, null);
}

public ExistingEmailRegistration {
Objects.requireNonNull(accountId, "accountId");
Objects.requireNonNull(lifecycleStatus, "lifecycleStatus");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.idea2strategy.backend.application.identity;

import java.time.Instant;

@FunctionalInterface
public interface PendingRegistrationCleanupPort {
int purgeExpired(Instant cutoff, Instant purgedAt, int limit);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.idea2strategy.backend.application.identity;

import java.time.Clock;
import java.time.Duration;
import java.util.Objects;

public final class PendingRegistrationCleanupService {
private final PendingRegistrationCleanupPort cleanup;
private final Clock clock;
private final Duration retention;

public PendingRegistrationCleanupService(
PendingRegistrationCleanupPort cleanup,
Clock clock,
Duration retention) {
this.cleanup = Objects.requireNonNull(cleanup, "cleanup");
this.clock = Objects.requireNonNull(clock, "clock");
this.retention = Objects.requireNonNull(retention, "retention");
if (retention.isZero() || retention.isNegative()) {
throw new IllegalArgumentException("retention must be positive");
}
}

public int purgeExpired(int limit) {
if (limit < 1) {
throw new IllegalArgumentException("limit must be positive");
}
var now = clock.instant();
return cleanup.purgeExpired(now.minus(retention), now, limit);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,5 @@ public interface RegistrationCommandPort {

VerificationOutcome consumeVerification(String tokenDigest, Instant consumedAt, UUID correlationId);

void replacePendingRegistration(PendingRegistrationReplacement replacement);

void replaceVerification(VerificationReplacement replacement);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.idea2strategy.backend.application.identity;

public final class VerificationRateLimitedException extends RuntimeException {
public VerificationRateLimitedException() {
super("Verification email request limit exceeded");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ void duplicateEmailAndShortPasswordAreRejected() {
}

@Test
void pendingEmailSignupReusesTheAccountAndIssuesANewVerificationToken() {
void pendingEmailSignupKeepsTheOriginalPasswordAndVerificationToken() {
UUID accountId = UUID.randomUUID();
var commands = new RecordingRegistrationPort();
var queries = new RegistrationQueryPort() {
Expand All @@ -95,16 +95,9 @@ public Optional<ExistingEmailRegistration> findEmailRegistration(
"person@example.com", "ValidPass!2026", UUID.randomUUID(), "192.0.2.0/24"));

assertThat(result.accountId()).isEqualTo(accountId);
assertThat(result.verificationToken()).isEqualTo("raw-verification-token");
assertThat(result.verificationToken()).isNull();
assertThat(commands.registrations).isEmpty();
assertThat(commands.pendingReplacements)
.singleElement()
.satisfies(replacement -> {
assertThat(replacement.accountId()).isEqualTo(accountId);
assertThat(replacement.tokenDigest()).isEqualTo("digest:raw-verification-token");
assertThat(replacement.password().encodedHash())
.isEqualTo("hash:ValidPass!2026");
});
assertThat(commands.replacements).isEmpty();
}

@Test
Expand Down Expand Up @@ -201,7 +194,6 @@ private static EmailRegistrationService service(

private static final class RecordingRegistrationPort implements RegistrationCommandPort {
private final List<PendingRegistration> registrations = new ArrayList<>();
private final List<PendingRegistrationReplacement> pendingReplacements = new ArrayList<>();
private final List<VerificationReplacement> replacements = new ArrayList<>();
private VerificationOutcome verificationOutcome = VerificationOutcome.VERIFIED;

Expand All @@ -216,11 +208,6 @@ public VerificationOutcome consumeVerification(
return verificationOutcome;
}

@Override
public void replacePendingRegistration(PendingRegistrationReplacement replacement) {
pendingReplacements.add(replacement);
}

@Override
public void replaceVerification(VerificationReplacement replacement) {
replacements.add(replacement);
Expand Down
Loading