diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/CustomerId.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/CustomerId.java new file mode 100644 index 0000000..aa00943 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/CustomerId.java @@ -0,0 +1,40 @@ +package dev.amg.payguard.identity.customer.domain.value_object; + +import domain.ValueObject; +import org.jspecify.annotations.Nullable; +import org.springframework.modulith.NamedInterface; + +@NamedInterface +public final class CustomerId implements ValueObject { + private static final String CUSTOMER_ID_PREFIX = "cus_"; + private final String value; + + private CustomerId(String value) { + this.value = PrefixedIdValidator.validateAndReturn(value, CUSTOMER_ID_PREFIX); + } + + public static CustomerId parse(String value) { + return new CustomerId(value); + } + + @Override + public boolean equals(@Nullable Object o) { + if (!(o instanceof CustomerId that)) return false; + + return getValue().equals(that.getValue()); + } + + @Override + public int hashCode() { + return getValue().hashCode(); + } + + public String getValue() { + return value; + } + + @Override + public boolean sameValueAs(@Nullable ValueObject otherCustomerId) { + return equals(otherCustomerId); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpId.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpId.java new file mode 100644 index 0000000..e4158a1 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpId.java @@ -0,0 +1,43 @@ +package dev.amg.payguard.identity.customer.domain.value_object; + +import domain.ValueObject; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** Immutable identifier. Parsing validateAndReturns syntax, not existence or authorization. */ +public final class OtpId implements ValueObject { + private static final String OTP_ID_PREFIX = "otp_"; + private final String value; + + private OtpId(String value) { + this.value = PrefixedIdValidator.validateAndReturn(value, OTP_ID_PREFIX); + } + + public static OtpId parse(String value) { + return new OtpId(value); + } + + public String value() { + return value; + } + + @Override + public boolean equals(@Nullable Object other) { + return this == other || (other instanceof OtpId that && value.equals(that.value)); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } + + @Override + public String toString() { + return value; + } + + @Override + public boolean sameValueAs(@Nullable ValueObject otherOtpId) { + return equals(otherOtpId); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/PrefixedIdValidator.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/PrefixedIdValidator.java new file mode 100644 index 0000000..889fe0b --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/PrefixedIdValidator.java @@ -0,0 +1,33 @@ +package dev.amg.payguard.identity.customer.domain.value_object; + +import java.util.Objects; + +final class PrefixedIdValidator { + private static final int RANDOM_SUFFIX_LENGTH = 22; + private static final String BASE62_ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + private PrefixedIdValidator() {} + + static String validateAndReturn(String identifier, String expectedPrefix) { + Objects.requireNonNull(identifier); + Objects.requireNonNull(expectedPrefix); + + if (identifier.length() != (expectedPrefix.length() + RANDOM_SUFFIX_LENGTH) + || !identifier.startsWith(expectedPrefix)) { + throw new IllegalArgumentException( + "invalid identifier format: expected " + + expectedPrefix + + " followed by 22 Base62 characters"); + } + + for (int index = expectedPrefix.length(); index < identifier.length(); index++) { + char character = identifier.charAt(index); + boolean isBase62Character = BASE62_ALPHABET.indexOf(character) >= 0; + if (!isBase62Character) { + throw new IllegalArgumentException("invalid identifier suffix: expected Base62 characters"); + } + } + return identifier; + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/package-info.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/package-info.java new file mode 100644 index 0000000..b00965e --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.amg.payguard.identity.customer.domain.value_object; + +import org.jspecify.annotations.NullMarked; diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpChannel.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpChannel.java new file mode 100644 index 0000000..9aa029d --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpChannel.java @@ -0,0 +1,8 @@ +package dev.amg.payguard.identity.verification.domain.enums; + +public enum OtpChannel { + SMS, + EMAIL, + PUSH, + AUTHENTICATOR_APP +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpPurpose.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpPurpose.java new file mode 100644 index 0000000..b0ea856 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpPurpose.java @@ -0,0 +1,10 @@ +package dev.amg.payguard.identity.verification.domain.enums; + +public enum OtpPurpose { + LOGIN, + TRANSFER_CONFIRM, + LOAN_DISBURSE, + COLLATERAL_RELEASE, + CARD_ISSUANCE, + PROFILE_CHANGE +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpStatus.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpStatus.java new file mode 100644 index 0000000..f396626 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpStatus.java @@ -0,0 +1,9 @@ +package dev.amg.payguard.identity.verification.domain.enums; + +public enum OtpStatus { + ACTIVE, + USED, + EXPIRED, + LOCKED, + INVALIDATED +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationMode.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationMode.java new file mode 100644 index 0000000..cc6b5a5 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationMode.java @@ -0,0 +1,6 @@ +package dev.amg.payguard.identity.verification.domain.enums; + +public enum OtpVerificationMode { + RANDOM_NUMERIC, + TOTP +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationResult.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationResult.java new file mode 100644 index 0000000..17f8e57 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationResult.java @@ -0,0 +1,12 @@ +package dev.amg.payguard.identity.verification.domain.enums; + +public enum OtpVerificationResult { + VERIFIED, + INVALID_CODE, + EXPIRED, + ALREADY_USED, + LOCKED, + INVALIDATED, + PURPOSE_MISMATCH, + DEVICE_MISMATCH +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEvent.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEvent.java new file mode 100644 index 0000000..b4f1df8 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEvent.java @@ -0,0 +1,18 @@ +package dev.amg.payguard.identity.verification.domain.event; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; +import java.time.Instant; + +public record OtpEvent( + String type, + OtpId challengeId, + CustomerId customerId, + OtpPurpose purpose, + OtpChannel channel, + OtpVerificationResult result, + Instant occurredAt, + String reason) {} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEventPublisher.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEventPublisher.java new file mode 100644 index 0000000..85f01a1 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEventPublisher.java @@ -0,0 +1,6 @@ +package dev.amg.payguard.identity.verification.domain.event; + +@FunctionalInterface +public interface OtpEventPublisher { + void publish(OtpEvent event); +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/InvalidOtpAttemptLimitException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/InvalidOtpAttemptLimitException.java new file mode 100644 index 0000000..95eb3cf --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/InvalidOtpAttemptLimitException.java @@ -0,0 +1,14 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; + +public final class InvalidOtpAttemptLimitException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + private static final String MESSAGE = + "OTP max attempts must be greater than or equal to %d, but was %d."; + + public InvalidOtpAttemptLimitException(int maxAttempts, int minAttempts) { + super(MESSAGE.formatted(maxAttempts, minAttempts)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpAttemptCountOutOfRangeException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpAttemptCountOutOfRangeException.java new file mode 100644 index 0000000..16791d8 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpAttemptCountOutOfRangeException.java @@ -0,0 +1,13 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; + +public final class OtpAttemptCountOutOfRangeException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + public static final String MESSAGE = "OTP attempts must be between 0 and %d, but was %d."; + + public OtpAttemptCountOutOfRangeException(int attempts, int maxAttempts) { + super(MESSAGE.formatted(maxAttempts, attempts)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashMustNotBeBlankException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashMustNotBeBlankException.java new file mode 100644 index 0000000..520eed8 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashMustNotBeBlankException.java @@ -0,0 +1,13 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; + +public final class OtpCodeHashMustNotBeBlankException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + public static final String MESSAGE = "OTP code hash must not be blank."; + + public OtpCodeHashMustNotBeBlankException() { + super(MESSAGE); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashRequiredForRandomNumericVerificationException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashRequiredForRandomNumericVerificationException.java new file mode 100644 index 0000000..1c4a65e --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashRequiredForRandomNumericVerificationException.java @@ -0,0 +1,16 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; +import java.io.Serial; + +public final class OtpCodeHashRequiredForRandomNumericVerificationException + extends RuntimeException { + @Serial private static final long serialVersionUID = 1L; + + public static final String MESSAGE = "OTP code hash is required for %s verification mode."; + + public OtpCodeHashRequiredForRandomNumericVerificationException( + OtpVerificationMode verificationMode) { + super(MESSAGE.formatted(verificationMode)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeMustMatchUsedStatusException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeMustMatchUsedStatusException.java new file mode 100644 index 0000000..f3e7d89 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeMustMatchUsedStatusException.java @@ -0,0 +1,18 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; +import java.time.Instant; + +public final class OtpConsumedTimeMustMatchUsedStatusException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + public static final String MESSAGE = + """ + OTP consumption time must be within the challenge validity period. \ + consumedAt=%s, createdAt=%s, expiresAt=%s."""; + + public OtpConsumedTimeMustMatchUsedStatusException( + Instant consumedAt, Instant createdAt, Instant expiresAt) { + super(MESSAGE.formatted(consumedAt, createdAt, expiresAt)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeOutsideValidityPeriodException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeOutsideValidityPeriodException.java new file mode 100644 index 0000000..0e8a8f4 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeOutsideValidityPeriodException.java @@ -0,0 +1,18 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; +import java.time.Instant; + +public final class OtpConsumedTimeOutsideValidityPeriodException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + public static final String MESSAGE = + """ + OTP consumption time must be within the validity period. \ + consumedAt=%s, createdAt=%s, expiresAt=%s"""; + + public OtpConsumedTimeOutsideValidityPeriodException( + Instant consumedAt, Instant createdAt, Instant expiresAt) { + super(MESSAGE.formatted(consumedAt, createdAt, expiresAt)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpDeviceBindingHashMustNotBeBlankException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpDeviceBindingHashMustNotBeBlankException.java new file mode 100644 index 0000000..573e48e --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpDeviceBindingHashMustNotBeBlankException.java @@ -0,0 +1,13 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; + +public final class OtpDeviceBindingHashMustNotBeBlankException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + public static final String MESSAGE = "OTP device binding hash must not be blank."; + + public OtpDeviceBindingHashMustNotBeBlankException() { + super(MESSAGE); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpExpirationTimeMustBeAfterCreationTimeException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpExpirationTimeMustBeAfterCreationTimeException.java new file mode 100644 index 0000000..99dfbd8 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpExpirationTimeMustBeAfterCreationTimeException.java @@ -0,0 +1,13 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; + +public final class OtpExpirationTimeMustBeAfterCreationTimeException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + private static final String MESSAGE = "OTP expiration time must be after the creation time."; + + public OtpExpirationTimeMustBeAfterCreationTimeException() { + super(MESSAGE); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpRetentionPeriodMustNotPrecedeExpirationException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpRetentionPeriodMustNotPrecedeExpirationException.java new file mode 100644 index 0000000..ef4a593 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpRetentionPeriodMustNotPrecedeExpirationException.java @@ -0,0 +1,19 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; +import java.time.Instant; + +public class OtpRetentionPeriodMustNotPrecedeExpirationException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + + public static final String MESSAGE = + "OTP retention period is invalid: retentionUntil must be after expiresAt. " + + "The OTP record cannot be scheduled for removal before or at the same time as its expiration. " + + "retentionUntil=%s, expiresAt=%s"; + + public OtpRetentionPeriodMustNotPrecedeExpirationException( + Instant retentionUntil, Instant expiresAt) { + super(MESSAGE.formatted(retentionUntil, expiresAt)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java new file mode 100644 index 0000000..9998b96 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java @@ -0,0 +1,154 @@ +package dev.amg.payguard.identity.verification.domain.model; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; +import dev.amg.payguard.identity.verification.domain.policy.OtpVerificationPolicy; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; +import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; +import domain.AggregateRoot; +import domain.DomainEvent; +import java.util.Objects; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** + * An OTP challenge associated with a PayGuard customer. + * + *

Owns the challenge's verification and invalidation behavior. State changes replace the current + * {@link OtpSnapshot} without modifying previously created snapshots. + * + *

Issuance and restoration

+ * + *

Issuance requires an ACTIVE snapshot with zero recorded attempts and no consumption time. + * Restoration accepts an existing validated snapshot without resetting its status, attempt count, + * or timestamps. + * + *

Verification

+ * + *

The verification policy checks for rejection before the code is checked. Rejected requests do + * not increase the attempt count. If the policy reports expiration for an ACTIVE challenge, its + * status changes to EXPIRED. + * + *

A failed code check records one attempt. The last allowed failed attempt also changes the + * status to LOCKED in the same new snapshot. + * + *

A successful code check records one attempt, changes the status to USED, and sets the + * consumption time to the time supplied by the command. A correct code is therefore accepted on the + * last allowed attempt. + * + *

For TOTP verification, this aggregate uses the verification result supplied by the command; it + * does not validate the TOTP itself. For other verification modes, it compares the submitted code + * with the stored hash through {@link + * dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher}. + * + *

Invalidation

+ * + *

Invalidation changes an ACTIVE challenge to INVALIDATED. Challenges in other states remain + * unchanged. + * + *

Responsibility

+ * + *

A VERIFIED result reports successful completion of this challenge. It does not establish + * permission to access a wallet or execute a financial operation. The consuming application must + * enforce those permissions and associate the result with the intended operation. + * + * @implSpec This aggregate is mutable and does not synchronize its operations. Concurrent mutations + * and persistence updates require coordination outside this class. Replacing an immutable + * snapshot does not make verification thread-safe. + */ +public final class OTP extends AggregateRoot { + + private final CustomerId customerId; + private OtpSnapshot snapshot; + + private OTP(OtpId id, CustomerId customerId, OtpSnapshot snapshot) { + super(Objects.requireNonNull(id, "id must not be null")); + this.customerId = Objects.requireNonNull(customerId, "customerID must not be null"); + this.snapshot = Objects.requireNonNull(snapshot, "snapshot must not be null"); + } + + public static OTP issue(OtpId id, CustomerId customerId, OtpSnapshot snapshot) { + Objects.requireNonNull(snapshot, "snapshot must not be null"); + Objects.requireNonNull(id, "id must not be null"); + Objects.requireNonNull(customerId, "customerId must not be null"); + + if (snapshot.status() != OtpStatus.ACTIVE || snapshot.attempts() != 0) { + throw new IllegalArgumentException("issuance requires an initial snapshot"); + } + + return new OTP(id, customerId, snapshot); + } + + public static OTP restore(OtpId id, CustomerId customerId, OtpSnapshot snapshot) { + return new OTP(id, customerId, snapshot); + } + + private static boolean isCodeValid( + VerifyOtpCommand command, OtpCodeHasher hasher, OtpSnapshot snapshot) { + if (snapshot.verificationMode() == OtpVerificationMode.TOTP) { + return command.authenticatorCodeValid(); + } + + return snapshot.codeHash() != null && hasher.matches(command.code(), snapshot.codeHash()); + } + + public OtpVerificationResult verify(VerifyOtpCommand command, OtpCodeHasher hasher) { + Optional rejection = + OtpVerificationPolicy.findRejection(command, snapshot, hasher); + + if (rejection.isPresent()) { + OtpVerificationResult result = rejection.get(); + if (result == OtpVerificationResult.EXPIRED && snapshot.status() == OtpStatus.ACTIVE) { + this.snapshot = this.snapshot.withStatus(OtpStatus.EXPIRED); + } + return result; + } + boolean codeMatches = isCodeValid(command, hasher, snapshot); + + if (!codeMatches) { + final boolean lastAllowedAttempt = snapshot.attempts() + 1 == snapshot.maxAttempts(); + + this.snapshot = + lastAllowedAttempt ? snapshot.withFailedAttemptAndLock() : snapshot.withAttemptRecorded(); + + return lastAllowedAttempt ? OtpVerificationResult.LOCKED : OtpVerificationResult.INVALID_CODE; + } + + this.snapshot = snapshot.withSuccessfulAttempt(command.now()); + return OtpVerificationResult.VERIFIED; + } + + public void invalidate() { + if (snapshot.status() == OtpStatus.ACTIVE) { + this.snapshot = this.snapshot.withStatus(OtpStatus.INVALIDATED); + } + } + + public CustomerId getCustomerId() { + return customerId; + } + + public OtpSnapshot getSnapshot() { + return snapshot; + } + + @Override + public boolean equals(@Nullable Object o) { + if (!(o instanceof OTP otp)) return false; + if (!super.equals(o)) return false; + + return Objects.equals(getCustomerId(), otp.getCustomerId()) + && Objects.equals(getSnapshot(), otp.getSnapshot()); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + Objects.hashCode(getCustomerId()); + result = 31 * result + Objects.hashCode(getSnapshot()); + return result; + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OtpSnapshot.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OtpSnapshot.java new file mode 100644 index 0000000..2132a17 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OtpSnapshot.java @@ -0,0 +1,334 @@ +package dev.amg.payguard.identity.verification.domain.model; + +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; +import dev.amg.payguard.identity.verification.domain.exception.InvalidOtpAttemptLimitException; +import dev.amg.payguard.identity.verification.domain.exception.OtpAttemptCountOutOfRangeException; +import dev.amg.payguard.identity.verification.domain.exception.OtpCodeHashMustNotBeBlankException; +import dev.amg.payguard.identity.verification.domain.exception.OtpCodeHashRequiredForRandomNumericVerificationException; +import dev.amg.payguard.identity.verification.domain.exception.OtpConsumedTimeMustMatchUsedStatusException; +import dev.amg.payguard.identity.verification.domain.exception.OtpConsumedTimeOutsideValidityPeriodException; +import dev.amg.payguard.identity.verification.domain.exception.OtpDeviceBindingHashMustNotBeBlankException; +import dev.amg.payguard.identity.verification.domain.exception.OtpExpirationTimeMustBeAfterCreationTimeException; +import dev.amg.payguard.identity.verification.domain.policy.OtpRetentionPolicy; +import java.time.Instant; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * An immutable snapshot of an OTP challenge's state. + * + *

Contains the verification settings, attempt count, status, and lifecycle timestamps. Changes + * produce a new validated snapshot without modifying the existing one. + * + *

Attempt limits

+ * + *

The maximum number of attempts must be positive. The recorded attempt count must be between + * zero and that maximum, inclusive. An ACTIVE snapshot must have at least one attempt remaining. + * + *

Both successful and failed code checks count as attempts. Recording the last failed attempt + * and setting the status to LOCKED must happen in the same new snapshot to avoid an invalid + * intermediate state. + * + *

Verification data

+ * + *

RANDOM_NUMERIC verification requires a nonblank code hash. The device-binding hash is + * optional. Neither hash may be blank when present. + * + *

Validity and consumption

+ * + *

Expiration must be strictly after creation. A consumption time must be present exactly when + * the status is USED. + * + *

Consumption is allowed at creation time, but not at or after expiration: {@code createdAt <= + * consumedAt < expiresAt}. + * + *

Retention constraints are checked by {@link OtpRetentionPolicy}. Retention does not extend the + * challenge's verification validity. + * + *

Responsibility

+ * + *

This record validates the resulting state. The OTP aggregate decides whether a requested state + * transition is allowed. Constructing a valid snapshot does not prove that an OTP code was + * verified. + * + * @implSpec Every constructor call validates the supplied state. The factory and builder use the + * same constructor validation. All components are immutable. + */ +public record OtpSnapshot( + OtpPurpose purpose, + OtpChannel channel, + OtpVerificationMode verificationMode, + @Nullable String codeHash, + @Nullable String deviceBindingHash, + int maxAttempts, + Instant createdAt, + Instant expiresAt, + Instant retentionUntil, + int attempts, + OtpStatus status, + @Nullable Instant consumedAt) { + private static final int MIN_ATTEMPTS = 1; + + public OtpSnapshot { + validateRequiredFields( + purpose, channel, verificationMode, createdAt, expiresAt, retentionUntil, status); + validateAttempts(status, attempts, maxAttempts); + validateVerificationData(verificationMode, codeHash, deviceBindingHash); + validateLifecycle(status, createdAt, expiresAt, consumedAt); + OtpRetentionPolicy.validate(expiresAt, retentionUntil); + } + + /** + * Creates a validated snapshot. The supplied state does not have to represent a newly issued OTP. + */ + public static OtpSnapshot create( + OtpPurpose purpose, + OtpChannel channel, + OtpVerificationMode verificationMode, + @Nullable String codeHash, + @Nullable String deviceBindingHash, + int maxAttempts, + Instant createdAt, + Instant expiresAt, + Instant retentionUntil, + int attempts, + OtpStatus status, + @Nullable Instant consumedAt) { + return new OtpSnapshot( + purpose, + channel, + verificationMode, + codeHash, + deviceBindingHash, + maxAttempts, + createdAt, + expiresAt, + retentionUntil, + attempts, + status, + consumedAt); + } + + private static void validateRequiredFields( + OtpPurpose purpose, + OtpChannel channel, + OtpVerificationMode verificationMode, + Instant createdAt, + Instant expiresAt, + Instant retentionUntil, + OtpStatus status) { + + Objects.requireNonNull(purpose, "purpose must not be null"); + Objects.requireNonNull(channel, "channel must not be null"); + Objects.requireNonNull(verificationMode, "verificationMode must not be null"); + Objects.requireNonNull(createdAt, "createdAt must not be null"); + Objects.requireNonNull(expiresAt, "expiresAt must not be null"); + Objects.requireNonNull(retentionUntil, "retentionUntil must not be null"); + Objects.requireNonNull(status, "status must not be null"); + } + + private static void validateAttempts(OtpStatus status, int attempts, int maxAttempts) { + if (maxAttempts < MIN_ATTEMPTS) { + throw new InvalidOtpAttemptLimitException(maxAttempts, MIN_ATTEMPTS); + } + + if (attempts < 0 || attempts > maxAttempts) { + throw new OtpAttemptCountOutOfRangeException(attempts, maxAttempts); + } + /* An ACTIVE OTP must have at least one attempt remaining. */ + if (status == OtpStatus.ACTIVE && attempts == maxAttempts) { + throw new OtpAttemptCountOutOfRangeException(attempts, maxAttempts); + } + } + + private static void validateVerificationData( + OtpVerificationMode verificationMode, + @Nullable String codeHash, + @Nullable String deviceBindingHash) { + if (verificationMode == OtpVerificationMode.RANDOM_NUMERIC + && (codeHash == null || codeHash.isBlank())) { + throw new OtpCodeHashRequiredForRandomNumericVerificationException(verificationMode); + } + + if (codeHash != null && codeHash.isBlank()) { + throw new OtpCodeHashMustNotBeBlankException(); + } + + /* Null means no device binding; a supplied hash must not be blank. */ + if (deviceBindingHash != null && deviceBindingHash.isBlank()) { + throw new OtpDeviceBindingHashMustNotBeBlankException(); + } + } + + private static void validateLifecycle( + OtpStatus status, Instant createdAt, Instant expiresAt, @Nullable Instant consumedAt) { + if (!expiresAt.isAfter(createdAt)) { + throw new OtpExpirationTimeMustBeAfterCreationTimeException(); + } + + final boolean isUsed = status == OtpStatus.USED; + final boolean hasConsumedAt = consumedAt != null; + + if (isUsed != hasConsumedAt) { + throw new OtpConsumedTimeMustMatchUsedStatusException(consumedAt, createdAt, expiresAt); + } + if (consumedAt != null && (consumedAt.isBefore(createdAt) || !consumedAt.isBefore(expiresAt))) { + throw new OtpConsumedTimeOutsideValidityPeriodException(consumedAt, createdAt, expiresAt); + } + } + + /* Fluent builder methods deliberately match their state fields. */ + @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName") + static final class Builder { + + private OtpPurpose purpose; + private OtpChannel channel; + private OtpVerificationMode verificationMode; + @Nullable private String codeHash; + @Nullable private String deviceBindingHash; + private Instant createdAt; + private Instant expiresAt; + private Instant retentionUntil; + private OtpStatus status; + @Nullable private Instant consumedAt; + private int maxAttempts; + private int attempts; + + private Builder(OtpSnapshot snapshot) { + this.purpose = snapshot.purpose(); + this.channel = snapshot.channel(); + this.verificationMode = snapshot.verificationMode(); + this.codeHash = snapshot.codeHash(); + this.deviceBindingHash = snapshot.deviceBindingHash(); + this.createdAt = snapshot.createdAt(); + this.expiresAt = snapshot.expiresAt(); + this.retentionUntil = snapshot.retentionUntil(); + this.status = snapshot.status(); + this.consumedAt = snapshot.consumedAt(); + this.maxAttempts = snapshot.maxAttempts(); + this.attempts = snapshot.attempts(); + } + + Builder purpose(OtpPurpose purpose) { + this.purpose = purpose; + return this; + } + + Builder channel(OtpChannel channel) { + this.channel = channel; + return this; + } + + Builder verificationMode(OtpVerificationMode mode) { + this.verificationMode = mode; + return this; + } + + Builder codeHash(@Nullable String codeHash) { + this.codeHash = codeHash; + return this; + } + + Builder deviceBindingHash(@Nullable String hash) { + this.deviceBindingHash = hash; + return this; + } + + Builder maxAttempts(int maxAttempts) { + this.maxAttempts = maxAttempts; + return this; + } + + Builder createdAt(Instant createdAt) { + this.createdAt = createdAt; + return this; + } + + Builder expiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + return this; + } + + Builder retentionUntil(Instant retentionUntil) { + this.retentionUntil = retentionUntil; + return this; + } + + Builder attempts(int attempts) { + this.attempts = attempts; + return this; + } + + Builder status(OtpStatus status) { + this.status = status; + return this; + } + + Builder consumedAt(@Nullable Instant consumedAt) { + this.consumedAt = consumedAt; + return this; + } + + OtpSnapshot build() { + return new OtpSnapshot( + purpose, + channel, + verificationMode, + codeHash, + deviceBindingHash, + maxAttempts, + createdAt, + expiresAt, + retentionUntil, + attempts, + status, + consumedAt); + } + } + + OtpSnapshot withStatus(OtpStatus newStatus) { + return toBuilder().status(newStatus).build(); + } + + /** + * Records one attempt without changing the status. Use withFailedAttemptAndLock for the last + * failed attempt. + */ + OtpSnapshot withAttemptRecorded() { + return toBuilder().attempts(attempts + 1).build(); + } + + /** + * Records a failed attempt and locks the OTP in the same snapshot. This avoids creating an ACTIVE + * snapshot with no attempts remaining. + */ + OtpSnapshot withFailedAttemptAndLock() { + return toBuilder().attempts(attempts + 1).status(OtpStatus.LOCKED).build(); + } + + /** + * Records a successful attempt, marks the OTP as USED, and sets its consumption time in the same + * snapshot. Successful attempts also count toward the attempt limit. + */ + OtpSnapshot withSuccessfulAttempt(Instant verifiedAt) { + return toBuilder().attempts(attempts + 1).status(OtpStatus.USED).consumedAt(verifiedAt).build(); + } + + @Override + public String toString() { + return "OtpSnapshot[purpose=" + + purpose + + ", status=" + + status + + ", attempts=" + + attempts + + ", codeHash=[REDACTED], deviceBindingHash=[REDACTED]]"; + } + + Builder toBuilder() { + return new Builder(this); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/package-info.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/package-info.java new file mode 100644 index 0000000..35d747f --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.amg.payguard.identity.verification.domain.model; + +import org.jspecify.annotations.NullMarked; diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpRetentionPolicy.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpRetentionPolicy.java new file mode 100644 index 0000000..c79d563 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpRetentionPolicy.java @@ -0,0 +1,23 @@ +package dev.amg.payguard.identity.verification.domain.policy; + +import dev.amg.payguard.identity.verification.domain.exception.OtpRetentionPeriodMustNotPrecedeExpirationException; +import java.time.Instant; +import java.time.ZoneOffset; + +public final class OtpRetentionPolicy { + private OtpRetentionPolicy() { + /* This utility class should not be instantiated */ + } + + private static final int MIN_RETENTION_YEARS = 3; + + private static Instant minimumRetentionUntil(Instant expiresAt) { + return expiresAt.atZone(ZoneOffset.UTC).plusYears(MIN_RETENTION_YEARS).toInstant(); + } + + public static void validate(Instant expiresAt, Instant retentionUntil) { + if (retentionUntil.isBefore(minimumRetentionUntil(expiresAt))) { + throw new OtpRetentionPeriodMustNotPrecedeExpirationException(retentionUntil, expiresAt); + } + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java new file mode 100644 index 0000000..cf1ef52 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java @@ -0,0 +1,43 @@ +package dev.amg.payguard.identity.verification.domain.policy; + +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; +import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; +import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; +import java.util.Optional; + +public final class OtpVerificationPolicy { + private OtpVerificationPolicy() {} + + /** Guards that reject before an attempt is counted. Empty = eligible to be checked. */ + public static Optional findRejection( + VerifyOtpCommand command, OtpSnapshot snapshot, OtpCodeHasher hasher) { + if (command.purpose() != snapshot.purpose()) { + return Optional.of(OtpVerificationResult.PURPOSE_MISMATCH); + } + return switch (snapshot.status()) { + case USED -> Optional.of(OtpVerificationResult.ALREADY_USED); + case LOCKED -> Optional.of(OtpVerificationResult.LOCKED); + case INVALIDATED -> Optional.of(OtpVerificationResult.INVALIDATED); + case EXPIRED -> Optional.of(OtpVerificationResult.EXPIRED); + case ACTIVE -> evaluateActiveStateGuards(command, snapshot, hasher); + }; + } + + private static Optional evaluateActiveStateGuards( + VerifyOtpCommand command, OtpSnapshot snapshot, OtpCodeHasher hasher) { + if (!command.now().isBefore(snapshot.expiresAt())) { + return Optional.of(OtpVerificationResult.EXPIRED); + } + + if (snapshot.deviceBindingHash() != null) { + var matches = + command.deviceFingerprint() != null + && hasher.matches(command.deviceFingerprint(), snapshot.deviceBindingHash()); + if (!matches) { + return Optional.of(OtpVerificationResult.DEVICE_MISMATCH); + } + } + return Optional.empty(); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeGenerator.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeGenerator.java new file mode 100644 index 0000000..0b77443 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeGenerator.java @@ -0,0 +1,6 @@ +package dev.amg.payguard.identity.verification.domain.port; + +@FunctionalInterface +public interface OtpCodeGenerator { + String generate(); +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeHasher.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeHasher.java new file mode 100644 index 0000000..a6c258b --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeHasher.java @@ -0,0 +1,7 @@ +package dev.amg.payguard.identity.verification.domain.port; + +public interface OtpCodeHasher { + String hash(String value); + + boolean matches(String value, String encodedHash); +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpRepository.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpRepository.java new file mode 100644 index 0000000..7e0306d --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpRepository.java @@ -0,0 +1,28 @@ +package dev.amg.payguard.identity.verification.domain.port; + +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.model.OTP; +import java.util.Optional; +import java.util.function.Function; + +public interface OtpRepository { + /** + * Invalidates ACTIVE challenges for the same customer and purpose and saves the new challenge in + * one database transaction, serialized for the same customer and purpose even on first issuance. + */ + void replaceActive(OTP otp); + + /** + * Inserts a challenge. Existing challenges must be mutated through the locked update contract. + */ + void save(OTP otp); + + /** + * Locks and restores current persisted state, applies one local mutation, and commits before + * returning its result. The callback must not perform remote calls or throw a business rejection + * whose state transition should be committed. Storage failures roll back the mutation. + */ + Optional withLockedChallenge(OtpId id, Function mutation); + + Optional findById(OtpId otpId); +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/value_object/VerifyOtpCommand.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/value_object/VerifyOtpCommand.java new file mode 100644 index 0000000..bfdf708 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/value_object/VerifyOtpCommand.java @@ -0,0 +1,20 @@ +package dev.amg.payguard.identity.verification.domain.value_object; + +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import java.time.Instant; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public record VerifyOtpCommand( + OtpPurpose purpose, + String code, + @Nullable String deviceFingerprint, + Instant now, + boolean authenticatorCodeValid // Used when verificationMode == TOTP + ) { + @Override + public String toString() { + return "VerifyOTPCommand[purpose=" + purpose + ", code=[REDACTED]]"; + } +} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPTest.java new file mode 100644 index 0000000..f286ed1 --- /dev/null +++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPTest.java @@ -0,0 +1,338 @@ +package dev.amg.payguard.identity.verification.domain; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.*; +import dev.amg.payguard.identity.verification.domain.model.OTP; +import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; +import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.HashSet; +import java.util.Set; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +class OTPTest { + + private static final Instant CREATED_AT = Instant.parse("2026-01-01T00:00:00Z"); + private static final Instant EXPIRES_AT = CREATED_AT.plusSeconds(300); + private static final Instant RETENTION_UNTIL = + EXPIRES_AT.atZone(ZoneOffset.UTC).plusYears(3).toInstant(); + + private static final OtpId ID = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"); + private static final CustomerId CUSTOMER_ID = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"); + + // Test double only. Never use this hasher in production. + private static final OtpCodeHasher HASHER = + new OtpCodeHasher() { + @Override + public String hash(String value) { + return "hash:" + value; + } + + @Override + public boolean matches(String value, String encodedHash) { + return encodedHash.equals(hash(value)); + } + }; + + private static OtpSnapshot snapshot( + OtpStatus status, + int attempts, + int maxAttempts, + @Nullable Instant consumedAt, + @Nullable String deviceHash) { + + return new OtpSnapshot( + OtpPurpose.LOGIN, + OtpChannel.SMS, + OtpVerificationMode.RANDOM_NUMERIC, + "hash:123456", + deviceHash, + maxAttempts, + CREATED_AT, + EXPIRES_AT, + RETENTION_UNTIL, + attempts, + status, + consumedAt); + } + + private static OTP issue(int maxAttempts) { + return OTP.issue(ID, CUSTOMER_ID, snapshot(OtpStatus.ACTIVE, 0, maxAttempts, null, null)); + } + + private static VerifyOtpCommand command( + OtpPurpose purpose, String code, Instant now, @Nullable String device) { + + return new VerifyOtpCommand(purpose, code, device, now, false); + } + + @Test + void issueRequiresFreshActiveSnapshot() { + OtpSnapshot attempted = snapshot(OtpStatus.ACTIVE, 1, 3, null, null); + + OtpSnapshot consumed = snapshot(OtpStatus.USED, 1, 3, CREATED_AT, null); + + assertThrows(IllegalArgumentException.class, () -> OTP.issue(ID, CUSTOMER_ID, attempted)); + + assertThrows(IllegalArgumentException.class, () -> OTP.issue(ID, CUSTOMER_ID, consumed)); + } + + @Test + void restorePreservesValidHistoricalStates() { + OtpSnapshot[] historicalStates = { + snapshot(OtpStatus.ACTIVE, 2, 3, null, null), + snapshot(OtpStatus.USED, 2, 3, CREATED_AT.plusSeconds(20), null), + snapshot(OtpStatus.LOCKED, 3, 3, null, null), + snapshot(OtpStatus.EXPIRED, 1, 3, null, null), + snapshot(OtpStatus.INVALIDATED, 0, 3, null, null) + }; + + for (OtpSnapshot state : historicalStates) { + OTP restored = OTP.restore(ID, CUSTOMER_ID, state); + + assertEquals(ID, restored.getId()); + assertEquals(CUSTOMER_ID, restored.getCustomerId()); + assertEquals(state, restored.getSnapshot(), state.status().name()); + } + } + + @Test + void restoreRejectsNullArguments() { + OtpSnapshot valid = snapshot(OtpStatus.ACTIVE, 0, 3, null, null); + + // Deliberate violations of the non-null contract. + assertThrows(NullPointerException.class, () -> OTP.restore(null, CUSTOMER_ID, valid)); + + assertThrows(NullPointerException.class, () -> OTP.restore(ID, null, valid)); + + assertThrows(NullPointerException.class, () -> OTP.restore(ID, CUSTOMER_ID, null)); + } + + @Test + void restoredUsedOtpCannotBeVerifiedAgain() { + OtpSnapshot used = snapshot(OtpStatus.USED, 2, 3, CREATED_AT.plusSeconds(20), null); + + OTP otp = OTP.restore(ID, CUSTOMER_ID, used); + OtpSnapshot before = otp.getSnapshot(); + + assertEquals( + OtpVerificationResult.ALREADY_USED, + otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT.plusSeconds(30), null), HASHER)); + + assertSame(before, otp.getSnapshot()); + } + + @Test + void invalidCodeCountsOneAttemptWithoutMutatingPreviousSnapshot() { + OTP otp = issue(3); + OtpSnapshot before = otp.getSnapshot(); + + assertEquals( + OtpVerificationResult.INVALID_CODE, + otp.verify(command(OtpPurpose.LOGIN, "000000", CREATED_AT, null), HASHER)); + + assertEquals(OtpStatus.ACTIVE, otp.getSnapshot().status()); + assertEquals(1, otp.getSnapshot().attempts()); + assertNull(otp.getSnapshot().consumedAt()); + + assertEquals(0, before.attempts()); + assertEquals(OtpStatus.ACTIVE, before.status()); + assertNotSame(before, otp.getSnapshot()); + } + + @Test + void finalInvalidAttemptLocksOtpAndPreventsFurtherVerification() { + OTP otp = issue(2); + + assertEquals( + OtpVerificationResult.INVALID_CODE, + otp.verify(command(OtpPurpose.LOGIN, "000000", CREATED_AT, null), HASHER)); + + assertEquals( + OtpVerificationResult.LOCKED, + otp.verify(command(OtpPurpose.LOGIN, "000000", CREATED_AT, null), HASHER)); + + assertEquals(OtpStatus.LOCKED, otp.getSnapshot().status()); + assertEquals(2, otp.getSnapshot().attempts()); + assertNull(otp.getSnapshot().consumedAt()); + + OtpSnapshot locked = otp.getSnapshot(); + + assertEquals( + OtpVerificationResult.LOCKED, + otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, null), HASHER)); + + assertSame(locked, otp.getSnapshot()); + } + + @Test + void successOnLastAvailableAttemptIsSingleUse() { + OTP otp = issue(2); + + assertEquals( + OtpVerificationResult.INVALID_CODE, + otp.verify(command(OtpPurpose.LOGIN, "000000", CREATED_AT, null), HASHER)); + + Instant verifiedAt = CREATED_AT.plusSeconds(10); + + assertEquals( + OtpVerificationResult.VERIFIED, + otp.verify(command(OtpPurpose.LOGIN, "123456", verifiedAt, null), HASHER)); + + assertEquals(OtpStatus.USED, otp.getSnapshot().status()); + assertEquals(2, otp.getSnapshot().attempts()); + assertEquals(verifiedAt, otp.getSnapshot().consumedAt()); + + OtpSnapshot consumed = otp.getSnapshot(); + + assertEquals( + OtpVerificationResult.ALREADY_USED, + otp.verify(command(OtpPurpose.LOGIN, "123456", verifiedAt, null), HASHER)); + + assertSame(consumed, otp.getSnapshot()); + } + + @Test + void verificationAtExpirationMarksOtpExpiredWithoutCountingAttempt() { + OTP otp = issue(3); + + assertEquals( + OtpVerificationResult.EXPIRED, + otp.verify(command(OtpPurpose.LOGIN, "123456", EXPIRES_AT, null), HASHER)); + + assertEquals(OtpStatus.EXPIRED, otp.getSnapshot().status()); + assertEquals(0, otp.getSnapshot().attempts()); + assertNull(otp.getSnapshot().consumedAt()); + + OtpSnapshot expired = otp.getSnapshot(); + + assertEquals( + OtpVerificationResult.EXPIRED, + otp.verify(command(OtpPurpose.LOGIN, "000000", EXPIRES_AT, null), HASHER)); + + assertSame(expired, otp.getSnapshot()); + } + + @Test + void purposeMismatchRejectsBeforeExpirationWithoutChangingState() { + OTP otp = issue(3); + OtpSnapshot before = otp.getSnapshot(); + + assertEquals( + OtpVerificationResult.PURPOSE_MISMATCH, + otp.verify(command(OtpPurpose.LOAN_DISBURSE, "123456", EXPIRES_AT, null), HASHER)); + + assertSame(before, otp.getSnapshot()); + assertEquals(0, otp.getSnapshot().attempts()); + } + + @Test + void deviceMismatchDoesNotConsumeAttempt() { + OTP otp = OTP.issue(ID, CUSTOMER_ID, snapshot(OtpStatus.ACTIVE, 0, 3, null, "hash:device-a")); + + OtpSnapshot before = otp.getSnapshot(); + + assertEquals( + OtpVerificationResult.DEVICE_MISMATCH, + otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, "device-b"), HASHER)); + + assertSame(before, otp.getSnapshot()); + + assertEquals( + OtpVerificationResult.VERIFIED, + otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, "device-a"), HASHER)); + + assertEquals(OtpStatus.USED, otp.getSnapshot().status()); + assertEquals(1, otp.getSnapshot().attempts()); + } + + @Test + void invalidationPreventsVerificationAndDoesNotChangeTerminalStates() { + OTP otp = issue(3); + + otp.invalidate(); + + assertEquals(OtpStatus.INVALIDATED, otp.getSnapshot().status()); + assertEquals(0, otp.getSnapshot().attempts()); + + OtpSnapshot invalidated = otp.getSnapshot(); + + assertEquals( + OtpVerificationResult.INVALIDATED, + otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, null), HASHER)); + + otp.invalidate(); + assertSame(invalidated, otp.getSnapshot()); + + OTP used = issue(1); + used.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, null), HASHER); + + OtpSnapshot usedState = used.getSnapshot(); + used.invalidate(); + + assertSame(usedState, used.getSnapshot()); + assertEquals(OtpStatus.USED, used.getSnapshot().status()); + } + + @Test + void hasherExceptionLeavesExactSnapshotUnchanged() { + OTP otp = issue(3); + OtpSnapshot before = otp.getSnapshot(); + + OtpCodeHasher failingHasher = + new OtpCodeHasher() { + @Override + public String hash(String value) { + throw new IllegalStateException("Hashing failed"); + } + + @Override + public boolean matches(String value, String encodedHash) { + throw new IllegalStateException("Hashing failed"); + } + }; + + assertThrows( + IllegalStateException.class, + () -> otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, null), failingHasher)); + + assertSame(before, otp.getSnapshot()); + } + + @Test + void verificationBeforeCreationIsRejectedWithoutMutation() { + OTP otp = issue(3); + OtpSnapshot before = otp.getSnapshot(); + + // Proposed contract: invalid pre-creation time throws IllegalArgumentException. + assertThrows( + IllegalArgumentException.class, + () -> + otp.verify( + command(OtpPurpose.LOGIN, "123456", CREATED_AT.minusSeconds(1), null), HASHER)); + + assertSame(before, otp.getSnapshot()); + } + + @Test + void aggregateHashCodeRemainsStableAfterStateTransition() { + OTP otp = issue(3); + + int hashBefore = otp.hashCode(); + Set set = new HashSet<>(); + set.add(otp); + + assertEquals( + OtpVerificationResult.VERIFIED, + otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, null), HASHER)); + + assertEquals(hashBefore, otp.hashCode()); + assertTrue(set.contains(otp)); + } +} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OtpSnapshotTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OtpSnapshotTest.java new file mode 100644 index 0000000..606a255 --- /dev/null +++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OtpSnapshotTest.java @@ -0,0 +1,204 @@ +package dev.amg.payguard.identity.verification.domain; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.amg.payguard.identity.verification.domain.enums.*; +import dev.amg.payguard.identity.verification.domain.exception.*; +import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; +import java.time.Instant; +import java.time.ZoneOffset; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +class OtpSnapshotTest { + + private static final Instant CREATED_AT = Instant.parse("2026-01-01T00:00:00Z"); + private static final Instant EXPIRES_AT = CREATED_AT.plusSeconds(300); + private static final Instant RETENTION_UNTIL = + EXPIRES_AT.atZone(ZoneOffset.UTC).plusYears(3).toInstant(); + + private static OtpSnapshot snapshot( + OtpStatus status, + int attempts, + int maxAttempts, + @Nullable Instant consumedAt, + String codeHash, + @Nullable String deviceHash, + Instant expiresAt, + Instant retentionUntil) { + + return new OtpSnapshot( + OtpPurpose.LOGIN, + OtpChannel.SMS, + OtpVerificationMode.RANDOM_NUMERIC, + codeHash, + deviceHash, + maxAttempts, + CREATED_AT, + expiresAt, + retentionUntil, + attempts, + status, + consumedAt); + } + + private static OtpSnapshot state( + OtpStatus status, int attempts, int maxAttempts, @Nullable Instant consumedAt) { + + return snapshot( + status, + attempts, + maxAttempts, + consumedAt, + "hash:123456", + null, + EXPIRES_AT, + RETENTION_UNTIL); + } + + @Test + void acceptsValidLifecycleStates() { + OtpSnapshot active = state(OtpStatus.ACTIVE, 0, 3, null); + OtpSnapshot used = state(OtpStatus.USED, 1, 3, CREATED_AT); + OtpSnapshot locked = state(OtpStatus.LOCKED, 3, 3, null); + OtpSnapshot expired = state(OtpStatus.EXPIRED, 1, 3, null); + OtpSnapshot invalidated = state(OtpStatus.INVALIDATED, 0, 3, null); + + assertEquals(OtpStatus.ACTIVE, active.status()); + assertNull(active.consumedAt()); + assertNull(active.deviceBindingHash()); + + assertEquals(OtpStatus.USED, used.status()); + assertEquals(CREATED_AT, used.consumedAt()); + + assertEquals(OtpStatus.LOCKED, locked.status()); + assertEquals(OtpStatus.EXPIRED, expired.status()); + assertEquals(OtpStatus.INVALIDATED, invalidated.status()); + } + + @Test + void rejectsInvalidAttemptLimits() { + assertThrows(InvalidOtpAttemptLimitException.class, () -> state(OtpStatus.ACTIVE, 0, 0, null)); + + assertThrows( + OtpAttemptCountOutOfRangeException.class, () -> state(OtpStatus.ACTIVE, -1, 3, null)); + + assertThrows( + OtpAttemptCountOutOfRangeException.class, () -> state(OtpStatus.ACTIVE, 4, 3, null)); + + // ACTIVE must always have an attempt remaining. + assertThrows( + OtpAttemptCountOutOfRangeException.class, () -> state(OtpStatus.ACTIVE, 3, 3, null)); + } + + @Test + void consumedAtMustExistExactlyWhenStatusIsUsed() { + assertThrows( + OtpConsumedTimeMustMatchUsedStatusException.class, () -> state(OtpStatus.USED, 1, 3, null)); + + assertThrows( + OtpConsumedTimeMustMatchUsedStatusException.class, + () -> state(OtpStatus.ACTIVE, 0, 3, CREATED_AT)); + + assertThrows( + OtpConsumedTimeMustMatchUsedStatusException.class, + () -> state(OtpStatus.LOCKED, 3, 3, CREATED_AT)); + } + + @Test + void consumedAtMustBeInsideValidityPeriod() { + assertThrows( + OtpConsumedTimeOutsideValidityPeriodException.class, + () -> state(OtpStatus.USED, 1, 3, CREATED_AT.minusNanos(1))); + + assertThrows( + OtpConsumedTimeOutsideValidityPeriodException.class, + () -> state(OtpStatus.USED, 1, 3, EXPIRES_AT)); + + // Consumption exactly at creation is valid. + OtpSnapshot valid = state(OtpStatus.USED, 1, 3, CREATED_AT); + assertEquals(CREATED_AT, valid.consumedAt()); + } + + @Test + void expirationMustBeStrictlyAfterCreation() { + assertThrows( + OtpExpirationTimeMustBeAfterCreationTimeException.class, + () -> + snapshot( + OtpStatus.ACTIVE, 0, 3, null, "hash:123456", null, CREATED_AT, RETENTION_UNTIL)); + + assertThrows( + OtpExpirationTimeMustBeAfterCreationTimeException.class, + () -> + snapshot( + OtpStatus.ACTIVE, + 0, + 3, + null, + "hash:123456", + null, + CREATED_AT.minusSeconds(1), + RETENTION_UNTIL)); + } + + @Test + void retentionMustRespectMinimumPeriod() { + // The current retention policy requires 3 years after expiry. + OtpSnapshot valid = state(OtpStatus.ACTIVE, 0, 3, null); + assertEquals(RETENTION_UNTIL, valid.retentionUntil()); + + assertThrows( + OtpRetentionPeriodMustNotPrecedeExpirationException.class, + () -> + snapshot( + OtpStatus.ACTIVE, + 0, + 3, + null, + "hash:123456", + null, + EXPIRES_AT, + RETENTION_UNTIL.minusNanos(1))); + } + + @Test + void numericCodeHashAndOptionalDeviceBindingAreValidated() { + assertThrows( + OtpCodeHashRequiredForRandomNumericVerificationException.class, + () -> snapshot(OtpStatus.ACTIVE, 0, 3, null, "", null, EXPIRES_AT, RETENTION_UNTIL)); + + assertThrows( + OtpCodeHashRequiredForRandomNumericVerificationException.class, + () -> snapshot(OtpStatus.ACTIVE, 0, 3, null, " ", null, EXPIRES_AT, RETENTION_UNTIL)); + + assertThrows( + OtpDeviceBindingHashMustNotBeBlankException.class, + () -> + snapshot( + OtpStatus.ACTIVE, 0, 3, null, "hash:123456", " ", EXPIRES_AT, RETENTION_UNTIL)); + + // No device binding is a valid configuration. + OtpSnapshot unbound = state(OtpStatus.ACTIVE, 0, 3, null); + assertNull(unbound.deviceBindingHash()); + } + + @Test + void diagnosticStringMustNotExposeSensitiveHashes() { + OtpSnapshot state = + snapshot( + OtpStatus.ACTIVE, + 0, + 3, + null, + "hash:123456", + "hash:device-a", + EXPIRES_AT, + RETENTION_UNTIL); + + String diagnostic = state.toString(); + + assertFalse(diagnostic.contains("hash:123456")); + assertFalse(diagnostic.contains("hash:device-a")); + } +} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/TypedIdentifiersTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/TypedIdentifiersTest.java new file mode 100644 index 0000000..d0a0cb9 --- /dev/null +++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/TypedIdentifiersTest.java @@ -0,0 +1,51 @@ +package dev.amg.payguard.identity.verification.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.infrastructure.security.OtpIdGenerator; +import org.junit.jupiter.api.Test; + +class TypedIdentifiersTest { + @Test + void acceptsExactFormatAndPreservesCaseAndEquality() { + String value = "cus_aBCDEFGHIJKLMNOPQRSTUV"; + CustomerId customer = CustomerId.parse(value); + assertEquals(value, customer.getValue()); + assertEquals(customer, CustomerId.parse(value)); + assertEquals(customer.hashCode(), CustomerId.parse(value).hashCode()); + assertNotEquals(customer, CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV")); + assertFalse(customer.sameValueAs(OtpId.parse("otp_aBCDEFGHIJKLMNOPQRSTUV"))); + } + + @Test + void rejectsWrongPrefixesLengthsWhitespaceAndNonAscii() { + for (String value : + new String[] { + "xxx_ABCDEFGHIJKLMNOPQRSTUV", + "cus_ABCDEFGHIJKLMNOPQRSTU", + "cus_ABCDEFGHIJKLMNOPQRSTUVW", + "cus_ABCDEFGHIJKLMNOPQRSTU ", + "cus_ABCDEFGHIJKLMNOPQRSTUĆ©", + "" + }) { + assertThrows(IllegalArgumentException.class, () -> CustomerId.parse(value)); + } + assertThrows(NullPointerException.class, () -> CustomerId.parse(null)); + assertThrows(IllegalArgumentException.class, () -> OtpId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV")); + } + + @Test + void generatorProducesAcceptedTypedIdsWithoutUuidConversion() { + OtpIdGenerator generator = new OtpIdGenerator(); + OtpId first = generator.generate(); + assertTrue(first.value().matches("otp_[A-Za-z0-9]{22}")); + assertEquals(first, OtpId.parse(first.value())); + assertNotEquals(first, generator.generate()); + } +}