Skip to content
Draft
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 @@ -215,7 +215,8 @@ EmailRegistrationService emailRegistrationService(
HmacVerificationTokens verificationTokens,
Clock identityClock,
@Value("${identity.preferences.default-language:ko}") String defaultLanguage,
@Value("${identity.preferences.default-timezone:America/New_York}") String defaultTimezone) {
@Value("${identity.preferences.default-timezone:America/New_York}") String defaultTimezone,
@Value("${identity.email-verification-required:false}") boolean emailVerificationRequired) {
return new EmailRegistrationService(
queries,
commands,
Expand All @@ -229,6 +230,7 @@ EmailRegistrationService emailRegistrationService(
verificationTokens,
verificationTokens,
new AccountPreferenceDefaults(defaultLanguage, defaultTimezone, ThemePreference.SYSTEM),
emailVerificationRequired,
identityClock);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ public ResponseEntity<SignupResponse> signup(
if (result.verificationToken() != null) {
verificationDelivery.send(result.accountId(), result.verificationToken(), result.expiresAt());
}
return ResponseEntity.accepted().body(new SignupResponse(result.accountId(), true, result.expiresAt()));
return ResponseEntity.accepted().body(new SignupResponse(
result.accountId(), result.verificationToken() != null, result.expiresAt()));
}

@GetMapping("/verify-email")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,13 @@ void rejectsMissingVerificationTokenAsBadRequest() throws Exception {
}

@Test
void signupDeliversVerificationSecretWithoutReturningItInTheApiBody() {
void signupReturnsAnImmediatelyUsableAccountWithoutSendingVerificationEmail() {
var registration = mock(EmailRegistrationService.class);
var authentication = mock(EmailAuthenticationService.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, "raw-verification-secret", expiresAt));
.thenReturn(new SignupResult(accountId, null, null));
var controller = new IdentityAuthController(registration, authentication, delivery, jwt(), cookies());

var response = controller.signup(
Expand All @@ -62,18 +61,19 @@ void signupDeliversVerificationSecretWithoutReturningItInTheApiBody() {
"192.0.2.0/24");

assertThat(response.getStatusCode().value()).isEqualTo(202);
assertThat(response.getBody().toString()).doesNotContain("raw-verification-secret");
verify(delivery).send(accountId, "raw-verification-secret", expiresAt);
assertThat(response.getBody().verificationRequired()).isFalse();
assertThat(response.getBody().verificationExpiresAt()).isNull();
verifyNoInteractions(delivery);
}

@Test
void repeatedPendingSignupDoesNotSendAnotherVerificationEmail() {
void signupResponseReflectsWhetherTheServiceIssuedAVerificationToken() {
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));
.thenReturn(new SignupResult(accountId, "raw-verification-secret", expiresAt));
var controller = new IdentityAuthController(
registration, mock(EmailAuthenticationService.class), delivery, jwt(), cookies());

Expand All @@ -84,7 +84,8 @@ void repeatedPendingSignupDoesNotSendAnotherVerificationEmail() {

assertThat(response.getStatusCode().value()).isEqualTo(202);
assertThat(response.getBody().accountId()).isEqualTo(accountId);
verifyNoInteractions(delivery);
assertThat(response.getBody().verificationRequired()).isTrue();
verify(delivery).send(accountId, "raw-verification-secret", expiresAt);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.idea2strategy.backend.application.identity;

import com.idea2strategy.backend.domain.identity.AccountPreferences;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;

public record ActiveEmailRegistration(
UUID accountId,
UUID loginIdentityId,
ProtectedEmail email,
PasswordHash password,
Instant registeredAt,
UUID correlationId,
AccountPreferences preferences) {
public ActiveEmailRegistration {
Objects.requireNonNull(accountId, "accountId");
Objects.requireNonNull(loginIdentityId, "loginIdentityId");
Objects.requireNonNull(email, "email");
Objects.requireNonNull(password, "password");
Objects.requireNonNull(registeredAt, "registeredAt");
Objects.requireNonNull(correlationId, "correlationId");
Objects.requireNonNull(preferences, "preferences");
}

@Override
public String toString() {
return "ActiveEmailRegistration[accountId=" + accountId + ", protected=REDACTED]";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public final class EmailRegistrationService {
private final VerificationTokenIssuer tokenIssuer;
private final VerificationTokenDigest tokenDigest;
private final AccountPreferenceDefaults preferenceDefaults;
private final boolean verificationRequired;
private final Clock clock;

public EmailRegistrationService(
Expand All @@ -38,6 +39,53 @@ public EmailRegistrationService(
tokenIssuer,
tokenDigest,
new AccountPreferenceDefaults("ko", "America/New_York", ThemePreference.SYSTEM),
true,
clock);
}

public EmailRegistrationService(
RegistrationQueryPort queryPort,
RegistrationCommandPort commandPort,
EmailProtector emailProtector,
PasswordPolicy passwordPolicy,
PasswordHasher passwordHasher,
VerificationTokenIssuer tokenIssuer,
VerificationTokenDigest tokenDigest,
boolean verificationRequired,
Clock clock) {
this(
queryPort,
commandPort,
emailProtector,
passwordPolicy,
passwordHasher,
tokenIssuer,
tokenDigest,
new AccountPreferenceDefaults("ko", "America/New_York", ThemePreference.SYSTEM),
verificationRequired,
clock);
}

public EmailRegistrationService(
RegistrationQueryPort queryPort,
RegistrationCommandPort commandPort,
EmailProtector emailProtector,
PasswordPolicy passwordPolicy,
PasswordHasher passwordHasher,
VerificationTokenIssuer tokenIssuer,
VerificationTokenDigest tokenDigest,
AccountPreferenceDefaults preferenceDefaults,
Clock clock) {
this(
queryPort,
commandPort,
emailProtector,
passwordPolicy,
passwordHasher,
tokenIssuer,
tokenDigest,
preferenceDefaults,
true,
clock);
}

Expand All @@ -50,6 +98,7 @@ public EmailRegistrationService(
VerificationTokenIssuer tokenIssuer,
VerificationTokenDigest tokenDigest,
AccountPreferenceDefaults preferenceDefaults,
boolean verificationRequired,
Clock clock) {
this.queryPort = Objects.requireNonNull(queryPort, "queryPort");
this.commandPort = Objects.requireNonNull(commandPort, "commandPort");
Expand All @@ -59,6 +108,7 @@ public EmailRegistrationService(
this.tokenIssuer = Objects.requireNonNull(tokenIssuer, "tokenIssuer");
this.tokenDigest = Objects.requireNonNull(tokenDigest, "tokenDigest");
this.preferenceDefaults = Objects.requireNonNull(preferenceDefaults, "preferenceDefaults");
this.verificationRequired = verificationRequired;
this.clock = Objects.requireNonNull(clock, "clock");
}

Expand All @@ -69,44 +119,54 @@ public SignupResult signup(SignupCommand command) {
validateEmail(email.normalized());
var existing = queryPort.findEmailRegistration(email.comparisonFingerprints());
if (existing.isPresent()) {
return continuePendingRegistration(existing.orElseThrow());
return continueExistingRegistration(existing.orElseThrow(), command.correlationId());
}
if (queryPort.emailExists(email.lookupHmac())) {
throw new DuplicateEmailException();
}

var now = clock.instant();
var expiresAt = now.plus(VERIFICATION_LIFETIME);
VerificationToken token = tokenIssuer.issue();
UUID accountId = UUID.randomUUID();
try {
commandPort.createPending(new PendingRegistration(
accountId,
UUID.randomUUID(),
UUID.randomUUID(),
email,
passwordHasher.hash(command.password()),
token.digest(),
now,
expiresAt,
command.correlationId(),
command.requestIpPrefix(),
preferenceDefaults.at(now)));
if (verificationRequired) {
var expiresAt = now.plus(VERIFICATION_LIFETIME);
VerificationToken token = tokenIssuer.issue();
commandPort.createPending(new PendingRegistration(
accountId,
UUID.randomUUID(),
UUID.randomUUID(),
email,
passwordHasher.hash(command.password()),
token.digest(),
now,
expiresAt,
command.correlationId(),
command.requestIpPrefix(),
preferenceDefaults.at(now)));
return new SignupResult(accountId, token.rawToken(), expiresAt);
}
commandPort.createActive(new ActiveEmailRegistration(
accountId, UUID.randomUUID(), email, passwordHasher.hash(command.password()),
now, command.correlationId(), preferenceDefaults.at(now)));
} catch (DuplicateEmailException duplicate) {
var racedRegistration = queryPort.findEmailRegistration(email.comparisonFingerprints());
if (racedRegistration.isPresent()) {
return continuePendingRegistration(racedRegistration.orElseThrow());
return continueExistingRegistration(racedRegistration.orElseThrow(), command.correlationId());
}
throw duplicate;
}
return new SignupResult(accountId, token.rawToken(), expiresAt);
return new SignupResult(accountId, null, null);
}

private SignupResult continuePendingRegistration(ExistingEmailRegistration existing) {
private SignupResult continueExistingRegistration(ExistingEmailRegistration existing, UUID correlationId) {
if (!existing.awaitingVerification()) {
throw new DuplicateEmailException();
}
return new SignupResult(existing.accountId(), null, existing.verificationExpiresAt());
if (verificationRequired) {
return new SignupResult(existing.accountId(), null, existing.verificationExpiresAt());
}
commandPort.activatePending(existing.accountId(), clock.instant(), correlationId);
return new SignupResult(existing.accountId(), null, null);
}

public void verify(VerifyEmailCommand command) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
import java.util.UUID;

public interface RegistrationCommandPort {
void createActive(ActiveEmailRegistration registration);

void activatePending(UUID accountId, Instant activatedAt, UUID correlationId);

void createPending(PendingRegistration registration);

VerificationOutcome consumeVerification(String tokenDigest, Instant consumedAt, UUID correlationId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,20 @@ class EmailRegistrationServiceTest {
private static final Instant NOW = Instant.parse("2026-08-01T12:00:00Z");

@Test
void signupStoresOnlyProtectedEmailPasswordHashAndTokenDigest() {
void signupCreatesAnActiveAccountWithoutIssuingAVerificationToken() {
var commands = new RecordingRegistrationPort();
var service = service(false, commands);

SignupResult result = service.signup(new SignupCommand(
" Person@Example.com ", "ValidPass!2026", UUID.randomUUID(), "192.0.2.0/24"));

assertThat(result.verificationToken()).isEqualTo("raw-verification-token");
assertThat(commands.registrations).singleElement().satisfies(registration -> {
assertThat(result.verificationToken()).isNull();
assertThat(result.expiresAt()).isNull();
assertThat(commands.activeRegistrations).singleElement().satisfies(registration -> {
assertThat(registration.email().normalized()).isEqualTo("person@example.com");
assertThat(registration.email().ciphertext()).isEqualTo("ciphertext:person@example.com");
assertThat(registration.email().lookupHmac()).isEqualTo("lookup:person@example.com");
assertThat(registration.password().encodedHash()).isEqualTo("hash:ValidPass!2026");
assertThat(registration.verificationTokenDigest()).isEqualTo("digest:raw-verification-token");
assertThat(registration.preferences().languageCode()).isEqualTo("ko");
assertThat(registration.preferences().timezoneName()).isEqualTo("America/New_York");
assertThat(registration.preferences().themePreference()).isEqualTo(ThemePreference.SYSTEM);
Expand All @@ -48,7 +48,7 @@ void signupCarriesConfiguredValidatedPreferenceDefaultsIntoTheAtomicRegistration
service.signup(new SignupCommand(
"person@example.com", "ValidPass!2026", UUID.randomUUID(), null));

assertThat(commands.registrations).singleElement().satisfies(registration -> {
assertThat(commands.activeRegistrations).singleElement().satisfies(registration -> {
assertThat(registration.preferences().languageCode()).isEqualTo("en");
assertThat(registration.preferences().timezoneName()).isEqualTo("America/Chicago");
assertThat(registration.preferences().themePreference()).isEqualTo(ThemePreference.SYSTEM);
Expand All @@ -68,7 +68,7 @@ void duplicateEmailAndShortPasswordAreRejected() {
}

@Test
void pendingEmailSignupKeepsTheOriginalPasswordAndVerificationToken() {
void pendingEmailSignupActivatesTheExistingAccountWithoutReplacingItsPassword() {
UUID accountId = UUID.randomUUID();
var commands = new RecordingRegistrationPort();
var queries = new RegistrationQueryPort() {
Expand Down Expand Up @@ -96,8 +96,11 @@ public Optional<ExistingEmailRegistration> findEmailRegistration(

assertThat(result.accountId()).isEqualTo(accountId);
assertThat(result.verificationToken()).isNull();
assertThat(result.expiresAt()).isNull();
assertThat(commands.registrations).isEmpty();
assertThat(commands.activeRegistrations).isEmpty();
assertThat(commands.replacements).isEmpty();
assertThat(commands.activatedAccounts).containsExactly(accountId);
}

@Test
Expand Down Expand Up @@ -189,14 +192,27 @@ private static EmailRegistrationService service(
() -> new VerificationToken("raw-verification-token", "digest:raw-verification-token"),
raw -> "digest:" + raw,
defaults,
false,
Clock.fixed(NOW, ZoneOffset.UTC));
}

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

@Override
public void createActive(ActiveEmailRegistration registration) {
activeRegistrations.add(registration);
}

@Override
public void activatePending(UUID accountId, Instant activatedAt, UUID correlationId) {
activatedAccounts.add(accountId);
}

@Override
public void createPending(PendingRegistration registration) {
registrations.add(registration);
Expand Down
Loading
Loading