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 @@ -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,40 @@ 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 signupWithoutVerificationTokenReportsThatVerificationIsNotRequired() {
var registration = mock(EmailRegistrationService.class);
var authentication = mock(EmailAuthenticationService.class);
var delivery = mock(VerificationDeliveryPort.class);
UUID accountId = UUID.randomUUID();
when(registration.signup(org.mockito.ArgumentMatchers.any()))
.thenReturn(new SignupResult(accountId, null, null));
var controller = new IdentityAuthController(registration, authentication, delivery, jwt(), cookies());

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

assertThat(response.getStatusCode().value()).isEqualTo(202);
assertThat(response.getBody().verificationRequired()).isFalse();
assertThat(response.getBody().verificationExpiresAt()).isNull();
verifyNoInteractions(delivery);
}

@Test
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 +105,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
Expand Up @@ -6,7 +6,6 @@

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.idea2strategy.backend.api.identity.AccountVerificationEmailRequested;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.junit.jupiter.api.Test;
Expand All @@ -15,8 +14,6 @@
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.RecordApplicationEvents;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
Expand All @@ -41,7 +38,6 @@
*/
@Testcontainers(disabledWithoutDocker = true)
@SpringBootTest
@RecordApplicationEvents
class ExternalToolDelegatedEditJourneyIntegrationTest {
private static final String EMAIL = "delegated-edit@example.com";
private static final String PASSWORD = "CorrectHorse!2026";
Expand Down Expand Up @@ -69,28 +65,20 @@ static void properties(DynamicPropertyRegistry registry) {

@Autowired WebApplicationContext context;
@Autowired ObjectMapper json;
@Autowired ApplicationEvents events;

@Test
void anExternalToolDelegatesThenPreviewsAndAppliesABasicEdit() throws Exception {
MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build();

mvc.perform(post("/api/v1/auth/signup")
JsonNode signup = json.readTree(mvc.perform(post("/api/v1/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"%s","password":"%s","nickname":"delegator"}
""".formatted(EMAIL, PASSWORD)))
.andExpect(status().isAccepted());
String verificationToken = events.stream(AccountVerificationEmailRequested.class)
.findFirst()
.map(AccountVerificationEmailRequested::verificationToken)
.orElseThrow();
mvc.perform(post("/api/v1/auth/verify-email")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"verificationToken":"%s"}
""".formatted(verificationToken)))
.andExpect(status().isNoContent());
.andExpect(status().isAccepted())
.andReturn().getResponse().getContentAsString());
assertThat(signup.path("verificationRequired").asBoolean()).isFalse();
assertThat(signup.path("verificationExpiresAt").isNull()).isTrue();

String accessToken = json.readTree(mvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
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
Loading