diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthController.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthController.java index 579b699a..1250cfc1 100644 --- a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthController.java +++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthController.java @@ -54,7 +54,9 @@ public ResponseEntity 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())); } diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthExceptionHandler.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthExceptionHandler.java index f2890074..43299860 100644 --- a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthExceptionHandler.java +++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthExceptionHandler.java @@ -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; @@ -60,6 +61,12 @@ ResponseEntity> duplicate(DuplicateEmailException exception) return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("code", "EMAIL_ALREADY_REGISTERED")); } + @ExceptionHandler(VerificationRateLimitedException.class) + ResponseEntity> verificationRateLimited(VerificationRateLimitedException exception) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(Map.of("code", "VERIFICATION_REQUEST_RATE_LIMITED")); + } + @ExceptionHandler(AuthenticationRejectedException.class) ResponseEntity> authentication(AuthenticationRejectedException exception) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("code", "AUTHENTICATION_REJECTED")); diff --git a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/identity/IdentityAuthControllerTest.java b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/identity/IdentityAuthControllerTest.java index dcaf8619..e4224374 100644 --- a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/identity/IdentityAuthControllerTest.java +++ b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/identity/IdentityAuthControllerTest.java @@ -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; @@ -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; @@ -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); @@ -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), diff --git a/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/PendingRegistrationCleanupBatchConfiguration.java b/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/PendingRegistrationCleanupBatchConfiguration.java new file mode 100644 index 00000000..d18ec7cb --- /dev/null +++ b/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/PendingRegistrationCleanupBatchConfiguration.java @@ -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); + } +} diff --git a/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/PendingRegistrationCleanupBatchRunner.java b/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/PendingRegistrationCleanupBatchRunner.java new file mode 100644 index 00000000..2939e8a3 --- /dev/null +++ b/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/PendingRegistrationCleanupBatchRunner.java @@ -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); + } +} diff --git a/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BackendBatchApplicationTest.java b/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BackendBatchApplicationTest.java index e1fe1bbd..25e23718 100644 --- a/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BackendBatchApplicationTest.java +++ b/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BackendBatchApplicationTest.java @@ -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", diff --git a/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/PendingRegistrationCleanupBatchRunnerTest.java b/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/PendingRegistrationCleanupBatchRunnerTest.java new file mode 100644 index 00000000..c562ef8c --- /dev/null +++ b/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/PendingRegistrationCleanupBatchRunnerTest.java @@ -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); + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/EmailRegistrationService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/EmailRegistrationService.java index 29873fe9..5acb7595 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/EmailRegistrationService.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/EmailRegistrationService.java @@ -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(); @@ -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) { diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/ExistingEmailRegistration.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/ExistingEmailRegistration.java index e0b90498..2522f4e8 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/ExistingEmailRegistration.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/ExistingEmailRegistration.java @@ -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"); diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/PendingRegistrationCleanupPort.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/PendingRegistrationCleanupPort.java new file mode 100644 index 00000000..ec10a466 --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/PendingRegistrationCleanupPort.java @@ -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); +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/PendingRegistrationCleanupService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/PendingRegistrationCleanupService.java new file mode 100644 index 00000000..042face8 --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/PendingRegistrationCleanupService.java @@ -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); + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/PendingRegistrationReplacement.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/PendingRegistrationReplacement.java deleted file mode 100644 index bf789152..00000000 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/PendingRegistrationReplacement.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.idea2strategy.backend.application.identity; - -import java.time.Instant; -import java.util.Objects; -import java.util.UUID; - -public record PendingRegistrationReplacement( - UUID requestId, - UUID accountId, - PasswordHash password, - String tokenDigest, - Instant requestedAt, - Instant expiresAt, - UUID correlationId, - String requestIpPrefix) { - public PendingRegistrationReplacement { - Objects.requireNonNull(requestId, "requestId"); - Objects.requireNonNull(accountId, "accountId"); - Objects.requireNonNull(password, "password"); - Objects.requireNonNull(tokenDigest, "tokenDigest"); - Objects.requireNonNull(requestedAt, "requestedAt"); - Objects.requireNonNull(expiresAt, "expiresAt"); - Objects.requireNonNull(correlationId, "correlationId"); - } - - @Override - public String toString() { - return "PendingRegistrationReplacement[accountId=" + accountId + ", credentials=REDACTED]"; - } -} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/RegistrationCommandPort.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/RegistrationCommandPort.java index f3bd8e32..1d66f330 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/RegistrationCommandPort.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/RegistrationCommandPort.java @@ -8,7 +8,5 @@ public interface RegistrationCommandPort { VerificationOutcome consumeVerification(String tokenDigest, Instant consumedAt, UUID correlationId); - void replacePendingRegistration(PendingRegistrationReplacement replacement); - void replaceVerification(VerificationReplacement replacement); } diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/VerificationRateLimitedException.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/VerificationRateLimitedException.java new file mode 100644 index 00000000..5828c63e --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/VerificationRateLimitedException.java @@ -0,0 +1,7 @@ +package com.idea2strategy.backend.application.identity; + +public final class VerificationRateLimitedException extends RuntimeException { + public VerificationRateLimitedException() { + super("Verification email request limit exceeded"); + } +} diff --git a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/EmailRegistrationServiceTest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/EmailRegistrationServiceTest.java index 13f0c022..797e4ef8 100644 --- a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/EmailRegistrationServiceTest.java +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/EmailRegistrationServiceTest.java @@ -68,7 +68,7 @@ void duplicateEmailAndShortPasswordAreRejected() { } @Test - void pendingEmailSignupReusesTheAccountAndIssuesANewVerificationToken() { + void pendingEmailSignupKeepsTheOriginalPasswordAndVerificationToken() { UUID accountId = UUID.randomUUID(); var commands = new RecordingRegistrationPort(); var queries = new RegistrationQueryPort() { @@ -95,16 +95,9 @@ public Optional 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 @@ -201,7 +194,6 @@ private static EmailRegistrationService service( private static final class RecordingRegistrationPort implements RegistrationCommandPort { private final List registrations = new ArrayList<>(); - private final List pendingReplacements = new ArrayList<>(); private final List replacements = new ArrayList<>(); private VerificationOutcome verificationOutcome = VerificationOutcome.VERIFIED; @@ -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); diff --git a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/PendingRegistrationCleanupServiceTest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/PendingRegistrationCleanupServiceTest.java new file mode 100644 index 00000000..b1083d6a --- /dev/null +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/PendingRegistrationCleanupServiceTest.java @@ -0,0 +1,29 @@ +package com.idea2strategy.backend.application.identity; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class PendingRegistrationCleanupServiceTest { + @Test + void purgesRegistrationsWhoseLastVerificationRequestIsSevenDaysOld() { + Instant now = Instant.parse("2026-08-10T00:00:00Z"); + var observed = new AtomicReference(); + PendingRegistrationCleanupPort port = (cutoff, purgedAt, limit) -> { + observed.set(new Call(cutoff, purgedAt, limit)); + return 3; + }; + var service = new PendingRegistrationCleanupService( + port, Clock.fixed(now, ZoneOffset.UTC), Duration.ofDays(7)); + + assertThat(service.purgeExpired(100)).isEqualTo(3); + assertThat(observed.get()).isEqualTo(new Call(now.minus(Duration.ofDays(7)), now, 100)); + } + + private record Call(Instant cutoff, Instant purgedAt, int limit) {} +} diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJooqQueryAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJooqQueryAdapter.java index 9481cb15..f80db976 100644 --- a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJooqQueryAdapter.java +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJooqQueryAdapter.java @@ -23,6 +23,7 @@ import com.idea2strategy.backend.application.identity.RefreshTokenFamilyQueryPort; import com.idea2strategy.backend.application.identity.StoredRefreshTokenFamily; import java.time.Instant; +import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -56,9 +57,11 @@ public Optional findEmailRegistration( List comparisonFingerprints) { var emails = table(name("identity", "account_emails")).as("email"); var accounts = table(name("identity", "accounts")).as("account"); + var verifications = table(name("identity", "email_verification_requests")).as("verification"); var accountId = field(name("account", "id"), UUID.class); var lifecycleStatus = field(name("account", "lifecycle_status")).cast(String.class); var emailStatus = field(name("email", "status")).cast(String.class); + var verificationExpiresAt = field(name("verification", "expires_at"), OffsetDateTime.class); Condition identifierMatch = falseCondition(); for (var fingerprint : comparisonFingerprints) { identifierMatch = identifierMatch.or( @@ -66,14 +69,19 @@ public Optional findEmailRegistration( .and(field(name("email", "email_lookup_key_version"), Short.class) .eq(fingerprint.keyVersion()))); } - return dsl.select(accountId, lifecycleStatus, emailStatus) + return dsl.select(accountId, lifecycleStatus, emailStatus, verificationExpiresAt) .from(emails) .join(accounts).on(field(name("email", "account_id"), UUID.class).eq(accountId)) + .leftJoin(verifications) + .on(field(name("verification", "account_id"), UUID.class).eq(accountId) + .and(field(name("verification", "consumed_at")).isNull()) + .and(field(name("verification", "revoked_at")).isNull())) .where(identifierMatch) .fetchOptional(record -> new ExistingEmailRegistration( record.value1(), AccountLifecycleStatus.valueOf(record.value2()), - EmailStatus.valueOf(record.value3()))); + EmailStatus.valueOf(record.value3()), + record.value4() == null ? null : record.value4().toInstant())); } @Override diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJpaCommandAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJpaCommandAdapter.java index dba60b58..8ed007aa 100644 --- a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJpaCommandAdapter.java +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJpaCommandAdapter.java @@ -12,7 +12,6 @@ import com.idea2strategy.backend.application.identity.LoginFailure; import com.idea2strategy.backend.application.identity.OidcIdentityCommandPort; import com.idea2strategy.backend.application.identity.PendingRegistration; -import com.idea2strategy.backend.application.identity.PendingRegistrationReplacement; import com.idea2strategy.backend.application.identity.PendingOidcLink; import com.idea2strategy.backend.application.identity.PendingOidcRegistration; import com.idea2strategy.backend.application.identity.PendingPasswordReset; @@ -24,15 +23,18 @@ import com.idea2strategy.backend.application.identity.RegistrationCommandPort; import com.idea2strategy.backend.application.identity.RefreshTokenFamilyCommandPort; import com.idea2strategy.backend.application.identity.VerificationOutcome; +import com.idea2strategy.backend.application.identity.VerificationRateLimitedException; import com.idea2strategy.backend.application.identity.VerificationReplacement; import jakarta.persistence.EntityManager; import jakarta.persistence.NoResultException; import java.time.Instant; +import java.time.Duration; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.UUID; import java.util.List; import org.springframework.stereotype.Repository; +import org.springframework.beans.factory.annotation.Value; import org.springframework.transaction.annotation.Transactional; @Repository @@ -43,9 +45,25 @@ public class IdentityJpaCommandAdapter RefreshTokenFamilyCommandPort, AccountRecoveryCommandPort { private final EntityManager entityManager; + private final Duration verificationResendCooldown; + private final Duration verificationRateWindow; + private final int verificationRequestsPerAccount; + private final int verificationRequestsPerIp; - public IdentityJpaCommandAdapter(EntityManager entityManager) { + public IdentityJpaCommandAdapter( + EntityManager entityManager, + @Value("${identity.verification.resend-cooldown:PT60S}") Duration verificationResendCooldown, + @Value("${identity.verification.rate-window:PT1H}") Duration verificationRateWindow, + @Value("${identity.verification.max-requests-per-account:5}") int verificationRequestsPerAccount, + @Value("${identity.verification.max-requests-per-ip:20}") int verificationRequestsPerIp) { this.entityManager = entityManager; + this.verificationResendCooldown = requirePositive(verificationResendCooldown, "verificationResendCooldown"); + this.verificationRateWindow = requirePositive(verificationRateWindow, "verificationRateWindow"); + if (verificationRequestsPerAccount < 1 || verificationRequestsPerIp < 1) { + throw new IllegalArgumentException("Verification request limits must be positive"); + } + this.verificationRequestsPerAccount = verificationRequestsPerAccount; + this.verificationRequestsPerIp = verificationRequestsPerIp; } @Override @@ -132,6 +150,7 @@ public void createPending(PendingRegistration registration) { } catch (AuthenticationRejectedException rejected) { throw new DuplicateEmailException(); } + enforceIpVerificationRateLimit(registration.requestIpPrefix(), registration.requestedAt()); entityManager.createNativeQuery(""" insert into identity.accounts (id, lifecycle_status, status_changed_at, created_at) values (:id, cast('PENDING_VERIFICATION' as identity.account_lifecycle_status), :now, :now) @@ -301,64 +320,6 @@ public VerificationOutcome consumeVerification(String tokenDigest, Instant consu return VerificationOutcome.VERIFIED; } - @Override - @Transactional - public void replacePendingRegistration(PendingRegistrationReplacement replacement) { - Object[] account = (Object[]) entityManager.createNativeQuery(""" - select account.lifecycle_status::text, login.id - from identity.accounts account - join identity.login_identities login on login.account_id = account.id - join identity.auth_providers provider on provider.id = login.provider_id - join identity.password_credentials credential on credential.login_identity_id = login.id - where account.id = :accountId and provider.code = 'PASSWORD' - for update of account, login, credential - """) - .setParameter("accountId", replacement.accountId()) - .getSingleResult(); - if (!"PENDING_VERIFICATION".equals(account[0])) { - throw new IllegalStateException("Only pending accounts can replace registration credentials"); - } - UUID loginId = (UUID) account[1]; - OffsetDateTime now = utc(replacement.requestedAt()); - entityManager.createNativeQuery(""" - update identity.password_credentials - set password_hash = :hash, hash_scheme = :scheme, - hash_parameters = cast(:parameters as jsonb), - credential_version = credential_version + 1, - password_changed_at = :now - where login_identity_id = :loginId - """) - .setParameter("hash", replacement.password().encodedHash()) - .setParameter("scheme", replacement.password().scheme()) - .setParameter("parameters", replacement.password().parametersJson()) - .setParameter("now", now) - .setParameter("loginId", loginId) - .executeUpdate(); - entityManager.createNativeQuery(""" - update identity.email_verification_requests set revoked_at = :now - where account_id = :accountId and consumed_at is null and revoked_at is null - """) - .setParameter("now", now) - .setParameter("accountId", replacement.accountId()) - .executeUpdate(); - insertVerification( - replacement.requestId(), - replacement.accountId(), - replacement.tokenDigest(), - replacement.requestedAt(), - replacement.expiresAt(), - replacement.requestIpPrefix()); - insertAuthenticationEvent( - replacement.accountId(), - "EMAIL_VERIFICATION_REISSUED", - loginId, - "USER", - null, - replacement.correlationId(), - "pending-signup-reissue:" + replacement.correlationId(), - now); - } - @Override @Transactional public void replaceVerification(VerificationReplacement replacement) { @@ -375,6 +336,8 @@ public void replaceVerification(VerificationReplacement replacement) { if (!"PENDING_VERIFICATION".equals(account[0])) { throw new IllegalStateException("Only pending accounts can request another verification token"); } + enforceAccountVerificationRateLimit(replacement.accountId(), replacement.requestedAt()); + enforceIpVerificationRateLimit(replacement.requestIpPrefix(), replacement.requestedAt()); OffsetDateTime now = utc(replacement.requestedAt()); entityManager.createNativeQuery(""" update identity.email_verification_requests set revoked_at = :now @@ -1269,6 +1232,55 @@ private void insertAuthenticationTransitionEvent(ActivateOidcLink command, Offse .executeUpdate(); } + private void enforceAccountVerificationRateLimit(UUID accountId, Instant requestedAt) { + Object latest = entityManager.createNativeQuery(""" + select max(requested_at) + from identity.email_verification_requests + where account_id = :accountId + """) + .setParameter("accountId", accountId) + .getSingleResult(); + if (latest != null && instant(latest).plus(verificationResendCooldown).isAfter(requestedAt)) { + throw new VerificationRateLimitedException(); + } + Number requestsInWindow = (Number) entityManager.createNativeQuery(""" + select count(*) + from identity.email_verification_requests + where account_id = :accountId and requested_at > :windowStart + """) + .setParameter("accountId", accountId) + .setParameter("windowStart", utc(requestedAt.minus(verificationRateWindow))) + .getSingleResult(); + if (requestsInWindow.longValue() >= verificationRequestsPerAccount) { + throw new VerificationRateLimitedException(); + } + } + + private void enforceIpVerificationRateLimit(String ipPrefix, Instant requestedAt) { + if (ipPrefix == null || ipPrefix.isBlank()) { + return; + } + String normalizedPrefix = (String) entityManager.createNativeQuery( + "select cast(cast(:ipPrefix as inet) as text)") + .setParameter("ipPrefix", ipPrefix) + .getSingleResult(); + entityManager.createNativeQuery("select pg_advisory_xact_lock(hashtextextended(:key, 0))") + .setParameter("key", "verification-ip:" + normalizedPrefix) + .getSingleResult(); + Number requestsInWindow = (Number) entityManager.createNativeQuery(""" + select count(*) + from identity.email_verification_requests + where request_ip_prefix = cast(:ipPrefix as inet) + and requested_at > :windowStart + """) + .setParameter("ipPrefix", normalizedPrefix) + .setParameter("windowStart", utc(requestedAt.minus(verificationRateWindow))) + .getSingleResult(); + if (requestsInWindow.longValue() >= verificationRequestsPerIp) { + throw new VerificationRateLimitedException(); + } + } + private void insertVerification( UUID requestId, UUID accountId, String digest, Instant requestedAt, Instant expiresAt, String ipPrefix) { entityManager.createNativeQuery(""" @@ -1412,4 +1424,11 @@ private static Instant instant(Object value) { } throw new IllegalStateException("Unsupported timestamp value: " + value); } + + private static Duration requirePositive(Duration duration, String name) { + if (duration == null || duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + return duration; + } } diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/PendingRegistrationCleanupJpaAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/PendingRegistrationCleanupJpaAdapter.java new file mode 100644 index 00000000..88e9c208 --- /dev/null +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/PendingRegistrationCleanupJpaAdapter.java @@ -0,0 +1,161 @@ +package com.idea2strategy.backend.persistence.identity; + +import com.idea2strategy.backend.application.identity.PendingRegistrationCleanupPort; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public class PendingRegistrationCleanupJpaAdapter implements PendingRegistrationCleanupPort { + private final EntityManager entityManager; + + public PendingRegistrationCleanupJpaAdapter(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @Transactional + public int purgeExpired(Instant cutoff, Instant purgedAt, int limit) { + if (limit < 1) { + throw new IllegalArgumentException("limit must be positive"); + } + @SuppressWarnings("unchecked") + List candidates = entityManager.createNativeQuery(""" + select account.id + from identity.accounts account + where account.lifecycle_status = cast('PENDING_VERIFICATION' as identity.account_lifecycle_status) + and coalesce(( + select max(request.requested_at) + from identity.email_verification_requests request + where request.account_id = account.id + ), account.created_at) <= :cutoff + order by account.created_at, account.id + limit :limit + for update of account skip locked + """) + .setParameter("cutoff", utc(cutoff)) + .setParameter("limit", limit) + .getResultList(); + + int purged = 0; + for (UUID accountId : candidates) { + if (purgeLocked(accountId, cutoff, purgedAt)) { + purged++; + } + } + return purged; + } + + private boolean purgeLocked(UUID accountId, Instant cutoff, Instant purgedAt) { + Object[] account = (Object[]) entityManager.createNativeQuery(""" + select lifecycle_status::text, lifecycle_version, last_lifecycle_event_id, + coalesce(( + select max(request.requested_at) + from identity.email_verification_requests request + where request.account_id = account.id + ), account.created_at) + from identity.accounts account + where id = :accountId + for update + """) + .setParameter("accountId", accountId) + .getSingleResult(); + if (!"PENDING_VERIFICATION".equals(account[0]) || timestamp(account[3]).isAfter(cutoff)) { + return false; + } + + UUID eventId = UUID.randomUUID(); + long nextVersion = ((Number) account[1]).longValue() + 1; + UUID previousEventId = (UUID) account[2]; + OffsetDateTime now = utc(purgedAt); + String idempotencyKey = "pending-registration-expired:" + accountId; + entityManager.createNativeQuery(""" + insert into identity.account_lifecycle_events + (id, account_id, event_sequence, previous_event_id, lifecycle_version, + previous_status, new_status, command_type, actor_type, actor_id, + correlation_id, idempotency_key, request_hash, reason_code, occurred_at) + values (:eventId, :accountId, :version, :previousEventId, :version, + cast('PENDING_VERIFICATION' as identity.account_lifecycle_status), + cast('CLOSED' as identity.account_lifecycle_status), + 'PENDING_REGISTRATION_EXPIRED', 'SYSTEM', null, + :correlationId, :idempotencyKey, + md5(:idempotencyKey) || md5(:idempotencyKey || ':2'), + 'PENDING_REGISTRATION_EXPIRED', :now) + """) + .setParameter("eventId", eventId) + .setParameter("accountId", accountId) + .setParameter("version", nextVersion) + .setParameter("previousEventId", previousEventId) + .setParameter("correlationId", UUID.randomUUID()) + .setParameter("idempotencyKey", idempotencyKey) + .setParameter("now", now) + .executeUpdate(); + entityManager.createNativeQuery(""" + update identity.accounts + set lifecycle_status = cast('CLOSED' as identity.account_lifecycle_status), + status_changed_at = :now, + lifecycle_version = :version, + last_lifecycle_event_id = :eventId, + closed_at = :now + where id = :accountId + and lifecycle_status = cast('PENDING_VERIFICATION' as identity.account_lifecycle_status) + """) + .setParameter("now", now) + .setParameter("version", nextVersion) + .setParameter("eventId", eventId) + .setParameter("accountId", accountId) + .executeUpdate(); + + entityManager.createNativeQuery( + "delete from identity.email_verification_requests where account_id = :accountId") + .setParameter("accountId", accountId) + .executeUpdate(); + entityManager.createNativeQuery(""" + delete from identity.password_credentials credential + using identity.login_identities login + where credential.login_identity_id = login.id and login.account_id = :accountId + """) + .setParameter("accountId", accountId) + .executeUpdate(); + entityManager.createNativeQuery(""" + update identity.login_identities + set status = cast('DISABLED' as identity.login_identity_status), + disabled_at = :now, + disabled_reason_code = 'PENDING_REGISTRATION_EXPIRED' + where account_id = :accountId + and status = cast('PENDING' as identity.login_identity_status) + """) + .setParameter("now", now) + .setParameter("accountId", accountId) + .executeUpdate(); + entityManager.createNativeQuery("delete from identity.account_preferences where account_id = :accountId") + .setParameter("accountId", accountId) + .executeUpdate(); + entityManager.createNativeQuery("delete from identity.account_security_states where account_id = :accountId") + .setParameter("accountId", accountId) + .executeUpdate(); + entityManager.createNativeQuery("delete from identity.account_emails where account_id = :accountId") + .setParameter("accountId", accountId) + .executeUpdate(); + return true; + } + + private static OffsetDateTime utc(Instant instant) { + return instant.atOffset(ZoneOffset.UTC); + } + + private static Instant timestamp(Object value) { + if (value instanceof Instant instant) { + return instant; + } + if (value instanceof OffsetDateTime offsetDateTime) { + return offsetDateTime.toInstant(); + } + throw new IllegalStateException("Unsupported timestamp value: " + value); + } +} diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/identity/IdentityPersistenceIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/identity/IdentityPersistenceIntegrationTest.java index c20c8882..15d39c78 100644 --- a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/identity/IdentityPersistenceIntegrationTest.java +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/identity/IdentityPersistenceIntegrationTest.java @@ -21,14 +21,17 @@ import com.idea2strategy.backend.application.identity.ProtectedOidcSubject; import com.idea2strategy.backend.application.identity.IdentifierFingerprint; import com.idea2strategy.backend.application.identity.RequestPasswordResetCommand; +import com.idea2strategy.backend.application.identity.ResendVerificationCommand; import com.idea2strategy.backend.application.identity.RecoverWithCodeCommand; import com.idea2strategy.backend.application.identity.ResetPasswordCommand; import com.idea2strategy.backend.application.identity.RefreshTokenSecret; import com.idea2strategy.backend.application.identity.SignupCommand; import com.idea2strategy.backend.application.identity.StartOidcLinkCommand; import com.idea2strategy.backend.application.identity.VerificationToken; +import com.idea2strategy.backend.application.identity.VerificationRateLimitedException; import com.idea2strategy.backend.application.identity.VerifyEmailCommand; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; import java.util.List; @@ -74,6 +77,9 @@ static void databaseProperties(DynamicPropertyRegistry registry) { @Autowired private IdentityJpaCommandAdapter commandAdapter; + @Autowired + private PendingRegistrationCleanupJpaAdapter pendingRegistrationCleanup; + @Autowired private JdbcTemplate jdbcTemplate; @@ -130,16 +136,14 @@ void signupVerificationAndLoginShareOneTransactionalIdentityModel() { signup.accountId())) .isEqualTo(1); - var replacement = registration.signup(new SignupCommand( - "person@example.com", "DifferentPass!2026", + var repeated = registration.signup(new SignupCommand( + "person@example.com", "AttackerKnownPass!2026", UUID.randomUUID(), "192.0.2.0/24")); - assertThat(replacement.accountId()).isEqualTo(signup.accountId()); - assertThatThrownBy(() -> registration.verify( - new VerifyEmailCommand(signup.verificationToken(), UUID.randomUUID()))) - .hasMessage("Verification token is no longer valid"); - registration.verify(new VerifyEmailCommand(replacement.verificationToken(), UUID.randomUUID())); + assertThat(repeated.accountId()).isEqualTo(signup.accountId()); + assertThat(repeated.verificationToken()).isNull(); + registration.verify(new VerifyEmailCommand(signup.verificationToken(), UUID.randomUUID())); var login = authenticationService().login(new LoginCommand( - "person@example.com", "DifferentPass!2026", UUID.randomUUID())); + "person@example.com", "ValidPass!2026", UUID.randomUUID())); assertThat(login.accountId()).isEqualTo(signup.accountId()); assertThat(login.refreshTokenSecret()).startsWith("raw-session-token-"); @@ -177,21 +181,161 @@ void signupVerificationAndLoginShareOneTransactionalIdentityModel() { """, String.class, signup.accountId())) - .isEqualTo("hash:DifferentPass!2026"); + .isEqualTo("hash:ValidPass!2026"); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from identity.email_verification_requests where account_id = ?", + Integer.class, + signup.accountId())) + .isEqualTo(1); assertThat(jdbcTemplate.queryForObject( "select count(*) from identity.authentication_events where account_id = ? and event_type = 'LOGIN_FAILED'", Integer.class, signup.accountId())) .isEqualTo(1); - assertThatThrownBy(() -> registration.verify( - new VerifyEmailCommand(replacement.verificationToken(), UUID.randomUUID()))) - .hasMessage("Verification token is no longer valid"); + assertThatThrownBy(() -> authenticationService().login(new LoginCommand( + "person@example.com", "AttackerKnownPass!2026", UUID.randomUUID()))) + .isInstanceOf(AuthenticationRejectedException.class); assertThatThrownBy(() -> registration.signup(new SignupCommand( "person@example.com", "AnotherPass!2026", UUID.randomUUID(), null))) .isInstanceOf(DuplicateEmailException.class); } + @Test + void verificationResendEnforcesCooldownAndFiveRequestsPerAccountPerHour() { + var tokenSequence = new AtomicInteger(); + var signup = registrationService(tokenSequence, NOW).signup(new SignupCommand( + "limited-account@example.com", "ValidPass!2026", UUID.randomUUID(), "198.51.101.0/24")); + + assertThatThrownBy(() -> registrationService(tokenSequence, NOW.plusSeconds(30)) + .resendVerification(new ResendVerificationCommand( + signup.accountId(), UUID.randomUUID(), "198.51.101.0/24"))) + .isInstanceOf(VerificationRateLimitedException.class); + + for (int request = 1; request <= 4; request++) { + var delivery = registrationService(tokenSequence, NOW.plusSeconds(61L * request)) + .resendVerification(new ResendVerificationCommand( + signup.accountId(), UUID.randomUUID(), "198.51.101.0/24")); + assertThat(delivery.verificationToken()).isNotBlank(); + } + + assertThatThrownBy(() -> registrationService(tokenSequence, NOW.plusSeconds(61L * 5)) + .resendVerification(new ResendVerificationCommand( + signup.accountId(), UUID.randomUUID(), "198.51.101.0/24"))) + .isInstanceOf(VerificationRateLimitedException.class); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from identity.email_verification_requests where account_id = ?", + Integer.class, + signup.accountId())) + .isEqualTo(5); + } + + @Test + void signupLimitsVerificationEmailsAcrossAccountsForOneIpPrefix() { + var tokenSequence = new AtomicInteger(); + var registration = registrationService(tokenSequence, raw -> { + String normalized = raw.trim().toLowerCase(); + return new ProtectedEmail( + normalized, + "ciphertext-v2:" + normalized, + "lookup-v2:" + normalized, + (short) 2, + (short) 2, + List.of( + new IdentifierFingerprint("lookup-v2:" + normalized, (short) 2), + new IdentifierFingerprint("lookup:" + normalized, (short) 1))); + }, NOW); + for (int request = 0; request < 20; request++) { + registration.signup(new SignupCommand( + "limited-ip-" + request + "@example.com", + "ValidPass!2026", + UUID.randomUUID(), + "198.51.102.0/24")); + } + + assertThatThrownBy(() -> registration.signup(new SignupCommand( + "limited-ip-overflow@example.com", + "ValidPass!2026", + UUID.randomUUID(), + "198.51.102.0/24"))) + .isInstanceOf(VerificationRateLimitedException.class); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from identity.email_verification_requests where request_ip_prefix = cast(? as inet)", + Integer.class, + "198.51.102.0/24")) + .isEqualTo(20); + } + + @Test + void cleanupPurgesExpiredPendingCredentialsAndIdentifiersButKeepsImmutableEvidence() { + var tokenSequence = new AtomicInteger(); + var oldPending = registrationService(tokenSequence, NOW).signup(new SignupCommand( + "abandoned@example.com", "ValidPass!2026", UUID.randomUUID(), "198.51.103.0/24")); + var freshPending = registrationService(tokenSequence, NOW.plus(Duration.ofDays(1))).signup(new SignupCommand( + "fresh-pending@example.com", "ValidPass!2026", UUID.randomUUID(), "198.51.104.0/24")); + var active = registrationService(tokenSequence, NOW).signup(new SignupCommand( + "active-person@example.com", "ValidPass!2026", UUID.randomUUID(), "198.51.105.0/24")); + registrationService(tokenSequence, NOW.plusSeconds(10)) + .verify(new VerifyEmailCommand(active.verificationToken(), UUID.randomUUID())); + + int purged = pendingRegistrationCleanup.purgeExpired( + NOW, NOW.plus(Duration.ofDays(7)), 100); + + assertThat(purged).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "select lifecycle_status::text from identity.accounts where id = ?", + String.class, + oldPending.accountId())) + .isEqualTo("CLOSED"); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from identity.account_emails where account_id = ?", + Integer.class, + oldPending.accountId())) + .isZero(); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from identity.email_verification_requests where account_id = ?", + Integer.class, + oldPending.accountId())) + .isZero(); + assertThat(jdbcTemplate.queryForObject(""" + select count(*) from identity.password_credentials credential + join identity.login_identities login on login.id = credential.login_identity_id + where login.account_id = ? + """, Integer.class, oldPending.accountId())) + .isZero(); + assertThat(jdbcTemplate.queryForObject( + "select status::text from identity.login_identities where account_id = ?", + String.class, + oldPending.accountId())) + .isEqualTo("DISABLED"); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from identity.authentication_events where account_id = ?", + Integer.class, + oldPending.accountId())) + .isGreaterThanOrEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "select command_type from identity.account_lifecycle_events where account_id = ? order by event_sequence desc limit 1", + String.class, + oldPending.accountId())) + .isEqualTo("PENDING_REGISTRATION_EXPIRED"); + assertThat(jdbcTemplate.queryForObject( + "select lifecycle_status::text from identity.accounts where id = ?", + String.class, + freshPending.accountId())) + .isEqualTo("PENDING_VERIFICATION"); + assertThat(jdbcTemplate.queryForObject( + "select lifecycle_status::text from identity.accounts where id = ?", + String.class, + active.accountId())) + .isEqualTo("ACTIVE"); + + var restarted = registrationService(tokenSequence, NOW.plus(Duration.ofDays(7)).plusSeconds(1)) + .signup(new SignupCommand( + "abandoned@example.com", "NewValidPass!2026", UUID.randomUUID(), "198.51.103.0/24")); + assertThat(restarted.accountId()).isNotEqualTo(oldPending.accountId()); + assertThat(restarted.verificationToken()).isNotBlank(); + } + @Test void stepUpUpdatesAuthenticationFreshnessButEmitsNoLoginEventOrSession() { var registration = registrationService(new AtomicInteger()); @@ -538,6 +682,18 @@ private EmailRegistrationService registrationService(AtomicInteger tokenSequence }); } + private EmailRegistrationService registrationService(AtomicInteger tokenSequence, Instant now) { + return registrationService(tokenSequence, raw -> { + String normalized = raw.trim().toLowerCase(); + return new ProtectedEmail( + normalized, + "ciphertext:" + normalized, + "lookup:" + normalized, + (short) 1, + (short) 1); + }, now); + } + @Test void oidcLinkFailsClosedForMissingActiveBindingKeyVersionAndAcceptsProviderCompleteRing() { jdbcTemplate.update(""" @@ -590,6 +746,11 @@ on conflict (id) do nothing private EmailRegistrationService registrationService( AtomicInteger tokenSequence, Function protector) { + return registrationService(tokenSequence, protector, NOW); + } + + private EmailRegistrationService registrationService( + AtomicInteger tokenSequence, Function protector, Instant now) { return new EmailRegistrationService( queryAdapter, commandAdapter, @@ -601,7 +762,7 @@ private EmailRegistrationService registrationService( return new VerificationToken(raw, "digest:" + raw); }, raw -> "digest:" + raw, - Clock.fixed(NOW, ZoneOffset.UTC)); + Clock.fixed(now, ZoneOffset.UTC)); } private ProtectedOidcSubject protectedExternalSubject() { @@ -625,6 +786,6 @@ private EmailAuthenticationService authenticationService() { @SpringBootConfiguration @EnableAutoConfiguration @EntityScan(basePackageClasses = IdentityAccountJpaEntity.class) - @Import({IdentityJooqQueryAdapter.class, IdentityJpaCommandAdapter.class}) + @Import({IdentityJooqQueryAdapter.class, IdentityJpaCommandAdapter.class, PendingRegistrationCleanupJpaAdapter.class}) static class TestApplication {} }