Skip to content
Closed
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
@@ -0,0 +1,97 @@
package dev.amg.payguard.otp.domain.model;

import dev.amg.payguard.otp.domain.enums.OtpStatus;
import dev.amg.payguard.otp.domain.enums.OtpVerificationMode;
import dev.amg.payguard.otp.domain.enums.OtpVerificationResult;
import dev.amg.payguard.otp.domain.policy.OtpVerificationPolicy;
import dev.amg.payguard.otp.domain.port.CodeHasher;
import dev.amg.payguard.otp.domain.port.VerifyOTPCommand;
import domain.AggregateRoot;
import domain.DomainEvent;

import java.util.Objects;
import java.util.Optional;
import java.util.UUID;

public final class OTP extends AggregateRoot<UUID, DomainEvent> {

private final String userId;
private OtpSnapshot snapshot;

private OTP(UUID id, String userId, OtpSnapshot snapshot) {
super(Objects.requireNonNull(id, "id must not be null"));
this.userId = Objects.requireNonNull(userId, "userId must not be null");
this.snapshot = Objects.requireNonNull(snapshot, "snapshot must not be null");
}

public static OTP issue(UUID id, String userId, OtpSnapshot snapshot) {
Objects.requireNonNull(snapshot, "snapshot must not be null");

if (snapshot.status() != OtpStatus.ACTIVE
|| snapshot.attempts() != 0
|| Objects.nonNull(snapshot.consumedAt())) {
throw new IllegalArgumentException(
"issuance requires an initial snapshot");
}

return new OTP(id, userId, snapshot);
}


public static OTP restore(UUID id, String userId, OtpSnapshot snapshot) {
return new OTP(id, userId, snapshot);
}


public OtpVerificationResult verify(VerifyOTPCommand command, CodeHasher hasher) {
Optional<OtpVerificationResult> 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 String getUserId() {
return userId;
}
public OtpSnapshot getSnapshot() {
return snapshot;
}

private static boolean isCodeValid(VerifyOTPCommand command, CodeHasher hasher, OtpSnapshot snapshot) {
if (snapshot.verificationMode() == OtpVerificationMode.TOTP) {
return command.authenticatorCodeValid();
}

return command.code() != null
&& snapshot.codeHash() != null
&& hasher.matches(command.code(), snapshot.codeHash());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
package dev.amg.payguard.otp.domain.model;

import dev.amg.payguard.otp.domain.enums.OtpChannel;
import dev.amg.payguard.otp.domain.enums.OtpPurpose;
import dev.amg.payguard.otp.domain.enums.OtpStatus;
import dev.amg.payguard.otp.domain.enums.OtpVerificationMode;
import dev.amg.payguard.otp.domain.exception.InvalidOtpAttemptLimitException;
import dev.amg.payguard.otp.domain.exception.OtpAttemptCountOutOfRangeException;
import dev.amg.payguard.otp.domain.exception.OtpCodeHashMustNotBeBlankException;
import dev.amg.payguard.otp.domain.exception.OtpCodeHashRequiredForRandomNumericVerificationException;
import dev.amg.payguard.otp.domain.exception.OtpConsumedTimeMustMatchUsedStatusException;
import dev.amg.payguard.otp.domain.exception.OtpConsumedTimeOutsideValidityPeriodException;
import dev.amg.payguard.otp.domain.exception.OtpDeviceBindingHashMustNotBeBlankException;
import dev.amg.payguard.otp.domain.exception.OtpExpirationTimeMustBeAfterCreationTimeException;
import dev.amg.payguard.otp.domain.policy.OtpRetentionPolicy;

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

public record OtpSnapshot(
OtpPurpose purpose, OtpChannel channel,
OtpVerificationMode verificationMode, String codeHash,
String deviceBindingHash, int maxAttempts,
Instant createdAt, Instant expiresAt,
Instant retentionUntil, int attempts,
OtpStatus status, 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);
}

public static OtpSnapshot reconstitute(
OtpPurpose purpose, OtpChannel channel,
OtpVerificationMode verificationMode, String codeHash,
String deviceBindingHash, int maxAttempts,
Instant createdAt, Instant expiresAt, Instant retentionUntil,
int attempts, OtpStatus status, 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");
}

OtpSnapshot withStatus(OtpStatus newStatus) {
return toBuilder().status(newStatus).build();
}

OtpSnapshot withAttemptRecorded() {
return toBuilder().attempts(attempts + 1).build();
}

OtpSnapshot withFailedAttemptAndLock() {
return toBuilder()
.attempts(attempts + 1)
.status(OtpStatus.LOCKED)
.build();
}
OtpSnapshot withSuccessfulAttempt(Instant verifiedAt) {
return toBuilder()
.attempts(attempts + 1)
.status(OtpStatus.USED)
.consumedAt(verifiedAt)
.build();
}

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);
}

if (status == OtpStatus.ACTIVE && attempts == maxAttempts) {
throw new OtpAttemptCountOutOfRangeException(attempts, maxAttempts);
}
}

private static void validateVerificationData(OtpVerificationMode verificationMode, String codeHash, String deviceBindingHash) {
if (verificationMode == OtpVerificationMode.RANDOM_NUMERIC
&& (codeHash == null || codeHash.isBlank())) {
throw new OtpCodeHashRequiredForRandomNumericVerificationException(verificationMode);

}

if (Objects.nonNull(codeHash) && codeHash.isBlank()) {
throw new OtpCodeHashMustNotBeBlankException();
}

if (Objects.nonNull(deviceBindingHash) && deviceBindingHash.isBlank()) {
throw new OtpDeviceBindingHashMustNotBeBlankException();
}

}

private static void validateLifecycle(OtpStatus status, Instant createdAt, Instant expiresAt, Instant consumedAt) {
if (!expiresAt.isAfter(createdAt)) {
throw new OtpExpirationTimeMustBeAfterCreationTimeException();
}

final boolean isUsed = status == OtpStatus.USED;
final boolean hasConsumedAt = Objects.nonNull(consumedAt);

if (isUsed != hasConsumedAt) {
throw new OtpConsumedTimeMustMatchUsedStatusException(consumedAt, createdAt, expiresAt);
}

if (hasConsumedAt
&& (consumedAt.isBefore(createdAt)
|| !consumedAt.isBefore(expiresAt))) {
throw new OtpConsumedTimeOutsideValidityPeriodException(consumedAt, createdAt, expiresAt);
}
}

Builder toBuilder() {
return new Builder()
.purpose(purpose)
.channel(channel)
.verificationMode(verificationMode)
.codeHash(codeHash)
.deviceBindingHash(deviceBindingHash)
.maxAttempts(maxAttempts)
.createdAt(createdAt)
.expiresAt(expiresAt)
.retentionUntil(retentionUntil)
.attempts(attempts)
.status(status)
.consumedAt(consumedAt);
}

static final class Builder {

private OtpPurpose purpose;
private OtpChannel channel;
private OtpVerificationMode verificationMode;
private String codeHash;
private String deviceBindingHash;
private int maxAttempts;
private Instant createdAt;
private Instant expiresAt;
private Instant retentionUntil;
private int attempts;
private OtpStatus status;
private Instant consumedAt;

private Builder() {
}

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(String codeHash) {
this.codeHash = codeHash;
return this;
}

Builder deviceBindingHash(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(Instant consumedAt) {
this.consumedAt = consumedAt;
return this;
}

OtpSnapshot build() {
return new OtpSnapshot(purpose, channel, verificationMode, codeHash,
deviceBindingHash, maxAttempts, createdAt, expiresAt, retentionUntil,
attempts, status, consumedAt);
}
}

}
Loading