From 68e4bd008528becaecdba2e6884f9e5c1a202794 Mon Sep 17 00:00:00 2001 From: Amir Golmoradi Date: Mon, 12 Oct 2026 10:37:54 +0330 Subject: [PATCH 1/5] # RENAMED TO IDENTITY SERVICE ### I DECIDED TO ADD MULTI-LAYER AUTHENTICATION BECAUSE. --- payguard-otp-service/Dockerfile | 13 - payguard-otp-service/README.md | 44 ---- payguard-otp-service/pom.xml | 92 ------- .../payguard/otp/OtpServiceApplication.java | 12 - .../otp/application/IssueOtpCommand.java | 12 - .../payguard/otp/application/IssuedOtp.java | 9 - .../application/OtpApplicationService.java | 182 -------------- .../OtpChallengeNotFoundException.java | 12 - .../OtpRateLimitExceededException.java | 10 - .../application/OtpVerificationException.java | 18 -- .../payguard/otp/application/VerifiedOtp.java | 6 - .../otp/application/VerifyOtpCommand.java | 12 - .../otp/domain/AuthenticatorCodeVerifier.java | 9 - .../amg/payguard/otp/domain/DeliveryPort.java | 15 -- .../payguard/otp/domain/OtpAuditLogPort.java | 11 - .../amg/payguard/otp/domain/OtpChallenge.java | 236 ------------------ .../otp/domain/OtpChallengeRepository.java | 13 - .../otp/domain/OtpChallengeStatus.java | 9 - .../amg/payguard/otp/domain/OtpChannel.java | 8 - .../payguard/otp/domain/OtpCodeGenerator.java | 7 - .../payguard/otp/domain/OtpCodeHasher.java | 8 - .../otp/domain/OtpEventPublisher.java | 20 -- .../amg/payguard/otp/domain/OtpPurpose.java | 10 - .../otp/domain/OtpVerificationMode.java | 6 - .../otp/domain/OtpVerificationResult.java | 12 - .../amg/payguard/otp/domain/RateLimiter.java | 8 - .../payguard/otp/domain/StepUpTokenPort.java | 12 - .../config/OtpConfiguration.java | 58 ----- .../delivery/EmailDeliveryAdapter.java | 16 -- .../delivery/MockDeliveryAdapter.java | 19 -- .../delivery/PushDeliveryAdapter.java | 16 -- .../delivery/SmsDeliveryAdapter.java | 16 -- .../kafka/KafkaOtpEventPublisher.java | 33 --- .../persistence/JpaOtpAuditLogAdapter.java | 24 -- .../JpaOtpChallengeRepositoryAdapter.java | 55 ---- .../persistence/OtpAuditLogEntity.java | 54 ---- .../persistence/OtpAuditLogJpaRepository.java | 6 - .../persistence/OtpChallengeEntity.java | 126 ---------- .../OtpChallengeJpaRepository.java | 13 - .../redis/RedisRateLimiterAdapter.java | 53 ---- .../redis/RedisStepUpTokenAdapter.java | 69 ----- .../security/AuthenticatorSecretPort.java | 9 - .../security/HmacOtpCodeHasher.java | 63 ----- .../security/SecureNumericOtpGenerator.java | 16 -- .../TotpAuthenticatorCodeVerifier.java | 81 ------ ...UnavailableAuthenticatorSecretAdapter.java | 14 -- .../otp/interfaces/rest/OtpController.java | 90 ------- .../interfaces/rest/OtpExceptionHandler.java | 42 ---- .../rest/StepUpTokenController.java | 39 --- .../src/main/resources/application.yaml | 46 ---- .../src/main/resources/banner.txt | 6 - .../resources/db/migration/V1__otp_schema.sql | 41 --- .../OtpApplicationServiceTest.java | 176 ------------- .../payguard/otp/domain/OtpChallengeTest.java | 102 -------- .../OtpInfrastructureIntegrationTest.java | 29 --- .../security/HmacOtpCodeHasherTest.java | 23 -- .../src/test/resources/application-test.yaml | 9 - 57 files changed, 2150 deletions(-) delete mode 100644 payguard-otp-service/Dockerfile delete mode 100644 payguard-otp-service/README.md delete mode 100644 payguard-otp-service/pom.xml delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/OtpServiceApplication.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssueOtpCommand.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssuedOtp.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpApplicationService.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpChallengeNotFoundException.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpRateLimitExceededException.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpVerificationException.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifiedOtp.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifyOtpCommand.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/AuthenticatorCodeVerifier.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/DeliveryPort.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpAuditLogPort.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallenge.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeRepository.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeStatus.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChannel.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeGenerator.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeHasher.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpEventPublisher.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpPurpose.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationMode.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationResult.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/RateLimiter.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/StepUpTokenPort.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/config/OtpConfiguration.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/EmailDeliveryAdapter.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/MockDeliveryAdapter.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/PushDeliveryAdapter.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/SmsDeliveryAdapter.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/kafka/KafkaOtpEventPublisher.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpAuditLogAdapter.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpChallengeRepositoryAdapter.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogEntity.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogJpaRepository.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeEntity.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeJpaRepository.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisRateLimiterAdapter.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisStepUpTokenAdapter.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/AuthenticatorSecretPort.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasher.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/SecureNumericOtpGenerator.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/TotpAuthenticatorCodeVerifier.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpController.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpExceptionHandler.java delete mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/StepUpTokenController.java delete mode 100644 payguard-otp-service/src/main/resources/application.yaml delete mode 100644 payguard-otp-service/src/main/resources/banner.txt delete mode 100644 payguard-otp-service/src/main/resources/db/migration/V1__otp_schema.sql delete mode 100644 payguard-otp-service/src/test/java/dev/amg/payguard/otp/application/OtpApplicationServiceTest.java delete mode 100644 payguard-otp-service/src/test/java/dev/amg/payguard/otp/domain/OtpChallengeTest.java delete mode 100644 payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/OtpInfrastructureIntegrationTest.java delete mode 100644 payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasherTest.java delete mode 100644 payguard-otp-service/src/test/resources/application-test.yaml diff --git a/payguard-otp-service/Dockerfile b/payguard-otp-service/Dockerfile deleted file mode 100644 index 9d740dc..0000000 --- a/payguard-otp-service/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -# syntax=docker/dockerfile:1 -FROM eclipse-temurin:25-jdk-alpine AS build -WORKDIR /workspace -COPY . . -RUN chmod +x mvnw && ./mvnw --batch-mode --no-transfer-progress -pl :payguard-otp-service -am -DskipTests package - -FROM eclipse-temurin:25-jre-alpine -WORKDIR /app -RUN addgroup -S payguard && adduser -S payguard -G payguard -COPY --from=build /workspace/payguard-otp-service/target/payguard-otp-service-*.jar /app/app.jar -USER payguard -EXPOSE 8084 -ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"] diff --git a/payguard-otp-service/README.md b/payguard-otp-service/README.md deleted file mode 100644 index c8efd8c..0000000 --- a/payguard-otp-service/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# PayGuard OTP Service - -`payguard-otp-service` is the bounded context for login MFA and sensitive-action -step-up authentication. It is independently deployable, persists challenge -metadata in Oracle, and keeps rate-limit counters and one-time step-up tokens in -Redis. - -## API - -- `POST /otp/challenges` issues a challenge for an opaque `userId`, purpose, - channel, destination reference, and optional device fingerprint. -- `POST /otp/challenges/{challengeId}/verify` verifies the six-digit code and - returns a single-use, purpose-scoped step-up token. -- `POST /otp/step-up-tokens/consume` lets a downstream gateway consumer redeem - the token once for the exact user and purpose. - -SMS, email, and push channels use random six-digit codes. Authenticator-app -challenges use an RFC 6238 TOTP verifier behind `AuthenticatorSecretPort`; the -secret is resolved by a vault/identity adapter and is never stored in the OTP -database. The default mock delivery adapter discards codes without logging them. - -## Privacy and security posture - -- Only opaque user IDs and destination references are stored; phone numbers and - email addresses remain in the identity service. -- Random codes are salted and HMAC-SHA256 hashed with a server-side pepper. -- Challenges are single-use, expiry-bound, device-bindable, and limited to five - failed attempts by default. -- Redis enforces issuance/verification rate limits and stores three-minute, - single-use step-up tokens. -- Oracle retains challenge/audit facts for 90 days by default; the purge job is - intentionally externalized so retention can be configured by jurisdiction. -- Audit records contain no OTP code and are indexed by subject and time. - -## Local validation - -Unit tests run with Maven. The Oracle/Redis smoke test is enabled explicitly: - -```bash -RUN_OTP_INTEGRATION_TESTS=true ./mvnw -pl payguard-otp-service -am test -``` - -Production requires `SPRING_DATASOURCE_*`, `REDIS_HOST`, -`OTP_SECURITY_PEPPER`, and `KAFKA_BOOTSTRAP_SERVERS` environment variables. diff --git a/payguard-otp-service/pom.xml b/payguard-otp-service/pom.xml deleted file mode 100644 index 90238b7..0000000 --- a/payguard-otp-service/pom.xml +++ /dev/null @@ -1,92 +0,0 @@ - - - 4.0.0 - - - dev.amg.payguard - payguard-parent - 1.0.0-SNAPSHOT - - - payguard-otp-service - PayGuard OTP Service - - - - org.springframework.boot - spring-boot-starter-webmvc - - - org.springframework.boot - spring-boot-starter-validation - - - org.springframework.boot - spring-boot-starter-jackson - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-starter-data-redis - - - org.springframework.kafka - spring-kafka - - - org.flywaydb - flyway-core - - - org.flywaydb - flyway-database-oracle - - - com.oracle.database.jdbc - ojdbc17 - runtime - - - org.springframework.boot - spring-boot-starter-webmvc-test - test - - - org.testcontainers - testcontainers-oracle-xe - test - - - com.redis - testcontainers-redis - test - - - org.testcontainers - testcontainers-junit-jupiter - test - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - repackage - - repackage - - - - - - - diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/OtpServiceApplication.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/OtpServiceApplication.java deleted file mode 100644 index b1d5365..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/OtpServiceApplication.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.amg.payguard.otp; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class OtpServiceApplication { - - public static void main(String[] args) { - SpringApplication.run(OtpServiceApplication.class, args); - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssueOtpCommand.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssueOtpCommand.java deleted file mode 100644 index 27fa7ec..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssueOtpCommand.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.amg.payguard.otp.application; - -import dev.amg.payguard.otp.domain.OtpChannel; -import dev.amg.payguard.otp.domain.OtpPurpose; - -public record IssueOtpCommand( - String userId, - OtpPurpose purpose, - OtpChannel channel, - String destinationRef, - String deviceFingerprint, - String sourceIp) {} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssuedOtp.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssuedOtp.java deleted file mode 100644 index 3f57a66..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssuedOtp.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.amg.payguard.otp.application; - -import dev.amg.payguard.otp.domain.OtpChannel; -import dev.amg.payguard.otp.domain.OtpPurpose; -import java.time.Instant; -import java.util.UUID; - -public record IssuedOtp( - UUID challengeId, OtpPurpose purpose, OtpChannel channel, Instant expiresAt) {} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpApplicationService.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpApplicationService.java deleted file mode 100644 index 15f0c74..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpApplicationService.java +++ /dev/null @@ -1,182 +0,0 @@ -package dev.amg.payguard.otp.application; - -import dev.amg.payguard.otp.domain.AuthenticatorCodeVerifier; -import dev.amg.payguard.otp.domain.DeliveryPort; -import dev.amg.payguard.otp.domain.OtpAuditLogPort; -import dev.amg.payguard.otp.domain.OtpChallenge; -import dev.amg.payguard.otp.domain.OtpChallengeRepository; -import dev.amg.payguard.otp.domain.OtpChannel; -import dev.amg.payguard.otp.domain.OtpCodeGenerator; -import dev.amg.payguard.otp.domain.OtpCodeHasher; -import dev.amg.payguard.otp.domain.OtpEventPublisher; -import dev.amg.payguard.otp.domain.OtpVerificationMode; -import dev.amg.payguard.otp.domain.OtpVerificationResult; -import dev.amg.payguard.otp.domain.RateLimiter; -import dev.amg.payguard.otp.domain.StepUpTokenPort; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.UUID; - -public final class OtpApplicationService { - - private final OtpChallengeRepository challenges; - private final OtpCodeHasher hasher; - private final OtpCodeGenerator codeGenerator; - private final AuthenticatorCodeVerifier authenticatorCodeVerifier; - private final DeliveryPort delivery; - private final RateLimiter rateLimiter; - private final StepUpTokenPort stepUpTokens; - private final OtpEventPublisher events; - private final OtpAuditLogPort audit; - private final Clock clock; - private final Duration randomTtl; - private final Duration totpTtl; - private final Duration auditRetention; - private final int maxAttempts; - - public OtpApplicationService( - OtpChallengeRepository challenges, - OtpCodeHasher hasher, - OtpCodeGenerator codeGenerator, - AuthenticatorCodeVerifier authenticatorCodeVerifier, - DeliveryPort delivery, - RateLimiter rateLimiter, - StepUpTokenPort stepUpTokens, - OtpEventPublisher events, - OtpAuditLogPort audit, - Clock clock, - Duration randomTtl, - Duration totpTtl, - Duration auditRetention, - int maxAttempts) { - this.challenges = challenges; - this.hasher = hasher; - this.codeGenerator = codeGenerator; - this.authenticatorCodeVerifier = authenticatorCodeVerifier; - this.delivery = delivery; - this.rateLimiter = rateLimiter; - this.stepUpTokens = stepUpTokens; - this.events = events; - this.audit = audit; - this.clock = clock; - this.randomTtl = randomTtl; - this.totpTtl = totpTtl; - this.auditRetention = auditRetention; - this.maxAttempts = maxAttempts; - } - - public IssuedOtp issue(IssueOtpCommand command) { - requireText(command.userId(), "userId"); - if (!rateLimiter.allowIssue(command.userId(), command.purpose(), command.sourceIp())) { - throw new OtpRateLimitExceededException(); - } - Instant now = clock.instant(); - boolean totp = command.channel() == OtpChannel.AUTHENTICATOR_APP; - Duration ttl = totp ? totpTtl : randomTtl; - UUID challengeId = UUID.randomUUID(); - String code = totp ? null : codeGenerator.generate(); - OtpChallenge challenge = - OtpChallenge.issue( - challengeId, - command.userId(), - command.purpose(), - command.channel(), - totp ? OtpVerificationMode.TOTP : OtpVerificationMode.RANDOM_NUMERIC, - code == null ? null : hasher.hash(code), - command.deviceFingerprint() == null ? null : hasher.hash(command.deviceFingerprint()), - maxAttempts, - now, - now.plus(ttl), - now.plus(auditRetention)); - challenges.invalidateActive(command.userId(), command.purpose()); - challenges.save(challenge); - if (!totp) { - delivery.deliver( - new DeliveryPort.DeliveryMessage( - challengeId, - command.userId(), - command.purpose(), - command.channel(), - command.destinationRef(), - code)); - } - publish( - new OtpEventPublisher.OtpEvent( - "otp.challenge-issued", - challengeId, - command.userId(), - command.purpose(), - command.channel(), - null, - now, - "challenge-issued")); - audit.append( - challengeId, command.userId(), "ISSUED", "challenge-issued", command.sourceIp(), now); - return new IssuedOtp(challengeId, command.purpose(), command.channel(), challenge.expiresAt()); - } - - public VerifiedOtp verify(VerifyOtpCommand command) { - OtpChallenge challenge = - challenges - .findById(command.challengeId()) - .orElseThrow(() -> new OtpChallengeNotFoundException(command.challengeId())); - if (!challenge.userId().equals(command.userId())) { - throw new OtpChallengeNotFoundException(command.challengeId()); - } - if (!rateLimiter.allowVerify(command.userId(), command.purpose(), command.sourceIp())) { - throw new OtpRateLimitExceededException(); - } - Instant now = clock.instant(); - boolean authenticatorValid = - challenge.verificationMode() == OtpVerificationMode.TOTP - && authenticatorCodeVerifier.verify(command.userId(), command.code(), now); - OtpVerificationResult result = - challenge.verify( - command.purpose(), - command.code(), - command.deviceFingerprint(), - hasher, - authenticatorValid, - now); - challenges.save(challenge); - String reason = result.name().toLowerCase(java.util.Locale.ROOT); - audit.append(challenge.id(), command.userId(), "VERIFY", reason, command.sourceIp(), now); - if (result != OtpVerificationResult.VERIFIED) { - publish( - new OtpEventPublisher.OtpEvent( - result == OtpVerificationResult.LOCKED ? "otp.locked-out" : "otp.failed", - challenge.id(), - command.userId(), - command.purpose(), - challenge.channel(), - result, - now, - reason)); - throw new OtpVerificationException(result); - } - StepUpTokenPort.IssuedStepUpToken token = - stepUpTokens.issue(command.userId(), command.purpose(), now); - publish( - new OtpEventPublisher.OtpEvent( - "otp.verified", - challenge.id(), - command.userId(), - command.purpose(), - challenge.channel(), - result, - now, - "verified")); - return new VerifiedOtp(token.token(), command.purpose(), token.expiresAt()); - } - - private void publish(OtpEventPublisher.OtpEvent event) { - events.publish(event); - } - - private static void requireText(String value, String field) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException(field + " must not be blank"); - } - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpChallengeNotFoundException.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpChallengeNotFoundException.java deleted file mode 100644 index df19fd4..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpChallengeNotFoundException.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.amg.payguard.otp.application; - -import java.util.UUID; - -public class OtpChallengeNotFoundException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - public OtpChallengeNotFoundException(UUID challengeId) { - super("OTP challenge not found: " + challengeId); - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpRateLimitExceededException.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpRateLimitExceededException.java deleted file mode 100644 index 9ed363f..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpRateLimitExceededException.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.amg.payguard.otp.application; - -public class OtpRateLimitExceededException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - public OtpRateLimitExceededException() { - super("OTP rate limit exceeded"); - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpVerificationException.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpVerificationException.java deleted file mode 100644 index 95814de..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpVerificationException.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.amg.payguard.otp.application; - -import dev.amg.payguard.otp.domain.OtpVerificationResult; - -public class OtpVerificationException extends RuntimeException { - - private static final long serialVersionUID = 1L; - private final OtpVerificationResult verificationResult; - - public OtpVerificationException(OtpVerificationResult result) { - super("OTP verification failed: " + result.name().toLowerCase(java.util.Locale.ROOT)); - this.verificationResult = result; - } - - public OtpVerificationResult result() { - return verificationResult; - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifiedOtp.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifiedOtp.java deleted file mode 100644 index b9f10d7..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifiedOtp.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.amg.payguard.otp.application; - -import dev.amg.payguard.otp.domain.OtpPurpose; -import java.time.Instant; - -public record VerifiedOtp(String stepUpToken, OtpPurpose purpose, Instant expiresAt) {} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifyOtpCommand.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifyOtpCommand.java deleted file mode 100644 index 90066fd..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifyOtpCommand.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.amg.payguard.otp.application; - -import dev.amg.payguard.otp.domain.OtpPurpose; -import java.util.UUID; - -public record VerifyOtpCommand( - UUID challengeId, - String userId, - OtpPurpose purpose, - String code, - String deviceFingerprint, - String sourceIp) {} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/AuthenticatorCodeVerifier.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/AuthenticatorCodeVerifier.java deleted file mode 100644 index b2d0f23..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/AuthenticatorCodeVerifier.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.amg.payguard.otp.domain; - -import java.time.Instant; - -@FunctionalInterface -public interface AuthenticatorCodeVerifier { - - boolean verify(String userId, String code, Instant at); -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/DeliveryPort.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/DeliveryPort.java deleted file mode 100644 index fcadf54..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/DeliveryPort.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.amg.payguard.otp.domain; - -@FunctionalInterface -public interface DeliveryPort { - - void deliver(DeliveryMessage message); - - record DeliveryMessage( - java.util.UUID challengeId, - String userId, - OtpPurpose purpose, - OtpChannel channel, - String destinationRef, - String code) {} -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpAuditLogPort.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpAuditLogPort.java deleted file mode 100644 index d31c2c3..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpAuditLogPort.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.amg.payguard.otp.domain; - -import java.time.Instant; -import java.util.UUID; - -@FunctionalInterface -public interface OtpAuditLogPort { - - void append( - UUID challengeId, String userId, String action, String reason, String sourceIp, Instant at); -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallenge.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallenge.java deleted file mode 100644 index f6f1452..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallenge.java +++ /dev/null @@ -1,236 +0,0 @@ -package dev.amg.payguard.otp.domain; - -import java.time.Instant; -import java.util.Objects; -import java.util.UUID; - -@SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName") -public final class OtpChallenge { - - private static final int MIN_ATTEMPTS = 1; - - private final UUID id; - private final String userId; - private final OtpPurpose purpose; - private final OtpChannel channel; - private final OtpVerificationMode verificationMode; - private final String codeHash; - private final String deviceBindingHash; - private final int maxAttempts; - private final Instant createdAt; - private final Instant expiresAt; - private final Instant retentionUntil; - private int attempts; - private OtpChallengeStatus status; - private Instant consumedAt; - - private OtpChallenge( - UUID id, - String userId, - OtpPurpose purpose, - OtpChannel channel, - OtpVerificationMode verificationMode, - String codeHash, - String deviceBindingHash, - int maxAttempts, - Instant createdAt, - Instant expiresAt, - Instant retentionUntil, - int attempts, - OtpChallengeStatus status, - Instant consumedAt) { - this.id = Objects.requireNonNull(id); - this.userId = requireText(userId, "userId"); - this.purpose = Objects.requireNonNull(purpose); - this.channel = Objects.requireNonNull(channel); - this.verificationMode = Objects.requireNonNull(verificationMode); - this.codeHash = codeHash; - this.deviceBindingHash = deviceBindingHash; - if (maxAttempts < MIN_ATTEMPTS) { - throw new IllegalArgumentException("maxAttempts must be positive"); - } - this.maxAttempts = maxAttempts; - this.createdAt = Objects.requireNonNull(createdAt); - this.expiresAt = Objects.requireNonNull(expiresAt); - this.retentionUntil = Objects.requireNonNull(retentionUntil); - this.attempts = attempts; - this.status = Objects.requireNonNull(status); - this.consumedAt = consumedAt; - } - - public static OtpChallenge issue( - UUID id, - String userId, - OtpPurpose purpose, - OtpChannel channel, - OtpVerificationMode verificationMode, - String codeHash, - String deviceBindingHash, - int maxAttempts, - Instant createdAt, - Instant expiresAt, - Instant retentionUntil) { - return new OtpChallenge( - id, - userId, - purpose, - channel, - verificationMode, - codeHash, - deviceBindingHash, - maxAttempts, - createdAt, - expiresAt, - retentionUntil, - 0, - OtpChallengeStatus.ACTIVE, - null); - } - - public static OtpChallenge restore( - UUID id, - String userId, - OtpPurpose purpose, - OtpChannel channel, - OtpVerificationMode verificationMode, - String codeHash, - String deviceBindingHash, - int maxAttempts, - Instant createdAt, - Instant expiresAt, - Instant retentionUntil, - int attempts, - OtpChallengeStatus status, - Instant consumedAt) { - return new OtpChallenge( - id, - userId, - purpose, - channel, - verificationMode, - codeHash, - deviceBindingHash, - maxAttempts, - createdAt, - expiresAt, - retentionUntil, - attempts, - status, - consumedAt); - } - - public OtpVerificationResult verify( - OtpPurpose requestedPurpose, - String code, - String deviceFingerprint, - OtpCodeHasher hasher, - boolean authenticatorCodeValid, - Instant now) { - if (requestedPurpose != purpose) { - return OtpVerificationResult.PURPOSE_MISMATCH; - } - if (status == OtpChallengeStatus.USED) { - return OtpVerificationResult.ALREADY_USED; - } - if (status == OtpChallengeStatus.LOCKED) { - return OtpVerificationResult.LOCKED; - } - if (status == OtpChallengeStatus.INVALIDATED) { - return OtpVerificationResult.INVALIDATED; - } - if (status == OtpChallengeStatus.EXPIRED || !now.isBefore(expiresAt)) { - status = OtpChallengeStatus.EXPIRED; - return OtpVerificationResult.EXPIRED; - } - if (deviceBindingHash != null - && (deviceFingerprint == null || !hasher.matches(deviceFingerprint, deviceBindingHash))) { - return OtpVerificationResult.DEVICE_MISMATCH; - } - - attempts++; - boolean valid = - verificationMode == OtpVerificationMode.TOTP - ? authenticatorCodeValid - : code != null && codeHash != null && hasher.matches(code, codeHash); - if (!valid) { - if (attempts >= maxAttempts) { - status = OtpChallengeStatus.LOCKED; - } - return status == OtpChallengeStatus.LOCKED - ? OtpVerificationResult.LOCKED - : OtpVerificationResult.INVALID_CODE; - } - status = OtpChallengeStatus.USED; - consumedAt = now; - return OtpVerificationResult.VERIFIED; - } - - public void invalidate() { - if (status == OtpChallengeStatus.ACTIVE) { - status = OtpChallengeStatus.INVALIDATED; - } - } - - private static String requireText(String value, String field) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException(field + " must not be blank"); - } - return value; - } - - public UUID id() { - return id; - } - - public String userId() { - return userId; - } - - public OtpPurpose purpose() { - return purpose; - } - - public OtpChannel channel() { - return channel; - } - - public OtpVerificationMode verificationMode() { - return verificationMode; - } - - public String codeHash() { - return codeHash; - } - - public String deviceBindingHash() { - return deviceBindingHash; - } - - public int maxAttempts() { - return maxAttempts; - } - - public int attempts() { - return attempts; - } - - public OtpChallengeStatus status() { - return status; - } - - public Instant createdAt() { - return createdAt; - } - - public Instant expiresAt() { - return expiresAt; - } - - public Instant retentionUntil() { - return retentionUntil; - } - - public Instant consumedAt() { - return consumedAt; - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeRepository.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeRepository.java deleted file mode 100644 index ebfdad4..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.amg.payguard.otp.domain; - -import java.util.Optional; -import java.util.UUID; - -public interface OtpChallengeRepository { - - void invalidateActive(String userId, OtpPurpose purpose); - - OtpChallenge save(OtpChallenge challenge); - - Optional findById(UUID challengeId); -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeStatus.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeStatus.java deleted file mode 100644 index 492a7f6..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeStatus.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.amg.payguard.otp.domain; - -public enum OtpChallengeStatus { - ACTIVE, - USED, - EXPIRED, - LOCKED, - INVALIDATED -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChannel.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChannel.java deleted file mode 100644 index 048564e..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChannel.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.amg.payguard.otp.domain; - -public enum OtpChannel { - SMS, - EMAIL, - PUSH, - AUTHENTICATOR_APP -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeGenerator.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeGenerator.java deleted file mode 100644 index 5a45023..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeGenerator.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.amg.payguard.otp.domain; - -@FunctionalInterface -public interface OtpCodeGenerator { - - String generate(); -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeHasher.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeHasher.java deleted file mode 100644 index b0b9d9d..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeHasher.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.amg.payguard.otp.domain; - -public interface OtpCodeHasher { - - String hash(String value); - - boolean matches(String value, String encodedHash); -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpEventPublisher.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpEventPublisher.java deleted file mode 100644 index 40e6aa0..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpEventPublisher.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.amg.payguard.otp.domain; - -import java.time.Instant; -import java.util.UUID; - -@FunctionalInterface -public interface OtpEventPublisher { - - void publish(OtpEvent event); - - record OtpEvent( - String type, - UUID challengeId, - String userId, - OtpPurpose purpose, - OtpChannel channel, - OtpVerificationResult result, - Instant occurredAt, - String reason) {} -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpPurpose.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpPurpose.java deleted file mode 100644 index 3f7b398..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpPurpose.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.amg.payguard.otp.domain; - -public enum OtpPurpose { - LOGIN, - TRANSFER_CONFIRM, - LOAN_DISBURSE, - COLLATERAL_RELEASE, - CARD_ISSUANCE, - PROFILE_CHANGE -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationMode.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationMode.java deleted file mode 100644 index 00aa23e..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationMode.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.amg.payguard.otp.domain; - -public enum OtpVerificationMode { - RANDOM_NUMERIC, - TOTP -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationResult.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationResult.java deleted file mode 100644 index 2b1b8b9..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationResult.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.amg.payguard.otp.domain; - -public enum OtpVerificationResult { - VERIFIED, - INVALID_CODE, - EXPIRED, - ALREADY_USED, - LOCKED, - INVALIDATED, - PURPOSE_MISMATCH, - DEVICE_MISMATCH -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/RateLimiter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/RateLimiter.java deleted file mode 100644 index d2d47db..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/RateLimiter.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.amg.payguard.otp.domain; - -public interface RateLimiter { - - boolean allowIssue(String userId, OtpPurpose purpose, String sourceIp); - - boolean allowVerify(String userId, OtpPurpose purpose, String sourceIp); -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/StepUpTokenPort.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/StepUpTokenPort.java deleted file mode 100644 index f9e27c2..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/StepUpTokenPort.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.amg.payguard.otp.domain; - -import java.time.Instant; - -public interface StepUpTokenPort { - - IssuedStepUpToken issue(String userId, OtpPurpose purpose, Instant now); - - boolean consume(String token, String userId, OtpPurpose purpose, Instant now); - - record IssuedStepUpToken(String token, Instant expiresAt) {} -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/config/OtpConfiguration.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/config/OtpConfiguration.java deleted file mode 100644 index b301dd9..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/config/OtpConfiguration.java +++ /dev/null @@ -1,58 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.config; - -import dev.amg.payguard.otp.application.OtpApplicationService; -import dev.amg.payguard.otp.domain.AuthenticatorCodeVerifier; -import dev.amg.payguard.otp.domain.OtpAuditLogPort; -import dev.amg.payguard.otp.domain.OtpChallengeRepository; -import dev.amg.payguard.otp.domain.OtpCodeGenerator; -import dev.amg.payguard.otp.domain.OtpCodeHasher; -import dev.amg.payguard.otp.domain.OtpEventPublisher; -import dev.amg.payguard.otp.domain.RateLimiter; -import dev.amg.payguard.otp.domain.StepUpTokenPort; -import java.time.Clock; -import java.time.Duration; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class OtpConfiguration { - - @Bean - Clock otpClock() { - return Clock.systemUTC(); - } - - @Bean - OtpApplicationService otpApplicationService( - OtpChallengeRepository challenges, - OtpCodeHasher hasher, - OtpCodeGenerator generator, - AuthenticatorCodeVerifier authenticatorCodeVerifier, - dev.amg.payguard.otp.domain.DeliveryPort delivery, - RateLimiter rateLimiter, - StepUpTokenPort stepUpTokens, - OtpEventPublisher events, - OtpAuditLogPort audit, - Clock otpClock, - @Value("${otp.challenge.random-ttl:PT5M}") Duration randomTtl, - @Value("${otp.challenge.totp-ttl:PT30S}") Duration totpTtl, - @Value("${otp.audit.retention:PT2160H}") Duration auditRetention, - @Value("${otp.challenge.max-attempts:5}") int maxAttempts) { - return new OtpApplicationService( - challenges, - hasher, - generator, - authenticatorCodeVerifier, - delivery, - rateLimiter, - stepUpTokens, - events, - audit, - otpClock, - randomTtl, - totpTtl, - auditRetention, - maxAttempts); - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/EmailDeliveryAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/EmailDeliveryAdapter.java deleted file mode 100644 index 9cf1b97..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/EmailDeliveryAdapter.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.delivery; - -import dev.amg.payguard.otp.domain.DeliveryPort; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -/** TLS provider integration seam for email. The provider request must never be logged. */ -@Component -@ConditionalOnProperty(name = "otp.delivery.mode", havingValue = "email") -public class EmailDeliveryAdapter implements DeliveryPort { - - @Override - public void deliver(DeliveryMessage message) { - // Integrate with the email provider over TLS; do not log message.code. - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/MockDeliveryAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/MockDeliveryAdapter.java deleted file mode 100644 index 112c177..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/MockDeliveryAdapter.java +++ /dev/null @@ -1,19 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.delivery; - -import dev.amg.payguard.otp.domain.DeliveryPort; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -/** - * Safe local adapter. It deliberately discards the code and never logs it. A provider adapter is - * selected in production without changing the domain or application layer. - */ -@Component -@ConditionalOnProperty(name = "otp.delivery.mode", havingValue = "mock", matchIfMissing = true) -public class MockDeliveryAdapter implements DeliveryPort { - - @Override - public void deliver(DeliveryMessage message) { - // Provider calls belong here. Never log or persist message.code. - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/PushDeliveryAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/PushDeliveryAdapter.java deleted file mode 100644 index fbd0038..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/PushDeliveryAdapter.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.delivery; - -import dev.amg.payguard.otp.domain.DeliveryPort; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -/** TLS provider integration seam for push notifications. */ -@Component -@ConditionalOnProperty(name = "otp.delivery.mode", havingValue = "push") -public class PushDeliveryAdapter implements DeliveryPort { - - @Override - public void deliver(DeliveryMessage message) { - // Integrate with the push provider over TLS; do not log message.code. - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/SmsDeliveryAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/SmsDeliveryAdapter.java deleted file mode 100644 index 6e0d081..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/SmsDeliveryAdapter.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.delivery; - -import dev.amg.payguard.otp.domain.DeliveryPort; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -/** TLS provider integration seam for SMS. The provider request must never be logged. */ -@Component -@ConditionalOnProperty(name = "otp.delivery.mode", havingValue = "sms") -public class SmsDeliveryAdapter implements DeliveryPort { - - @Override - public void deliver(DeliveryMessage message) { - // Integrate with the SMS provider over TLS; do not log message.code. - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/kafka/KafkaOtpEventPublisher.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/kafka/KafkaOtpEventPublisher.java deleted file mode 100644 index 4aeff54..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/kafka/KafkaOtpEventPublisher.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.kafka; - -import dev.amg.payguard.otp.domain.OtpEventPublisher; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.stereotype.Component; -import tools.jackson.databind.ObjectMapper; - -@Component -public class KafkaOtpEventPublisher implements OtpEventPublisher { - - private final KafkaTemplate kafka; - private final ObjectMapper objectMapper; - private final String topic; - - public KafkaOtpEventPublisher( - KafkaTemplate kafka, - ObjectMapper objectMapper, - @Value("${otp.kafka.topic:otp.events.v1}") String topic) { - this.kafka = kafka; - this.objectMapper = objectMapper; - this.topic = topic; - } - - @Override - public void publish(OtpEvent event) { - try { - kafka.send(topic, event.challengeId().toString(), objectMapper.writeValueAsString(event)); - } catch (tools.jackson.core.JacksonException exception) { - throw new IllegalStateException("Unable to serialize OTP event", exception); - } - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpAuditLogAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpAuditLogAdapter.java deleted file mode 100644 index 0ee2220..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpAuditLogAdapter.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.persistence; - -import dev.amg.payguard.otp.domain.OtpAuditLogPort; -import java.time.Instant; -import java.util.UUID; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -public class JpaOtpAuditLogAdapter implements OtpAuditLogPort { - - private final OtpAuditLogJpaRepository repository; - - public JpaOtpAuditLogAdapter(OtpAuditLogJpaRepository repository) { - this.repository = repository; - } - - @Override - @Transactional - public void append( - UUID challengeId, String userId, String action, String reason, String sourceIp, Instant at) { - repository.save(new OtpAuditLogEntity(challengeId, userId, action, reason, sourceIp, at)); - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpChallengeRepositoryAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpChallengeRepositoryAdapter.java deleted file mode 100644 index a377d36..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpChallengeRepositoryAdapter.java +++ /dev/null @@ -1,55 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.persistence; - -import dev.amg.payguard.otp.domain.OtpChallenge; -import dev.amg.payguard.otp.domain.OtpChallengeRepository; -import dev.amg.payguard.otp.domain.OtpChallengeStatus; -import dev.amg.payguard.otp.domain.OtpPurpose; -import java.util.Optional; -import java.util.UUID; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -public class JpaOtpChallengeRepositoryAdapter implements OtpChallengeRepository { - - private final OtpChallengeJpaRepository repository; - - public JpaOtpChallengeRepositoryAdapter(OtpChallengeJpaRepository repository) { - this.repository = repository; - } - - @Override - @Transactional - public void invalidateActive(String userId, OtpPurpose purpose) { - repository - .findByUserIdAndPurposeAndStatus(userId, purpose, OtpChallengeStatus.ACTIVE) - .forEach( - entity -> { - OtpChallenge challenge = entity.toDomain(); - challenge.invalidate(); - entity.copyFrom(challenge); - repository.save(entity); - }); - } - - @Override - @Transactional - public OtpChallenge save(OtpChallenge challenge) { - OtpChallengeEntity entity = - repository - .findById(challenge.id()) - .map( - existing -> { - existing.copyFrom(challenge); - return existing; - }) - .orElseGet(() -> OtpChallengeEntity.from(challenge)); - return repository.save(entity).toDomain(); - } - - @Override - @Transactional(readOnly = true) - public Optional findById(UUID challengeId) { - return repository.findById(challengeId).map(OtpChallengeEntity::toDomain); - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogEntity.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogEntity.java deleted file mode 100644 index 081a596..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogEntity.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.persistence; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import java.time.Instant; -import java.util.UUID; - -@Entity -@Table(name = "otp_audit_log") -public class OtpAuditLogEntity { - - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; - - @Column(name = "challenge_id", nullable = false) - private UUID challengeId; - - @Column(name = "user_id", nullable = false, length = 255) - private String userId; - - @Column(nullable = false, length = 32) - private String action; - - @Column(nullable = false, length = 128) - private String reason; - - @Column(name = "source_ip", length = 64) - private String sourceIp; - - @Column(name = "occurred_at", nullable = false) - private Instant occurredAt; - - protected OtpAuditLogEntity() {} - - OtpAuditLogEntity( - UUID challengeId, - String userId, - String action, - String reason, - String sourceIp, - Instant occurredAt) { - this.challengeId = challengeId; - this.userId = userId; - this.action = action; - this.reason = reason; - this.sourceIp = sourceIp; - this.occurredAt = occurredAt; - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogJpaRepository.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogJpaRepository.java deleted file mode 100644 index af87577..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.persistence; - -import java.util.UUID; -import org.springframework.data.jpa.repository.JpaRepository; - -interface OtpAuditLogJpaRepository extends JpaRepository {} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeEntity.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeEntity.java deleted file mode 100644 index e3a2da5..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeEntity.java +++ /dev/null @@ -1,126 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.persistence; - -import dev.amg.payguard.otp.domain.OtpChallenge; -import dev.amg.payguard.otp.domain.OtpChallengeStatus; -import dev.amg.payguard.otp.domain.OtpChannel; -import dev.amg.payguard.otp.domain.OtpPurpose; -import dev.amg.payguard.otp.domain.OtpVerificationMode; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import java.time.Instant; -import java.util.UUID; - -@Entity -@Table(name = "otp_challenge") -public class OtpChallengeEntity { - - @Id private UUID id; - - @Column(name = "user_id", nullable = false, length = 255) - private String userId; - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 32) - private OtpPurpose purpose; - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 32) - private OtpChannel channel; - - @Enumerated(EnumType.STRING) - @Column(name = "verification_mode", nullable = false, length = 32) - private OtpVerificationMode verificationMode; - - @Column(name = "code_hash", length = 512) - private String codeHash; - - @Column(name = "device_binding_hash", length = 512) - private String deviceBindingHash; - - @Column(name = "attempts", nullable = false) - private int attempts; - - @Column(name = "max_attempts", nullable = false) - private int maxAttempts; - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) - private OtpChallengeStatus status; - - @Column(name = "created_at", nullable = false) - private Instant createdAt; - - @Column(name = "expires_at", nullable = false) - private Instant expiresAt; - - @Column(name = "consumed_at") - private Instant consumedAt; - - @Column(name = "retention_until", nullable = false) - private Instant retentionUntil; - - protected OtpChallengeEntity() {} - - private OtpChallengeEntity(OtpChallenge challenge) { - copyFrom(challenge); - } - - static OtpChallengeEntity from(OtpChallenge challenge) { - return new OtpChallengeEntity(challenge); - } - - final void copyFrom(OtpChallenge challenge) { - id = challenge.id(); - userId = challenge.userId(); - purpose = challenge.purpose(); - channel = challenge.channel(); - verificationMode = challenge.verificationMode(); - codeHash = challenge.codeHash(); - deviceBindingHash = challenge.deviceBindingHash(); - attempts = challenge.attempts(); - maxAttempts = challenge.maxAttempts(); - status = challenge.status(); - createdAt = challenge.createdAt(); - expiresAt = challenge.expiresAt(); - consumedAt = challenge.consumedAt(); - retentionUntil = challenge.retentionUntil(); - } - - OtpChallenge toDomain() { - return OtpChallenge.restore( - id, - userId, - purpose, - channel, - verificationMode, - codeHash, - deviceBindingHash, - maxAttempts, - createdAt, - expiresAt, - retentionUntil, - attempts, - status, - consumedAt); - } - - public UUID getId() { - return id; - } - - public String getUserId() { - return userId; - } - - public OtpPurpose getPurpose() { - return purpose; - } - - public OtpChallengeStatus getStatus() { - return status; - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeJpaRepository.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeJpaRepository.java deleted file mode 100644 index a97a54f..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeJpaRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.persistence; - -import dev.amg.payguard.otp.domain.OtpChallengeStatus; -import dev.amg.payguard.otp.domain.OtpPurpose; -import java.util.List; -import java.util.UUID; -import org.springframework.data.jpa.repository.JpaRepository; - -interface OtpChallengeJpaRepository extends JpaRepository { - - List findByUserIdAndPurposeAndStatus( - String userId, OtpPurpose purpose, OtpChallengeStatus status); -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisRateLimiterAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisRateLimiterAdapter.java deleted file mode 100644 index 7f61194..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisRateLimiterAdapter.java +++ /dev/null @@ -1,53 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.redis; - -import dev.amg.payguard.otp.domain.OtpPurpose; -import dev.amg.payguard.otp.domain.RateLimiter; -import java.time.Duration; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; - -@Component -public class RedisRateLimiterAdapter implements RateLimiter { - - private final StringRedisTemplate redis; - private final int maxIssues; - private final int maxVerifications; - private final Duration window; - - public RedisRateLimiterAdapter( - StringRedisTemplate redis, - @Value("${otp.rate-limit.max-issues:3}") int maxIssues, - @Value("${otp.rate-limit.max-verifications:20}") int maxVerifications, - @Value("${otp.rate-limit.window:PT15M}") Duration window) { - this.redis = redis; - this.maxIssues = maxIssues; - this.maxVerifications = maxVerifications; - this.window = window; - } - - @Override - public boolean allowIssue(String userId, OtpPurpose purpose, String sourceIp) { - return allow("issue", userId, purpose, sourceIp, maxIssues); - } - - @Override - public boolean allowVerify(String userId, OtpPurpose purpose, String sourceIp) { - return allow("verify", userId, purpose, sourceIp, maxVerifications); - } - - private boolean allow( - String operation, String userId, OtpPurpose purpose, String sourceIp, int maximum) { - String key = - "otp:rate:" + operation + ":" + safe(userId) + ":" + purpose + ":" + safe(sourceIp); - Long count = redis.opsForValue().increment(key); - if (count != null && count == 1L) { - redis.expire(key, window); - } - return count != null && count <= maximum; - } - - private static String safe(String value) { - return value == null || value.isBlank() ? "unknown" : value.replaceAll("[^A-Za-z0-9._-]", "_"); - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisStepUpTokenAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisStepUpTokenAdapter.java deleted file mode 100644 index a5386e8..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisStepUpTokenAdapter.java +++ /dev/null @@ -1,69 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.redis; - -import dev.amg.payguard.otp.domain.OtpPurpose; -import dev.amg.payguard.otp.domain.StepUpTokenPort; -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.time.Duration; -import java.time.Instant; -import java.util.Base64; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; - -@Component -public class RedisStepUpTokenAdapter implements StepUpTokenPort { - - private static final int TOKEN_PARTS = 3; - private final StringRedisTemplate redis; - private final Duration ttl; - private final SecureRandom random = new SecureRandom(); - - public RedisStepUpTokenAdapter( - StringRedisTemplate redis, @Value("${otp.step-up-token.ttl:PT3M}") Duration ttl) { - this.redis = redis; - this.ttl = ttl; - } - - @Override - public IssuedStepUpToken issue(String userId, OtpPurpose purpose, Instant now) { - byte[] bytes = new byte[32]; - random.nextBytes(bytes); - String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); - Instant expiresAt = now.plus(ttl); - String value = - Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(userId.getBytes(StandardCharsets.UTF_8)) - + "|" - + purpose - + "|" - + expiresAt.toEpochMilli(); - redis.opsForValue().set("otp:step-up:" + token, value, ttl); - return new IssuedStepUpToken(token, expiresAt); - } - - @Override - public boolean consume(String token, String userId, OtpPurpose purpose, Instant now) { - if (token == null || token.isBlank()) { - return false; - } - String value = redis.opsForValue().getAndDelete("otp:step-up:" + token); - if (value == null) { - return false; - } - String[] parts = value.split("\\|", -1); - if (parts.length != TOKEN_PARTS) { - return false; - } - try { - String storedUser = - new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8); - return storedUser.equals(userId) - && purpose.name().equals(parts[1]) - && now.isBefore(Instant.ofEpochMilli(Long.parseLong(parts[2]))); - } catch (IllegalArgumentException exception) { - return false; - } - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/AuthenticatorSecretPort.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/AuthenticatorSecretPort.java deleted file mode 100644 index 24945d1..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/AuthenticatorSecretPort.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.security; - -import java.util.Optional; - -@FunctionalInterface -public interface AuthenticatorSecretPort { - - Optional secretFor(String userId); -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasher.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasher.java deleted file mode 100644 index e092043..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasher.java +++ /dev/null @@ -1,63 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.security; - -import dev.amg.payguard.otp.domain.OtpCodeHasher; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.SecureRandom; -import java.util.HexFormat; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -@Component -public final class HmacOtpCodeHasher implements OtpCodeHasher { - - private static final String ALGORITHM = "HmacSHA256"; - private static final int HASH_PARTS = 2; - private final SecureRandom secureRandom = new SecureRandom(); - private final byte[] pepper; - - public HmacOtpCodeHasher(@Value("${otp.security.pepper}") String pepper) { - if (pepper == null || pepper.isBlank() || pepper.length() < 32) { - throw new IllegalArgumentException("otp.security.pepper must contain at least 32 characters"); - } - this.pepper = pepper.getBytes(StandardCharsets.UTF_8); - } - - @Override - public String hash(String value) { - byte[] salt = new byte[16]; - secureRandom.nextBytes(salt); - return HexFormat.of().formatHex(salt) + ":" + HexFormat.of().formatHex(digest(value, salt)); - } - - @Override - public boolean matches(String value, String encodedHash) { - if (value == null || encodedHash == null) { - return false; - } - try { - String[] parts = encodedHash.split(":", -1); - if (parts.length != HASH_PARTS) { - return false; - } - byte[] salt = HexFormat.of().parseHex(parts[0]); - byte[] expected = HexFormat.of().parseHex(parts[1]); - return MessageDigest.isEqual(expected, digest(value, salt)); - } catch (IllegalArgumentException exception) { - return false; - } - } - - private byte[] digest(String value, byte[] salt) { - try { - Mac mac = Mac.getInstance(ALGORITHM); - mac.init(new SecretKeySpec(pepper, ALGORITHM)); - mac.update(salt); - return mac.doFinal(value.getBytes(StandardCharsets.UTF_8)); - } catch (java.security.GeneralSecurityException exception) { - throw new IllegalStateException("HMAC-SHA256 is unavailable", exception); - } - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/SecureNumericOtpGenerator.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/SecureNumericOtpGenerator.java deleted file mode 100644 index 572fbff..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/SecureNumericOtpGenerator.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.security; - -import dev.amg.payguard.otp.domain.OtpCodeGenerator; -import java.security.SecureRandom; -import org.springframework.stereotype.Component; - -@Component -public final class SecureNumericOtpGenerator implements OtpCodeGenerator { - - private final SecureRandom random = new SecureRandom(); - - @Override - public String generate() { - return "%06d".formatted(random.nextInt(1_000_000)); - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/TotpAuthenticatorCodeVerifier.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/TotpAuthenticatorCodeVerifier.java deleted file mode 100644 index 3393ef0..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/TotpAuthenticatorCodeVerifier.java +++ /dev/null @@ -1,81 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.security; - -import dev.amg.payguard.otp.domain.AuthenticatorCodeVerifier; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.Locale; -import java.util.Optional; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import org.springframework.stereotype.Component; - -@Component -public final class TotpAuthenticatorCodeVerifier implements AuthenticatorCodeVerifier { - - private static final int DIGITS = 6; - private static final long STEP_SECONDS = 30; - private final AuthenticatorSecretPort secrets; - - public TotpAuthenticatorCodeVerifier(AuthenticatorSecretPort secrets) { - this.secrets = secrets; - } - - @Override - public boolean verify(String userId, String code, Instant at) { - if (code == null || !code.matches("\\d{" + DIGITS + "}")) { - return false; - } - Optional secret = secrets.secretFor(userId); - if (secret.isEmpty()) { - return false; - } - long counter = at.getEpochSecond() / STEP_SECONDS; - for (long offset = -1; offset <= 1; offset++) { - if (constantTimeEquals(code, generate(secret.get(), counter + offset))) { - return true; - } - } - return false; - } - - private String generate(String encodedSecret, long counter) { - try { - byte[] secret = decodeBase32(encodedSecret); - Mac mac = Mac.getInstance("HmacSHA1"); - mac.init(new SecretKeySpec(secret, "HmacSHA1")); - byte[] digest = mac.doFinal(ByteBuffer.allocate(Long.BYTES).putLong(counter).array()); - int offset = digest[digest.length - 1] & 0x0f; - int binary = - ((digest[offset] & 0x7f) << 24) - | ((digest[offset + 1] & 0xff) << 16) - | ((digest[offset + 2] & 0xff) << 8) - | (digest[offset + 3] & 0xff); - return "%06d".formatted(binary % 1_000_000); - } catch (java.security.GeneralSecurityException | IllegalArgumentException exception) { - return ""; - } - } - - private static byte[] decodeBase32(String input) { - String normalized = input.replace("=", "").replace(" ", "").toUpperCase(Locale.ROOT); - StringBuilder bits = new StringBuilder(); - for (char character : normalized.toCharArray()) { - int value = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(character); - if (value < 0) { - throw new IllegalArgumentException("Invalid base32 secret"); - } - bits.append(String.format("%5s", Integer.toBinaryString(value)).replace(' ', '0')); - } - byte[] decoded = new byte[bits.length() / 8]; - for (int index = 0; index < decoded.length; index++) { - decoded[index] = (byte) Integer.parseInt(bits.substring(index * 8, index * 8 + 8), 2); - } - return decoded; - } - - private static boolean constantTimeEquals(String left, String right) { - return java.security.MessageDigest.isEqual( - left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8)); - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java deleted file mode 100644 index defb71b..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.security; - -import java.util.Optional; -import org.springframework.stereotype.Component; - -/** Resolves no secrets by default; production wiring supplies a vault-backed implementation. */ -@Component -public final class UnavailableAuthenticatorSecretAdapter implements AuthenticatorSecretPort { - - @Override - public Optional secretFor(String userId) { - return Optional.empty(); - } -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpController.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpController.java deleted file mode 100644 index 78d96e1..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpController.java +++ /dev/null @@ -1,90 +0,0 @@ -package dev.amg.payguard.otp.interfaces.rest; - -import dev.amg.payguard.otp.application.IssueOtpCommand; -import dev.amg.payguard.otp.application.IssuedOtp; -import dev.amg.payguard.otp.application.OtpApplicationService; -import dev.amg.payguard.otp.application.VerifiedOtp; -import dev.amg.payguard.otp.application.VerifyOtpCommand; -import dev.amg.payguard.otp.domain.OtpChannel; -import dev.amg.payguard.otp.domain.OtpPurpose; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; -import java.time.Instant; -import java.util.UUID; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/otp/challenges") -public class OtpController { - - private final OtpApplicationService service; - - public OtpController(OtpApplicationService service) { - this.service = service; - } - - @PostMapping - public ResponseEntity issue( - @Valid @RequestBody IssueOtpRequest request, HttpServletRequest httpRequest) { - IssuedOtp issued = - service.issue( - new IssueOtpCommand( - request.userId(), - request.purpose(), - request.channel(), - request.destinationRef(), - request.deviceFingerprint(), - sourceIp(httpRequest))); - return ResponseEntity.ok( - new IssueOtpResponse( - issued.challengeId(), issued.purpose(), issued.channel(), issued.expiresAt())); - } - - @PostMapping("/{challengeId}/verify") - public ResponseEntity verify( - @PathVariable UUID challengeId, - @Valid @RequestBody VerifyOtpRequest request, - HttpServletRequest httpRequest) { - VerifiedOtp verified = - service.verify( - new VerifyOtpCommand( - challengeId, - request.userId(), - request.purpose(), - request.code(), - request.deviceFingerprint(), - sourceIp(httpRequest))); - return ResponseEntity.ok( - new VerifyOtpResponse(verified.stepUpToken(), verified.purpose(), verified.expiresAt())); - } - - private static String sourceIp(HttpServletRequest request) { - return request.getRemoteAddr(); - } - - public record IssueOtpRequest( - @NotBlank String userId, - @NotNull OtpPurpose purpose, - @NotNull OtpChannel channel, - String destinationRef, - String deviceFingerprint) {} - - public record VerifyOtpRequest( - @NotBlank String userId, - @NotNull OtpPurpose purpose, - @NotBlank @Pattern(regexp = "\\d{6}") String code, - String deviceFingerprint) {} - - public record IssueOtpResponse( - UUID challengeId, OtpPurpose purpose, OtpChannel channel, Instant expiresAt) {} - - public record VerifyOtpResponse(String stepUpToken, OtpPurpose purpose, Instant expiresAt) {} -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpExceptionHandler.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpExceptionHandler.java deleted file mode 100644 index 6c01fd3..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpExceptionHandler.java +++ /dev/null @@ -1,42 +0,0 @@ -package dev.amg.payguard.otp.interfaces.rest; - -import dev.amg.payguard.otp.application.OtpChallengeNotFoundException; -import dev.amg.payguard.otp.application.OtpRateLimitExceededException; -import dev.amg.payguard.otp.application.OtpVerificationException; -import java.time.Instant; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -@RestControllerAdvice -public class OtpExceptionHandler { - - @ExceptionHandler(OtpChallengeNotFoundException.class) - ResponseEntity notFound(OtpChallengeNotFoundException exception) { - return response(HttpStatus.NOT_FOUND, "challenge_not_found"); - } - - @ExceptionHandler(OtpRateLimitExceededException.class) - ResponseEntity rateLimited(OtpRateLimitExceededException exception) { - return response(HttpStatus.TOO_MANY_REQUESTS, "rate_limit_exceeded"); - } - - @ExceptionHandler(OtpVerificationException.class) - ResponseEntity verificationFailed(OtpVerificationException exception) { - HttpStatus status = - switch (exception.result()) { - case LOCKED -> HttpStatus.TOO_MANY_REQUESTS; - case ALREADY_USED, INVALIDATED -> HttpStatus.CONFLICT; - case EXPIRED -> HttpStatus.GONE; - default -> HttpStatus.UNAUTHORIZED; - }; - return response(status, exception.result().name().toLowerCase(java.util.Locale.ROOT)); - } - - private static ResponseEntity response(HttpStatus status, String code) { - return ResponseEntity.status(status).body(new ErrorResponse(code, Instant.now())); - } - - record ErrorResponse(String code, Instant timestamp) {} -} diff --git a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/StepUpTokenController.java b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/StepUpTokenController.java deleted file mode 100644 index 4466703..0000000 --- a/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/StepUpTokenController.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.amg.payguard.otp.interfaces.rest; - -import dev.amg.payguard.otp.domain.OtpPurpose; -import dev.amg.payguard.otp.domain.StepUpTokenPort; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import java.time.Clock; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/otp/step-up-tokens") -public class StepUpTokenController { - - private final StepUpTokenPort tokens; - private final Clock clock; - - public StepUpTokenController(StepUpTokenPort tokens, Clock clock) { - this.tokens = tokens; - this.clock = clock; - } - - @PostMapping("/consume") - public ResponseEntity consume(@Valid @RequestBody ConsumeTokenRequest request) { - boolean accepted = - tokens.consume(request.token(), request.userId(), request.purpose(), clock.instant()); - return accepted - ? ResponseEntity.noContent().build() - : ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - - public record ConsumeTokenRequest( - @NotBlank String token, @NotBlank String userId, @NotNull OtpPurpose purpose) {} -} diff --git a/payguard-otp-service/src/main/resources/application.yaml b/payguard-otp-service/src/main/resources/application.yaml deleted file mode 100644 index dc0c2d2..0000000 --- a/payguard-otp-service/src/main/resources/application.yaml +++ /dev/null @@ -1,46 +0,0 @@ -spring: - application: - name: payguard-otp-service - datasource: - url: ${SPRING_DATASOURCE_URL} - username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} - driver-class-name: oracle.jdbc.OracleDriver - jpa: - open-in-view: false - hibernate: - ddl-auto: none - flyway: - enabled: true - data: - redis: - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} - kafka: - bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} - producer: - key-serializer: org.apache.kafka.common.serialization.StringSerializer - value-serializer: org.apache.kafka.common.serialization.StringSerializer - -server: - port: ${OTP_SERVICE_PORT:8084} - -otp: - security: - pepper: ${OTP_SECURITY_PEPPER:change-this-otp-pepper-in-production-32-chars} - delivery: - mode: ${OTP_DELIVERY_MODE:mock} - challenge: - random-ttl: ${OTP_RANDOM_TTL:PT5M} - totp-ttl: ${OTP_TOTP_TTL:PT30S} - max-attempts: ${OTP_MAX_ATTEMPTS:5} - rate-limit: - max-issues: ${OTP_MAX_ISSUES:3} - max-verifications: ${OTP_MAX_VERIFICATIONS:20} - window: ${OTP_RATE_LIMIT_WINDOW:PT15M} - step-up-token: - ttl: ${OTP_STEP_UP_TOKEN_TTL:PT3M} - audit: - retention: ${OTP_AUDIT_RETENTION:PT2160H} - kafka: - topic: ${OTP_KAFKA_TOPIC:otp.events.v1} diff --git a/payguard-otp-service/src/main/resources/banner.txt b/payguard-otp-service/src/main/resources/banner.txt deleted file mode 100644 index 83938d4..0000000 --- a/payguard-otp-service/src/main/resources/banner.txt +++ /dev/null @@ -1,6 +0,0 @@ - ____ ____ _ - | _ \ __ _ _ _/ ___|_ _ __ _ _ __ __| | - | |_) / _` | | | \___ \ | | |/ _` | '__/ _` | - | __/ (_| | |_| |___) || |_| (_| | | | (_| | - |_| \__,_|\__, |____/ \__,\__,_|_| \__,_| - |___/ diff --git a/payguard-otp-service/src/main/resources/db/migration/V1__otp_schema.sql b/payguard-otp-service/src/main/resources/db/migration/V1__otp_schema.sql deleted file mode 100644 index 5f6084a..0000000 --- a/payguard-otp-service/src/main/resources/db/migration/V1__otp_schema.sql +++ /dev/null @@ -1,41 +0,0 @@ -CREATE TABLE otp_challenge ( - id RAW(16) NOT NULL, - user_id VARCHAR2(255 CHAR) NOT NULL, - purpose VARCHAR2(32 CHAR) NOT NULL, - channel VARCHAR2(32 CHAR) NOT NULL, - verification_mode VARCHAR2(32 CHAR) NOT NULL, - code_hash VARCHAR2(512 CHAR), - device_binding_hash VARCHAR2(512 CHAR), - attempts NUMBER(3) DEFAULT 0 NOT NULL, - max_attempts NUMBER(3) NOT NULL, - status VARCHAR2(20 CHAR) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - expires_at TIMESTAMP WITH TIME ZONE NOT NULL, - consumed_at TIMESTAMP WITH TIME ZONE, - retention_until TIMESTAMP WITH TIME ZONE NOT NULL, - CONSTRAINT pk_otp_challenge PRIMARY KEY (id), - CONSTRAINT ck_otp_challenge_attempts CHECK (attempts >= 0 AND attempts <= max_attempts) -); - -CREATE INDEX ix_otp_challenge_active - ON otp_challenge (user_id, purpose, status); -CREATE INDEX ix_otp_challenge_retention - ON otp_challenge (retention_until); - -CREATE TABLE otp_audit_log ( - id RAW(16) DEFAULT SYS_GUID() NOT NULL, - challenge_id RAW(16) NOT NULL, - user_id VARCHAR2(255 CHAR) NOT NULL, - action VARCHAR2(32 CHAR) NOT NULL, - reason VARCHAR2(128 CHAR) NOT NULL, - source_ip VARCHAR2(64 CHAR), - occurred_at TIMESTAMP WITH TIME ZONE NOT NULL, - CONSTRAINT pk_otp_audit_log PRIMARY KEY (id), - CONSTRAINT fk_otp_audit_challenge FOREIGN KEY (challenge_id) - REFERENCES otp_challenge (id) -); - -CREATE INDEX ix_otp_audit_subject_time - ON otp_audit_log (user_id, occurred_at); -CREATE INDEX ix_otp_audit_challenge - ON otp_audit_log (challenge_id, occurred_at); diff --git a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/application/OtpApplicationServiceTest.java b/payguard-otp-service/src/test/java/dev/amg/payguard/otp/application/OtpApplicationServiceTest.java deleted file mode 100644 index 3c12e36..0000000 --- a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/application/OtpApplicationServiceTest.java +++ /dev/null @@ -1,176 +0,0 @@ -package dev.amg.payguard.otp.application; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import dev.amg.payguard.otp.domain.DeliveryPort; -import dev.amg.payguard.otp.domain.OtpChallenge; -import dev.amg.payguard.otp.domain.OtpChallengeRepository; -import dev.amg.payguard.otp.domain.OtpChannel; -import dev.amg.payguard.otp.domain.OtpCodeHasher; -import dev.amg.payguard.otp.domain.OtpPurpose; -import dev.amg.payguard.otp.domain.OtpVerificationResult; -import dev.amg.payguard.otp.domain.RateLimiter; -import dev.amg.payguard.otp.domain.StepUpTokenPort; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class OtpApplicationServiceTest { - - private final FixedHasher hasher = new FixedHasher(); - private final InMemoryChallenges challenges = new InMemoryChallenges(); - private final CapturingDelivery delivery = new CapturingDelivery(); - private final StepUpTokenPort tokens = - new StepUpTokenPort() { - @Override - public IssuedStepUpToken issue(String userId, OtpPurpose purpose, Instant now) { - return new IssuedStepUpToken("step-up-token", now.plusSeconds(180)); - } - - @Override - public boolean consume(String token, String userId, OtpPurpose purpose, Instant now) { - return "step-up-token".equals(token); - } - }; - private OtpApplicationService service; - - @BeforeEach - void setUp() { - Instant now = Instant.parse("2026-01-01T00:00:00Z"); - service = - new OtpApplicationService( - challenges, - hasher, - () -> "123456", - (userId, code, at) -> false, - delivery, - new AllowAllRateLimiter(), - tokens, - event -> {}, - (challengeId, userId, action, reason, sourceIp, at) -> {}, - Clock.fixed(now, ZoneOffset.UTC), - Duration.ofMinutes(5), - Duration.ofSeconds(30), - Duration.ofDays(90), - 5); - } - - @Test - void issueAndVerifyReturnsSingleUseStepUpToken() { - IssuedOtp issued = - service.issue( - new IssueOtpCommand( - "user-1", OtpPurpose.LOGIN, OtpChannel.SMS, "identity:phone:1", null, "127.0.0.1")); - - assertEquals("123456", delivery.code); - VerifiedOtp verified = - service.verify( - new VerifyOtpCommand( - issued.challengeId(), "user-1", OtpPurpose.LOGIN, "123456", null, "127.0.0.1")); - - assertEquals("step-up-token", verified.stepUpToken()); - OtpVerificationException replay = - assertThrows( - OtpVerificationException.class, - () -> - service.verify( - new VerifyOtpCommand( - issued.challengeId(), - "user-1", - OtpPurpose.LOGIN, - "123456", - null, - "127.0.0.1"))); - assertEquals(OtpVerificationResult.ALREADY_USED, replay.result()); - } - - @Test - void wrongPurposeCannotVerifyChallenge() { - IssuedOtp issued = - service.issue( - new IssueOtpCommand( - "user-1", - OtpPurpose.LOGIN, - OtpChannel.EMAIL, - "identity:email:1", - null, - "127.0.0.1")); - - OtpVerificationException exception = - assertThrows( - OtpVerificationException.class, - () -> - service.verify( - new VerifyOtpCommand( - issued.challengeId(), - "user-1", - OtpPurpose.LOAN_DISBURSE, - "123456", - null, - "127.0.0.1"))); - assertEquals(OtpVerificationResult.PURPOSE_MISMATCH, exception.result()); - } - - private static final class FixedHasher implements 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 final class CapturingDelivery implements DeliveryPort { - private String code; - - @Override - public void deliver(DeliveryMessage message) { - code = message.code(); - } - } - - private static final class AllowAllRateLimiter implements RateLimiter { - @Override - public boolean allowIssue(String userId, OtpPurpose purpose, String sourceIp) { - return true; - } - - @Override - public boolean allowVerify(String userId, OtpPurpose purpose, String sourceIp) { - return true; - } - } - - private static final class InMemoryChallenges implements OtpChallengeRepository { - private final Map values = new HashMap<>(); - - @Override - public void invalidateActive(String userId, OtpPurpose purpose) { - values.values().stream() - .filter(challenge -> challenge.userId().equals(userId) && challenge.purpose() == purpose) - .forEach(OtpChallenge::invalidate); - } - - @Override - public OtpChallenge save(OtpChallenge challenge) { - values.put(challenge.id(), challenge); - return challenge; - } - - @Override - public Optional findById(UUID challengeId) { - return Optional.ofNullable(values.get(challengeId)); - } - } -} diff --git a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/domain/OtpChallengeTest.java b/payguard-otp-service/src/test/java/dev/amg/payguard/otp/domain/OtpChallengeTest.java deleted file mode 100644 index 8f80c7f..0000000 --- a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/domain/OtpChallengeTest.java +++ /dev/null @@ -1,102 +0,0 @@ -package dev.amg.payguard.otp.domain; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.time.Instant; -import java.util.UUID; -import org.junit.jupiter.api.Test; - -class OtpChallengeTest { - - private 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)); - } - }; - - @Test - void successfulVerificationIsSingleUse() { - Instant now = Instant.parse("2026-01-01T00:00:00Z"); - OtpChallenge challenge = challenge(now, 5); - - assertEquals( - OtpVerificationResult.VERIFIED, - challenge.verify(OtpPurpose.LOGIN, "123456", null, hasher, false, now)); - assertEquals( - OtpVerificationResult.ALREADY_USED, - challenge.verify(OtpPurpose.LOGIN, "123456", null, hasher, false, now.plusSeconds(1))); - } - - @Test - void fifthInvalidAttemptLocksChallenge() { - Instant now = Instant.parse("2026-01-01T00:00:00Z"); - OtpChallenge challenge = challenge(now, 2); - - assertEquals( - OtpVerificationResult.INVALID_CODE, - challenge.verify(OtpPurpose.LOGIN, "000000", null, hasher, false, now)); - assertEquals( - OtpVerificationResult.LOCKED, - challenge.verify(OtpPurpose.LOGIN, "000000", null, hasher, false, now)); - assertEquals( - OtpVerificationResult.LOCKED, - challenge.verify(OtpPurpose.LOGIN, "123456", null, hasher, false, now)); - } - - @Test - void expiredChallengeCannotBeVerified() { - Instant now = Instant.parse("2026-01-01T00:00:00Z"); - OtpChallenge challenge = challenge(now, 5); - - assertEquals( - OtpVerificationResult.EXPIRED, - challenge.verify(OtpPurpose.LOGIN, "123456", null, hasher, false, now.plusSeconds(300))); - } - - @Test - void deviceBindingIsRequiredWhenConfigured() { - Instant now = Instant.parse("2026-01-01T00:00:00Z"); - OtpChallenge challenge = - OtpChallenge.issue( - UUID.randomUUID(), - "user-1", - OtpPurpose.LOGIN, - OtpChannel.SMS, - OtpVerificationMode.RANDOM_NUMERIC, - hasher.hash("123456"), - hasher.hash("device-a"), - 5, - now, - now.plusSeconds(300), - now.plusSeconds(86_400)); - - assertEquals( - OtpVerificationResult.DEVICE_MISMATCH, - challenge.verify(OtpPurpose.LOGIN, "123456", "device-b", hasher, false, now)); - assertEquals( - OtpVerificationResult.VERIFIED, - challenge.verify(OtpPurpose.LOGIN, "123456", "device-a", hasher, false, now)); - } - - private static OtpChallenge challenge(Instant now, int maxAttempts) { - return OtpChallenge.issue( - UUID.randomUUID(), - "user-1", - OtpPurpose.LOGIN, - OtpChannel.SMS, - OtpVerificationMode.RANDOM_NUMERIC, - "hash:123456", - null, - maxAttempts, - now, - now.plusSeconds(300), - now.plusSeconds(86_400)); - } -} diff --git a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/OtpInfrastructureIntegrationTest.java b/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/OtpInfrastructureIntegrationTest.java deleted file mode 100644 index 19104dd..0000000 --- a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/OtpInfrastructureIntegrationTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.amg.payguard.otp.infrastructure; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.OracleContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -@Testcontainers -@EnabledIfEnvironmentVariable(named = "RUN_OTP_INTEGRATION_TESTS", matches = "true") -class OtpInfrastructureIntegrationTest { - - @Container - static final OracleContainer ORACLE = new OracleContainer("gvenzl/oracle-xe:21-slim-faststart"); - - @Container - static final GenericContainer REDIS = - new GenericContainer<>("redis:8-alpine").withExposedPorts(6379); - - @Test - void oracleAndRedisAreReachable() { - assertTrue(ORACLE.isRunning()); - assertTrue(REDIS.isRunning()); - assertTrue(ORACLE.getJdbcUrl().startsWith("jdbc:oracle:")); - } -} diff --git a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasherTest.java b/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasherTest.java deleted file mode 100644 index 779a0c4..0000000 --- a/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasherTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.amg.payguard.otp.infrastructure.security; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -class HmacOtpCodeHasherTest { - - private final HmacOtpCodeHasher hasher = - new HmacOtpCodeHasher("a-secure-test-pepper-with-at-least-32-chars"); - - @Test - void storesSaltedHashAndMatchesOnlyOriginalValue() { - String encoded = hasher.hash("123456"); - - assertNotEquals("123456", encoded); - assertTrue(hasher.matches("123456", encoded)); - assertFalse(hasher.matches("123457", encoded)); - assertFalse(hasher.matches("123456", "not-a-hash")); - } -} diff --git a/payguard-otp-service/src/test/resources/application-test.yaml b/payguard-otp-service/src/test/resources/application-test.yaml deleted file mode 100644 index 46c8f0f..0000000 --- a/payguard-otp-service/src/test/resources/application-test.yaml +++ /dev/null @@ -1,9 +0,0 @@ -spring: - flyway: - enabled: false - jpa: - open-in-view: false - -otp: - security: - pepper: test-pepper-with-at-least-32-characters From 23f4a77cf360d76c157343b659c92f4b0ef7bd7a Mon Sep 17 00:00:00 2001 From: Amir Golmoradi Date: Mon, 12 Oct 2026 10:51:29 +0330 Subject: [PATCH 2/5] Complete the Identity service replacement --- docs/tasks/identity-publication-repair.md | 22 ++ .../amg/payguard/identity/IdentityApp.java | 12 + .../application/IssueOtpCommand.java | 13 + .../verification/application/IssuedOtp.java | 9 + .../OtpChallengeNotFoundException.java | 12 + .../OtpRateLimitExceededException.java | 12 + .../application/OtpVerificationCommand.java | 18 ++ .../application/OtpVerificationException.java | 18 ++ .../verification/application/VerifiedOtp.java | 8 + .../port/AuthenticatorSecretPort.java | 10 + .../application/port/OtpDelivery.java | 10 + .../application/port/OtpDeliveryRequest.java | 20 ++ .../application/port/TotpVerification.java | 13 + .../application/port/VerificationAudit.java | 7 + .../port/VerificationAuditEntry.java | 22 ++ .../application/port/VerificationQuota.java | 20 ++ .../delivery/EmailDeliveryAdapter.java | 17 ++ .../delivery/MockDeliveryAdapter.java | 20 ++ .../delivery/PushDeliveryAdapter.java | 17 ++ .../delivery/SmsDeliveryAdapter.java | 17 ++ .../kafka/KafkaOtpEventPublisher.java | 56 ++++ .../persistence/JpaOtpAuditLogAdapter.java | 22 ++ .../persistence/OtpAuditLogEntity.java | 49 ++++ .../persistence/OtpAuditLogJpaRepository.java | 6 + .../redis/RedisRateLimiterAdapter.java | 54 ++++ .../security/OtpIdGenerator.java | 20 ++ .../security/SecureNumericOtpGenerator.java | 16 ++ .../TotpAuthenticatorCodeVerifier.java | 83 ++++++ ...UnavailableAuthenticatorSecretAdapter.java | 18 ++ .../interfaces/rest/OtpController.java | 97 +++++++ .../src/main/resources/banner.txt | 6 + .../OtpApplicationServiceTest.java | 180 +++++++++++++ .../domain/OTPDomainContractTest.java | 248 ++++++++++++++++++ .../delivery/DeliveryAdaptersTest.java | 32 +++ .../kafka/KafkaOtpEventPublisherTest.java | 44 ++++ .../security/TotpVerificationTest.java | 32 +++ .../interfaces/rest/OtpControllerTest.java | 104 ++++++++ pom.xml | 2 +- 38 files changed, 1365 insertions(+), 1 deletion(-) create mode 100644 docs/tasks/identity-publication-repair.md create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/IdentityApp.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssueOtpCommand.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssuedOtp.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpChallengeNotFoundException.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpRateLimitExceededException.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationCommand.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationException.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/VerifiedOtp.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/AuthenticatorSecretPort.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDelivery.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/TotpVerification.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationQuota.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/EmailDeliveryAdapter.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/MockDeliveryAdapter.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/PushDeliveryAdapter.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/SmsDeliveryAdapter.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisher.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOtpAuditLogAdapter.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogEntity.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogJpaRepository.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/redis/RedisRateLimiterAdapter.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/OtpIdGenerator.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/SecureNumericOtpGenerator.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpAuthenticatorCodeVerifier.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java create mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpController.java create mode 100644 payguard-identity-service/src/main/resources/banner.txt create mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationServiceTest.java create mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPDomainContractTest.java create mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/delivery/DeliveryAdaptersTest.java create mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisherTest.java create mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpVerificationTest.java create mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpControllerTest.java diff --git a/docs/tasks/identity-publication-repair.md b/docs/tasks/identity-publication-repair.md new file mode 100644 index 0000000..436ec5f --- /dev/null +++ b/docs/tasks/identity-publication-repair.md @@ -0,0 +1,22 @@ +# Identity publication repair + +## Context and scope + +The owner's local commit `68e4bd0` deleted the retired OTP module, but its committed parent POM still selected that module. The pre-push hook checks a detached committed worktree, so it failed before formatting or compilation. GitHub separately rejected a direct main push because a pull request and CodeQL results are required. + +The repair branch `amg-complete-identity-service` starts from local main and retains that deletion commit. Its companion change registers Identity and includes the 36 previously local application/bootstrap/adapter/test files needed by the existing service. Five unused future ID classes, CLAUDE.md, an unrelated ADR rename and protected local artifacts remain outside this publication. The Flyway environment configuration fix is committed separately. + +## Checklist + +- [x] Inspect the committed snapshot, working tree, migration notes and CI. +- [x] Preserve the original deletion commit and create an amg branch from main. +- [x] Record original file hashes and tracked edits in the ignored local repair directory. +- [ ] Commit the complete Identity module selection and source dependency closure. +- [ ] Verify the committed source from a clean worktree; resolve actual formatting, compilation, test and analysis failures without weakening checks. +- [ ] Commit the independently verified native Flyway environment fix and documentation. +- [ ] Push normally, open a PR to main and inspect GitHub quality/security/CodeQL results. +- [ ] Leave local main aligned with remote main, preserving the owner's deletion commit on the repair branch. + +## Evidence boundary + +Verification is pending for this publication. Earlier 95-test migration results were from a different full working checkout and do not establish this branch's result. This task does not implement new authentication, migrate live Oracle data, deploy services, disable repository rules, rebase history or force-push. diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/IdentityApp.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/IdentityApp.java new file mode 100644 index 0000000..3e55086 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/IdentityApp.java @@ -0,0 +1,12 @@ +package dev.amg.payguard.identity; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class IdentityApp { + + public static void main(String[] args) { + SpringApplication.run(dev.amg.payguard.identity.IdentityApp.class, args); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssueOtpCommand.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssueOtpCommand.java new file mode 100644 index 0000000..0d7a946 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssueOtpCommand.java @@ -0,0 +1,13 @@ +package dev.amg.payguard.identity.verification.application; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; + +public record IssueOtpCommand( + CustomerId customerId, + OtpPurpose purpose, + OtpChannel channel, + String destinationRef, + String deviceFingerprint, + String sourceIp) {} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssuedOtp.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssuedOtp.java new file mode 100644 index 0000000..b995934 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssuedOtp.java @@ -0,0 +1,9 @@ +package dev.amg.payguard.identity.verification.application; + +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 java.time.Instant; + +public record IssuedOtp( + OtpId challengeId, OtpPurpose purpose, OtpChannel channel, Instant expiresAt) {} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpChallengeNotFoundException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpChallengeNotFoundException.java new file mode 100644 index 0000000..b853009 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpChallengeNotFoundException.java @@ -0,0 +1,12 @@ +package dev.amg.payguard.identity.verification.application; + +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; + +public class OtpChallengeNotFoundException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public OtpChallengeNotFoundException(OtpId challengeId) { + super("OTP challenge not found: " + challengeId); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpRateLimitExceededException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpRateLimitExceededException.java new file mode 100644 index 0000000..801d3b0 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpRateLimitExceededException.java @@ -0,0 +1,12 @@ +package dev.amg.payguard.identity.verification.application; + +import java.io.Serial; + +public class OtpRateLimitExceededException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + + public OtpRateLimitExceededException() { + super("OTP rate limit exceeded"); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationCommand.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationCommand.java new file mode 100644 index 0000000..f4095b8 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationCommand.java @@ -0,0 +1,18 @@ +package dev.amg.payguard.identity.verification.application; + +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.OtpPurpose; + +public record OtpVerificationCommand( + OtpId challengeId, + CustomerId customerId, + OtpPurpose purpose, + String code, + String deviceFingerprint, + String sourceIp) { + @Override + public String toString() { + return "VerifyOtpCommand[challengeId=" + challengeId + ", code=[REDACTED]]"; + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationException.java new file mode 100644 index 0000000..c1ba04d --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationException.java @@ -0,0 +1,18 @@ +package dev.amg.payguard.identity.verification.application; + +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; + +public class OtpVerificationException extends RuntimeException { + + private static final long serialVersionUID = 1L; + private final OtpVerificationResult verificationResult; + + public OtpVerificationException(OtpVerificationResult result) { + super("OTP verification failed: " + result.name().toLowerCase(java.util.Locale.ROOT)); + this.verificationResult = result; + } + + public OtpVerificationResult result() { + return verificationResult; + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/VerifiedOtp.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/VerifiedOtp.java new file mode 100644 index 0000000..8ec95c3 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/VerifiedOtp.java @@ -0,0 +1,8 @@ +package dev.amg.payguard.identity.verification.application; + +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import java.time.Instant; + +/** Completion result, not a bearer credential or authorization for a financial operation. */ +public record VerifiedOtp(OtpId challengeId, OtpPurpose purpose, Instant verifiedAt) {} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/AuthenticatorSecretPort.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/AuthenticatorSecretPort.java new file mode 100644 index 0000000..a5befe1 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/AuthenticatorSecretPort.java @@ -0,0 +1,10 @@ +package dev.amg.payguard.identity.verification.application.port; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import java.util.Optional; + +@FunctionalInterface +public interface AuthenticatorSecretPort { + + Optional secretFor(CustomerId customerId); +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDelivery.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDelivery.java new file mode 100644 index 0000000..d3c7f4e --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDelivery.java @@ -0,0 +1,10 @@ +package dev.amg.payguard.identity.verification.application.port; + +@FunctionalInterface +public interface OtpDelivery { + /** + * Submits to the configured adapter; completion is not proof of receipt by the customer. The + * local mock deliberately discards the payload. Failures propagate to the use case. + */ + void submit(OtpDeliveryRequest request); +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java new file mode 100644 index 0000000..7ed1958 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java @@ -0,0 +1,20 @@ +package dev.amg.payguard.identity.verification.application.port; + +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; + +/** destinationRef is the caller's delivery reference; provider resolution is not implemented. */ +public record OtpDeliveryRequest( + OtpId challengeId, + CustomerId customerId, + OtpPurpose purpose, + OtpChannel channel, + String destinationRef, + String code) { + @Override + public String toString() { + return "OtpDeliveryRequest[challengeId=" + challengeId + ", code=[REDACTED]]"; + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/TotpVerification.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/TotpVerification.java new file mode 100644 index 0000000..23c0a6d --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/TotpVerification.java @@ -0,0 +1,13 @@ +package dev.amg.payguard.identity.verification.application.port; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import java.time.Instant; + +/** + * Server-side TOTP verification using a trusted secret source. Retained for the existing challenge + * flow; ownership of LOGIN MFA remains unresolved pending provider integration. + */ +@FunctionalInterface +public interface TotpVerification { + boolean verify(CustomerId customerId, String code, Instant at); +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java new file mode 100644 index 0000000..cff3fac --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java @@ -0,0 +1,7 @@ +package dev.amg.payguard.identity.verification.application.port; + +@FunctionalInterface +public interface VerificationAudit { + /** Persists synchronously; a persistence failure propagates to the caller. */ + void record(VerificationAuditEntry entry); +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java new file mode 100644 index 0000000..78f0aed --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java @@ -0,0 +1,22 @@ +package dev.amg.payguard.identity.verification.application.port; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import java.time.Instant; + +/** + * customerId is the challenge subject, not an authenticated actor. No trusted actor or correlation + * context is currently supplied by the inbound contract. + */ +public record VerificationAuditEntry( + OtpId challengeId, + CustomerId customerId, + Action action, + String reasonCode, + String sourceIp, + Instant occurredAt) { + public enum Action { + ISSUED, + VERIFY + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationQuota.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationQuota.java new file mode 100644 index 0000000..abab62e --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationQuota.java @@ -0,0 +1,20 @@ +package dev.amg.payguard.identity.verification.application.port; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; + +@FunctionalInterface +public interface VerificationQuota { + /** + * Consumes one request in the fixed window, including a denied request. Returns false on + * exhausted quota; storage failures propagate. + */ + boolean tryAcquire(Request request); + + record Request(CustomerId customerId, OtpPurpose purpose, String sourceIp, Operation operation) {} + + enum Operation { + ISSUE, + VERIFY + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/EmailDeliveryAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/EmailDeliveryAdapter.java new file mode 100644 index 0000000..1d7843b --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/EmailDeliveryAdapter.java @@ -0,0 +1,17 @@ +package dev.amg.payguard.identity.verification.infrastructure.delivery; + +import dev.amg.payguard.identity.verification.application.port.OtpDelivery; +import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** TLS provider integration seam for email. The provider request must never be logged. */ +@Component +@ConditionalOnProperty(name = "identity.delivery.mode", havingValue = "email") +public class EmailDeliveryAdapter implements OtpDelivery { + + @Override + public void submit(OtpDeliveryRequest message) { + throw new UnsupportedOperationException("Delivery provider is not configured"); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/MockDeliveryAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/MockDeliveryAdapter.java new file mode 100644 index 0000000..23ef0c8 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/MockDeliveryAdapter.java @@ -0,0 +1,20 @@ +package dev.amg.payguard.identity.verification.infrastructure.delivery; + +import dev.amg.payguard.identity.verification.application.port.OtpDelivery; +import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * Safe local adapter. It deliberately discards the code and never logs it. A provider adapter is + * selected in production without changing the domain or application layer. + */ +@Component +@ConditionalOnProperty(name = "identity.delivery.mode", havingValue = "mock", matchIfMissing = true) +public class MockDeliveryAdapter implements OtpDelivery { + + @Override + public void submit(OtpDeliveryRequest message) { + // Provider calls belong here. Never log or persist message.code. + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/PushDeliveryAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/PushDeliveryAdapter.java new file mode 100644 index 0000000..c55dad0 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/PushDeliveryAdapter.java @@ -0,0 +1,17 @@ +package dev.amg.payguard.identity.verification.infrastructure.delivery; + +import dev.amg.payguard.identity.verification.application.port.OtpDelivery; +import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** TLS provider integration seam for push notifications. */ +@Component +@ConditionalOnProperty(name = "identity.delivery.mode", havingValue = "push") +public class PushDeliveryAdapter implements OtpDelivery { + + @Override + public void submit(OtpDeliveryRequest message) { + throw new UnsupportedOperationException("Delivery provider is not configured"); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/SmsDeliveryAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/SmsDeliveryAdapter.java new file mode 100644 index 0000000..1fa7c76 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/SmsDeliveryAdapter.java @@ -0,0 +1,17 @@ +package dev.amg.payguard.identity.verification.infrastructure.delivery; + +import dev.amg.payguard.identity.verification.application.port.OtpDelivery; +import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** TLS provider integration seam for SMS. The provider request must never be logged. */ +@Component +@ConditionalOnProperty(name = "identity.delivery.mode", havingValue = "sms") +public class SmsDeliveryAdapter implements OtpDelivery { + + @Override + public void submit(OtpDeliveryRequest message) { + throw new UnsupportedOperationException("Delivery provider is not configured"); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisher.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisher.java new file mode 100644 index 0000000..298a466 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisher.java @@ -0,0 +1,56 @@ +package dev.amg.payguard.identity.verification.infrastructure.kafka; + +import dev.amg.payguard.identity.verification.domain.event.OtpEvent; +import dev.amg.payguard.identity.verification.domain.event.OtpEventPublisher; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +@Component +public class KafkaOtpEventPublisher implements OtpEventPublisher { + + private final KafkaTemplate kafka; + private final ObjectMapper objectMapper; + private final String topic; + + public KafkaOtpEventPublisher( + KafkaTemplate kafka, + ObjectMapper objectMapper, + @Value("${identity.kafka.topic:identity.events.v1}") String topic) { + this.kafka = kafka; + this.objectMapper = objectMapper; + this.topic = topic; + } + + @Override + public void publish(OtpEvent event) { + try { + kafka.send( + topic, + event.challengeId().toString(), + objectMapper.writeValueAsString( + new EventPayload( + event.type(), + event.challengeId().value(), + event.customerId().getValue(), + event.purpose().name(), + event.channel().name(), + event.result() == null ? null : event.result().name(), + event.occurredAt(), + event.reason()))); + } catch (tools.jackson.core.JacksonException exception) { + throw new IllegalStateException("Unable to serialize OTP event", exception); + } + } + + private record EventPayload( + String type, + String challengeId, + String userId, + String purpose, + String channel, + String result, + java.time.Instant occurredAt, + String reason) {} +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOtpAuditLogAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOtpAuditLogAdapter.java new file mode 100644 index 0000000..a182c24 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOtpAuditLogAdapter.java @@ -0,0 +1,22 @@ +package dev.amg.payguard.identity.verification.infrastructure.persistence; + +import dev.amg.payguard.identity.verification.application.port.VerificationAudit; +import dev.amg.payguard.identity.verification.application.port.VerificationAuditEntry; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class JpaOtpAuditLogAdapter implements VerificationAudit { + + private final OtpAuditLogJpaRepository repository; + + public JpaOtpAuditLogAdapter(OtpAuditLogJpaRepository repository) { + this.repository = repository; + } + + @Override + @Transactional + public void record(VerificationAuditEntry entry) { + repository.save(new OtpAuditLogEntity(entry)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogEntity.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogEntity.java new file mode 100644 index 0000000..3bebd17 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogEntity.java @@ -0,0 +1,49 @@ +package dev.amg.payguard.identity.verification.infrastructure.persistence; + +import dev.amg.payguard.identity.verification.application.port.VerificationAuditEntry; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "identity_audit_log") +public class OtpAuditLogEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "challenge_id", nullable = false) + private String challengeId; + + @Column(name = "user_id", nullable = false, length = 255) + private String userId; + + @Column(nullable = false, length = 32) + private String action; + + @Column(nullable = false, length = 128) + private String reason; + + @Column(name = "source_ip", length = 64) + private String sourceIp; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + protected OtpAuditLogEntity() {} + + OtpAuditLogEntity(VerificationAuditEntry entry) { + this.challengeId = entry.challengeId().value(); + this.userId = entry.customerId().getValue(); + this.action = entry.action().name(); + this.reason = entry.reasonCode(); + this.sourceIp = entry.sourceIp(); + this.occurredAt = entry.occurredAt(); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogJpaRepository.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogJpaRepository.java new file mode 100644 index 0000000..1af6d22 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogJpaRepository.java @@ -0,0 +1,6 @@ +package dev.amg.payguard.identity.verification.infrastructure.persistence; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +interface OtpAuditLogJpaRepository extends JpaRepository {} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/redis/RedisRateLimiterAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/redis/RedisRateLimiterAdapter.java new file mode 100644 index 0000000..e3bef71 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/redis/RedisRateLimiterAdapter.java @@ -0,0 +1,54 @@ +package dev.amg.payguard.identity.verification.infrastructure.redis; + +import dev.amg.payguard.identity.verification.application.port.VerificationQuota; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import java.time.Duration; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +@Component +public class RedisRateLimiterAdapter implements VerificationQuota { + + private final StringRedisTemplate redis; + private final int maxIssues; + private final int maxVerifications; + private final Duration window; + + public RedisRateLimiterAdapter( + StringRedisTemplate redis, + @Value("${identity.rate-limit.max-issues:3}") int maxIssues, + @Value("${identity.rate-limit.max-verifications:20}") int maxVerifications, + @Value("${identity.rate-limit.window:PT15M}") Duration window) { + this.redis = redis; + this.maxIssues = maxIssues; + this.maxVerifications = maxVerifications; + this.window = window; + } + + @Override + public boolean tryAcquire(VerificationQuota.Request request) { + boolean issuing = request.operation() == VerificationQuota.Operation.ISSUE; + return allow( + issuing ? "issue" : "verify", + request.customerId().getValue(), + request.purpose(), + request.sourceIp(), + issuing ? maxIssues : maxVerifications); + } + + private boolean allow( + String operation, String userId, OtpPurpose purpose, String sourceIp, int maximum) { + String key = + "identity:rate:" + operation + ":" + safe(userId) + ":" + purpose + ":" + safe(sourceIp); + Long count = redis.opsForValue().increment(key); + if (count != null && count == 1L) { + redis.expire(key, window); + } + return count != null && count <= maximum; + } + + private static String safe(String value) { + return value == null || value.isBlank() ? "unknown" : value.replaceAll("[^A-Za-z0-9._-]", "_"); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/OtpIdGenerator.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/OtpIdGenerator.java new file mode 100644 index 0000000..aa5879a --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/OtpIdGenerator.java @@ -0,0 +1,20 @@ +package dev.amg.payguard.identity.verification.infrastructure.security; + +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import java.security.SecureRandom; +import org.springframework.stereotype.Component; + +@Component +public final class OtpIdGenerator { + private static final String ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private final SecureRandom random = new SecureRandom(); + + public OtpId generate() { + StringBuilder value = new StringBuilder("otp_"); + for (int index = 0; index < 22; index++) { + value.append(ALPHABET.charAt(random.nextInt(ALPHABET.length()))); + } + return OtpId.parse(value.toString()); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/SecureNumericOtpGenerator.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/SecureNumericOtpGenerator.java new file mode 100644 index 0000000..89124c6 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/SecureNumericOtpGenerator.java @@ -0,0 +1,16 @@ +package dev.amg.payguard.identity.verification.infrastructure.security; + +import dev.amg.payguard.identity.verification.domain.port.OtpCodeGenerator; +import java.security.SecureRandom; +import org.springframework.stereotype.Component; + +@Component +public final class SecureNumericOtpGenerator implements OtpCodeGenerator { + + private final SecureRandom random = new SecureRandom(); + + @Override + public String generate() { + return "%06d".formatted(random.nextInt(1_000_000)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpAuthenticatorCodeVerifier.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpAuthenticatorCodeVerifier.java new file mode 100644 index 0000000..3176f61 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpAuthenticatorCodeVerifier.java @@ -0,0 +1,83 @@ +package dev.amg.payguard.identity.verification.infrastructure.security; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.verification.application.port.AuthenticatorSecretPort; +import dev.amg.payguard.identity.verification.application.port.TotpVerification; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Locale; +import java.util.Optional; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.stereotype.Component; + +@Component +public final class TotpAuthenticatorCodeVerifier implements TotpVerification { + + private static final int DIGITS = 6; + private static final long STEP_SECONDS = 30; + private final AuthenticatorSecretPort secrets; + + public TotpAuthenticatorCodeVerifier(AuthenticatorSecretPort secrets) { + this.secrets = secrets; + } + + @Override + public boolean verify(CustomerId customerId, String code, Instant at) { + if (code == null || !code.matches("\\d{" + DIGITS + "}")) { + return false; + } + Optional secret = secrets.secretFor(customerId); + if (secret.isEmpty()) { + return false; + } + long counter = at.getEpochSecond() / STEP_SECONDS; + for (long offset = -1; offset <= 1; offset++) { + if (constantTimeEquals(code, generate(secret.get(), counter + offset))) { + return true; + } + } + return false; + } + + private String generate(String encodedSecret, long counter) { + try { + byte[] secret = decodeBase32(encodedSecret); + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(secret, "HmacSHA1")); + byte[] digest = mac.doFinal(ByteBuffer.allocate(Long.BYTES).putLong(counter).array()); + int offset = digest[digest.length - 1] & 0x0f; + int binary = + ((digest[offset] & 0x7f) << 24) + | ((digest[offset + 1] & 0xff) << 16) + | ((digest[offset + 2] & 0xff) << 8) + | (digest[offset + 3] & 0xff); + return "%06d".formatted(binary % 1_000_000); + } catch (java.security.GeneralSecurityException | IllegalArgumentException exception) { + return ""; + } + } + + private static byte[] decodeBase32(String input) { + String normalized = input.replace("=", "").replace(" ", "").toUpperCase(Locale.ROOT); + StringBuilder bits = new StringBuilder(); + for (char character : normalized.toCharArray()) { + int value = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(character); + if (value < 0) { + throw new IllegalArgumentException("Invalid base32 secret"); + } + bits.append(String.format("%5s", Integer.toBinaryString(value)).replace(' ', '0')); + } + byte[] decoded = new byte[bits.length() / 8]; + for (int index = 0; index < decoded.length; index++) { + decoded[index] = (byte) Integer.parseInt(bits.substring(index * 8, index * 8 + 8), 2); + } + return decoded; + } + + private static boolean constantTimeEquals(String left, String right) { + return java.security.MessageDigest.isEqual( + left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java new file mode 100644 index 0000000..5a31bd8 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java @@ -0,0 +1,18 @@ +package dev.amg.payguard.identity.verification.infrastructure.security; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.verification.application.port.AuthenticatorSecretPort; +import java.util.Optional; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** Resolves no secrets by default; production wiring supplies a vault-backed implementation. */ +@Component +@ConditionalOnProperty(name = "payguard.vault.enabled", havingValue = "false") +public final class UnavailableAuthenticatorSecretAdapter implements AuthenticatorSecretPort { + + @Override + public Optional secretFor(CustomerId customerId) { + return Optional.empty(); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpController.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpController.java new file mode 100644 index 0000000..48eebbd --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpController.java @@ -0,0 +1,97 @@ +package dev.amg.payguard.identity.verification.interfaces.rest; + +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.application.IssueOtpCommand; +import dev.amg.payguard.identity.verification.application.IssuedOtp; +import dev.amg.payguard.identity.verification.application.OtpApplicationService; +import dev.amg.payguard.identity.verification.application.OtpVerificationCommand; +import dev.amg.payguard.identity.verification.application.VerifiedOtp; +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import java.time.Instant; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/identity/challenges") +public class OtpController { + + private final OtpApplicationService service; + + public OtpController(OtpApplicationService service) { + this.service = service; + } + + @PostMapping + public ResponseEntity issue( + @Valid @RequestBody IssueOtpRequest request, HttpServletRequest httpRequest) { + IssuedOtp issued = + service.issue( + new IssueOtpCommand( + CustomerId.parse(request.userId()), + request.purpose(), + request.channel(), + request.destinationRef(), + request.deviceFingerprint(), + sourceIp(httpRequest))); + return ResponseEntity.ok( + new IssueOtpResponse( + issued.challengeId().value(), issued.purpose(), issued.channel(), issued.expiresAt())); + } + + @PostMapping("/{challengeId}/verify") + public ResponseEntity verify( + @PathVariable String challengeId, + @Valid @RequestBody VerifyOtpRequest request, + HttpServletRequest httpRequest) { + VerifiedOtp verified = + service.verify( + new OtpVerificationCommand( + OtpId.parse(challengeId), + CustomerId.parse(request.userId()), + request.purpose(), + request.code(), + request.deviceFingerprint(), + sourceIp(httpRequest))); + return ResponseEntity.ok( + new VerifyOtpResponse( + verified.challengeId().value(), verified.purpose(), verified.verifiedAt())); + } + + private static String sourceIp(HttpServletRequest request) { + return request.getRemoteAddr(); + } + + public record IssueOtpRequest( + @NotBlank String userId, + @NotNull OtpPurpose purpose, + @NotNull OtpChannel channel, + String destinationRef, + String deviceFingerprint) {} + + public record VerifyOtpRequest( + @NotBlank String userId, + @NotNull OtpPurpose purpose, + @NotBlank @Pattern(regexp = "\\d{6}") String code, + String deviceFingerprint) { + @Override + public String toString() { + return "VerifyOtpRequest[code=[REDACTED]]"; + } + } + + public record IssueOtpResponse( + String challengeId, OtpPurpose purpose, OtpChannel channel, Instant expiresAt) {} + + public record VerifyOtpResponse(String challengeId, OtpPurpose purpose, Instant verifiedAt) {} +} diff --git a/payguard-identity-service/src/main/resources/banner.txt b/payguard-identity-service/src/main/resources/banner.txt new file mode 100644 index 0000000..88535c2 --- /dev/null +++ b/payguard-identity-service/src/main/resources/banner.txt @@ -0,0 +1,6 @@ + __ _______ _______ .__ __. .___________. __ .___________.____ ____ +| | | \ | ____|| \ | | | || | | |\ \ / / +| | | .--. || |__ | \| | `---| |----`| | `---| |----` \ \/ / +| | | | | || __| | . ` | | | | | | | \_ _/ +| | | '--' || |____ | |\ | | | | | | | | | +|__| |_______/ |_______||__| \__| |__| |__| |__| |__| diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationServiceTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationServiceTest.java new file mode 100644 index 0000000..eec7089 --- /dev/null +++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationServiceTest.java @@ -0,0 +1,180 @@ +package dev.amg.payguard.identity.verification.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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.application.port.OtpDelivery; +import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; +import dev.amg.payguard.identity.verification.application.port.VerificationQuota; +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 dev.amg.payguard.identity.verification.domain.model.OTP; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; +import dev.amg.payguard.identity.verification.domain.port.OtpRepository; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class OtpApplicationServiceTest { + + private final FixedHasher hasher = new FixedHasher(); + private final InMemoryChallenges challenges = new InMemoryChallenges(); + private final CapturingDelivery delivery = new CapturingDelivery(); + private OtpApplicationService service; + + @BeforeEach + void setUp() { + Instant now = Instant.parse("2026-01-01T00:00:00Z"); + service = + new OtpApplicationService( + challenges, + hasher, + () -> "123456", + (userId, code, at) -> false, + delivery, + new AllowAllVerificationQuota(), + () -> OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"), + event -> {}, + entry -> {}, + Clock.fixed(now, ZoneOffset.UTC), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + Duration.ofDays(1097), + 5); + } + + @Test + void issueAndVerifyReturnsCompletedChallengeAndRejectsReplay() { + IssuedOtp issued = + service.issue( + new IssueOtpCommand( + CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), + OtpPurpose.LOGIN, + OtpChannel.SMS, + "identity:phone:1", + null, + "127.0.0.1")); + + assertEquals("123456", delivery.code); + VerifiedOtp verified = + service.verify( + new OtpVerificationCommand( + issued.challengeId(), + CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), + OtpPurpose.LOGIN, + "123456", + null, + "127.0.0.1")); + + assertEquals(issued.challengeId(), verified.challengeId()); + assertEquals(Instant.parse("2026-01-01T00:00:00Z"), verified.verifiedAt()); + OtpVerificationException replay = + assertThrows( + OtpVerificationException.class, + () -> + service.verify( + new OtpVerificationCommand( + issued.challengeId(), + CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), + OtpPurpose.LOGIN, + "123456", + null, + "127.0.0.1"))); + assertEquals(OtpVerificationResult.ALREADY_USED, replay.result()); + } + + @Test + void wrongPurposeCannotVerifyChallenge() { + IssuedOtp issued = + service.issue( + new IssueOtpCommand( + CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), + OtpPurpose.LOGIN, + OtpChannel.EMAIL, + "identity:email:1", + null, + "127.0.0.1")); + + OtpVerificationException exception = + assertThrows( + OtpVerificationException.class, + () -> + service.verify( + new OtpVerificationCommand( + issued.challengeId(), + CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), + OtpPurpose.LOAN_DISBURSE, + "123456", + null, + "127.0.0.1"))); + assertEquals(OtpVerificationResult.PURPOSE_MISMATCH, exception.result()); + } + + private static final class FixedHasher implements 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 final class CapturingDelivery implements OtpDelivery { + private String code; + + @Override + public void submit(OtpDeliveryRequest message) { + code = message.code(); + } + } + + private static final class AllowAllVerificationQuota implements VerificationQuota { + @Override + public boolean tryAcquire(VerificationQuota.Request request) { + return true; + } + } + + private static final class InMemoryChallenges implements OtpRepository { + @Override + public Optional withLockedChallenge( + OtpId id, java.util.function.Function mutation) { + return findById(id).map(mutation); + } + + private final Map values = new HashMap<>(); + + @Override + public void replaceActive(OTP replacement) { + values.values().stream() + .filter( + challenge -> + challenge.getCustomerId().equals(replacement.getCustomerId()) + && challenge.getSnapshot().purpose() == replacement.getSnapshot().purpose()) + .forEach(OTP::invalidate); + save(replacement); + } + + @Override + public void save(OTP challenge) { + values.put(challenge.getId(), challenge); + } + + @Override + public Optional findById(OtpId challengeId) { + return Optional.ofNullable(values.get(challengeId)); + } + } +} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPDomainContractTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPDomainContractTest.java new file mode 100644 index 0000000..33b3c24 --- /dev/null +++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPDomainContractTest.java @@ -0,0 +1,248 @@ +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +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.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.enums.OtpVerificationResult; +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.junit.jupiter.api.Test; + +class OTPDomainContractTest { + private static final Instant NOW = Instant.parse("2026-01-01T00:00:00Z"); + private static final Instant EXPIRES = NOW.plusSeconds(300); + private static final Instant RETAINS = EXPIRES.atZone(ZoneOffset.UTC).plusYears(3).toInstant(); + private static final OtpId ID = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"); + private static final CustomerId CUSTOMER = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"); + 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 hash(value).equals(encodedHash); + } + }; + + @Test + void issueRequiresActiveZeroAttemptSnapshot() { + assertEquals( + OtpStatus.ACTIVE, + OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null)).getSnapshot().status()); + assertThrows( + IllegalArgumentException.class, + () -> OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 1, 3, null))); + assertThrows( + IllegalArgumentException.class, + () -> OTP.issue(ID, CUSTOMER, state(OtpStatus.USED, 1, 3, NOW))); + assertThrows( + IllegalArgumentException.class, + () -> OTP.issue(ID, CUSTOMER, state(OtpStatus.LOCKED, 3, 3, null))); + assertThrows( + IllegalArgumentException.class, + () -> OTP.issue(ID, CUSTOMER, state(OtpStatus.EXPIRED, 1, 3, null))); + } + + @Test + void issueRejectsNullArgumentsAtRuntime() { + OtpSnapshot initial = state(OtpStatus.ACTIVE, 0, 3, null); + assertThrows(NullPointerException.class, () -> OTP.issue(null, CUSTOMER, initial)); + assertThrows(NullPointerException.class, () -> OTP.issue(ID, null, initial)); + assertThrows(NullPointerException.class, () -> OTP.issue(ID, CUSTOMER, null)); + } + + @Test + void restoreAcceptsHistoricalStatesWithoutReset() { + for (OtpSnapshot state : + new OtpSnapshot[] { + state(OtpStatus.ACTIVE, 2, 3, null), + state(OtpStatus.USED, 2, 3, NOW.plusSeconds(1)), + state(OtpStatus.LOCKED, 3, 3, null), + state(OtpStatus.EXPIRED, 1, 3, null), + state(OtpStatus.INVALIDATED, 1, 3, null) + }) { + OTP restored = OTP.restore(ID, CUSTOMER, state); + assertEquals(ID, restored.getId()); + assertEquals(CUSTOMER, restored.getCustomerId()); + assertSame(state, restored.getSnapshot()); + assertEquals(state.attempts(), restored.getSnapshot().attempts()); + assertEquals(state.consumedAt(), restored.getSnapshot().consumedAt()); + } + } + + @Test + void restoreRejectsNullArgumentsAtRuntime() { + OtpSnapshot existing = state(OtpStatus.USED, 1, 3, NOW); + assertThrows(NullPointerException.class, () -> OTP.restore(null, CUSTOMER, existing)); + assertThrows(NullPointerException.class, () -> OTP.restore(ID, null, existing)); + assertThrows(NullPointerException.class, () -> OTP.restore(ID, CUSTOMER, null)); + } + + @Test + void correctCodeSucceedsOnLastRemainingAttemptAfterPreviousFailures() { + OTP otp = OTP.restore(ID, CUSTOMER, state(OtpStatus.ACTIVE, 2, 3, null)); + assertEquals(OtpVerificationResult.VERIFIED, otp.verify(command("123456", NOW), HASHER)); + assertEquals(OtpStatus.USED, otp.getSnapshot().status()); + assertEquals(3, otp.getSnapshot().attempts()); + assertEquals(NOW, otp.getSnapshot().consumedAt()); + } + + @Test + void failedAttemptDoesNotRequireDeviceBindingOrConsumptionTime() { + OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null)); + OtpSnapshot before = otp.getSnapshot(); + assertEquals(OtpVerificationResult.INVALID_CODE, otp.verify(command("000000", NOW), HASHER)); + assertEquals(OtpStatus.ACTIVE, otp.getSnapshot().status()); + assertEquals(1, otp.getSnapshot().attempts()); + assertNull(otp.getSnapshot().consumedAt()); + assertNull(otp.getSnapshot().deviceBindingHash()); + assertEquals(0, before.attempts()); + } + + @Test + void consumedChallengeCannotBeReusedOrMutated() { + OTP otp = OTP.restore(ID, CUSTOMER, state(OtpStatus.USED, 1, 3, NOW)); + OtpSnapshot before = otp.getSnapshot(); + assertEquals( + OtpVerificationResult.ALREADY_USED, + otp.verify(command("123456", NOW.plusSeconds(2)), HASHER)); + assertSame(before, otp.getSnapshot()); + } + + @Test + void lockedChallengeCannotBeReusedOrMutated() { + OTP otp = OTP.restore(ID, CUSTOMER, state(OtpStatus.LOCKED, 3, 3, null)); + OtpSnapshot before = otp.getSnapshot(); + assertEquals(OtpVerificationResult.LOCKED, otp.verify(command("123456", NOW), HASHER)); + assertSame(before, otp.getSnapshot()); + } + + @Test + void verificationAtExpirationTransitionsToExpiredWithoutCountingAttempt() { + OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null)); + assertEquals(OtpVerificationResult.EXPIRED, otp.verify(command("123456", EXPIRES), HASHER)); + assertEquals(OtpStatus.EXPIRED, otp.getSnapshot().status()); + assertEquals(0, otp.getSnapshot().attempts()); + assertNull(otp.getSnapshot().consumedAt()); + } + + @Test + void verificationBeforeCreationIsInvalidEvenForWrongCode() { + OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null)); + OtpSnapshot before = otp.getSnapshot(); + assertThrows( + IllegalArgumentException.class, + () -> otp.verify(command("000000", NOW.minusSeconds(1)), HASHER)); + assertSame(before, otp.getSnapshot()); + } + + @Test + void hasherFailureInDeviceBindingGuardPreservesState() { + OtpSnapshot withBinding = + new OtpSnapshot( + OtpPurpose.LOGIN, + OtpChannel.SMS, + OtpVerificationMode.RANDOM_NUMERIC, + "hash:123456", + "hash:device-a", + 3, + NOW, + EXPIRES, + RETAINS, + 0, + OtpStatus.ACTIVE, + null); + OTP otp = OTP.issue(ID, CUSTOMER, withBinding); + OtpCodeHasher failing = + new OtpCodeHasher() { + @Override + public String hash(String value) { + throw new IllegalStateException("hash unavailable"); + } + + @Override + public boolean matches(String value, String encodedHash) { + throw new IllegalStateException("hash unavailable"); + } + }; + assertThrows( + IllegalStateException.class, + () -> + otp.verify( + new VerifyOtpCommand(OtpPurpose.LOGIN, "123456", "device-a", NOW, false), failing)); + assertSame(withBinding, otp.getSnapshot()); + } + + @Test + void invalidatingActiveChallengeIsCompleteAndIdempotentInMemory() { + OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null)); + otp.invalidate(); + assertEquals(OtpStatus.INVALIDATED, otp.getSnapshot().status()); + assertEquals(0, otp.getSnapshot().attempts()); + OtpSnapshot invalidated = otp.getSnapshot(); + otp.invalidate(); + assertSame(invalidated, otp.getSnapshot()); + } + + @Test + void invalidatingUsedChallengeDoesNotChangeIt() { + OTP otp = OTP.restore(ID, CUSTOMER, state(OtpStatus.USED, 1, 3, NOW)); + OtpSnapshot before = otp.getSnapshot(); + otp.invalidate(); + assertSame(before, otp.getSnapshot()); + } + + @Test + void identityHashingDoesNotChangeWhenSnapshotChanges() { + OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null)); + Set known = new HashSet<>(); + known.add(otp); + int beforeHash = otp.hashCode(); + assertEquals(OtpVerificationResult.VERIFIED, otp.verify(command("123456", NOW), HASHER)); + assertEquals(beforeHash, otp.hashCode()); + assertTrue(known.contains(otp)); + OTP restored = OTP.restore(ID, CUSTOMER, otp.getSnapshot()); + assertEquals(otp, restored); + assertFalse(otp == restored); + } + + private static VerifyOtpCommand command(String code, Instant now) { + return new VerifyOtpCommand(OtpPurpose.LOGIN, code, null, now, false); + } + + private static OtpSnapshot state( + OtpStatus status, int attempts, int maxAttempts, Instant consumedAt) { + return new OtpSnapshot( + OtpPurpose.LOGIN, + OtpChannel.SMS, + OtpVerificationMode.RANDOM_NUMERIC, + "hash:123456", + null, + maxAttempts, + NOW, + EXPIRES, + RETAINS, + attempts, + status, + consumedAt); + } +} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/delivery/DeliveryAdaptersTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/delivery/DeliveryAdaptersTest.java new file mode 100644 index 0000000..a7c077e --- /dev/null +++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/delivery/DeliveryAdaptersTest.java @@ -0,0 +1,32 @@ +package dev.amg.payguard.identity.verification.infrastructure.delivery; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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.application.port.OtpDeliveryRequest; +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import org.junit.jupiter.api.Test; + +class DeliveryAdaptersTest { + @Test + void mockRemainsLocalAndUnconfiguredProviderModesFailExplicitly() { + var request = + new OtpDeliveryRequest( + OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"), + CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), + OtpPurpose.LOGIN, + OtpChannel.SMS, + "phone-ref", + "123456"); + assertDoesNotThrow(() -> new MockDeliveryAdapter().submit(request)); + assertThrows( + UnsupportedOperationException.class, () -> new SmsDeliveryAdapter().submit(request)); + assertThrows( + UnsupportedOperationException.class, () -> new EmailDeliveryAdapter().submit(request)); + assertThrows( + UnsupportedOperationException.class, () -> new PushDeliveryAdapter().submit(request)); + } +} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisherTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisherTest.java new file mode 100644 index 0000000..e8b0380 --- /dev/null +++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisherTest.java @@ -0,0 +1,44 @@ +package dev.amg.payguard.identity.verification.infrastructure.kafka; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +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 dev.amg.payguard.identity.verification.domain.event.OtpEvent; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.kafka.core.KafkaTemplate; +import tools.jackson.databind.ObjectMapper; + +class KafkaOtpEventPublisherTest { + @Test + @SuppressWarnings("unchecked") + void convertsTypedIdsToExistingWireFields() { + KafkaTemplate kafka = mock(KafkaTemplate.class); + OtpId id = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"); + var publisher = new KafkaOtpEventPublisher(kafka, new ObjectMapper(), "identity.events.v1"); + publisher.publish( + new OtpEvent( + "identity.verified", + id, + CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), + OtpPurpose.LOGIN, + OtpChannel.SMS, + OtpVerificationResult.VERIFIED, + Instant.parse("2026-01-01T00:00:00Z"), + "verified")); + var json = ArgumentCaptor.forClass(String.class); + verify(kafka).send(eq("identity.events.v1"), eq(id.value()), json.capture()); + assertTrue(json.getValue().contains("\"challengeId\":\"otp_ABCDEFGHIJKLMNOPQRSTUV\"")); + assertTrue(json.getValue().contains("\"userId\":\"cus_ABCDEFGHIJKLMNOPQRSTUV\"")); + assertFalse(json.getValue().contains("customerId")); + } +} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpVerificationTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpVerificationTest.java new file mode 100644 index 0000000..3e3f2c0 --- /dev/null +++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpVerificationTest.java @@ -0,0 +1,32 @@ +package dev.amg.payguard.identity.verification.infrastructure.security; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class TotpVerificationTest { + private static final CustomerId CUSTOMER = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"); + + @Test + void verifiesKnownRfc6238Sha1VectorWithExistingSixDigitFormat() { + var verifier = + new TotpAuthenticatorCodeVerifier( + customer -> Optional.of("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")); + assertTrue(verifier.verify(CUSTOMER, "287082", Instant.ofEpochSecond(59))); + assertFalse(verifier.verify(CUSTOMER, "000000", Instant.ofEpochSecond(59))); + assertFalse(verifier.verify(CUSTOMER, "287082", Instant.ofEpochSecond(300))); + assertFalse(verifier.verify(CUSTOMER, "2870820", Instant.ofEpochSecond(59))); + } + + @Test + void missingOrInvalidSecretsFailClosed() { + var missing = new TotpAuthenticatorCodeVerifier(new UnavailableAuthenticatorSecretAdapter()); + assertFalse(missing.verify(CUSTOMER, "287082", Instant.ofEpochSecond(59))); + var invalid = new TotpAuthenticatorCodeVerifier(customer -> Optional.of("invalid!")); + assertFalse(invalid.verify(CUSTOMER, "287082", Instant.ofEpochSecond(59))); + } +} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpControllerTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpControllerTest.java new file mode 100644 index 0000000..e1b916c --- /dev/null +++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpControllerTest.java @@ -0,0 +1,104 @@ +package dev.amg.payguard.identity.verification.interfaces.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +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.application.IssueOtpCommand; +import dev.amg.payguard.identity.verification.application.IssuedOtp; +import dev.amg.payguard.identity.verification.application.OtpApplicationService; +import dev.amg.payguard.identity.verification.application.OtpVerificationCommand; +import dev.amg.payguard.identity.verification.application.VerifiedOtp; +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.value_object.VerifyOtpCommand; +import jakarta.servlet.http.HttpServletRequest; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class OtpControllerTest { + @Test + void parsesCustomerAndChallengeAtBoundaryAndReturnsStringIds() { + var service = mock(OtpApplicationService.class); + var request = mock(HttpServletRequest.class); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + OtpId id = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"); + CustomerId customer = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"); + Instant now = Instant.parse("2026-01-01T00:00:00Z"); + when(service.issue(any())).thenReturn(new IssuedOtp(id, OtpPurpose.LOGIN, OtpChannel.SMS, now)); + when(service.verify(any())).thenReturn(new VerifiedOtp(id, OtpPurpose.LOGIN, now)); + var controller = new OtpController(service); + assertEquals( + id.value(), + controller + .issue( + new OtpController.IssueOtpRequest( + customer.getValue(), OtpPurpose.LOGIN, OtpChannel.SMS, "phone-ref", null), + request) + .getBody() + .challengeId()); + assertEquals( + id.value(), + controller + .verify( + id.value(), + new OtpController.VerifyOtpRequest( + customer.getValue(), OtpPurpose.LOGIN, "123456", null), + request) + .getBody() + .challengeId()); + var issue = ArgumentCaptor.forClass(IssueOtpCommand.class); + verify(service).issue(issue.capture()); + assertEquals(customer, issue.getValue().customerId()); + var verification = ArgumentCaptor.forClass(OtpVerificationCommand.class); + verify(service).verify(verification.capture()); + assertEquals(id, verification.getValue().challengeId()); + assertEquals(customer, verification.getValue().customerId()); + } + + @Test + void rejectsMalformedIdsBeforeCallingApplication() { + var service = mock(OtpApplicationService.class); + var controller = new OtpController(service); + assertThrows( + IllegalArgumentException.class, + () -> + controller.verify( + "invalid", + new OtpController.VerifyOtpRequest( + "cus_ABCDEFGHIJKLMNOPQRSTUV", OtpPurpose.LOGIN, "123456", null), + mock(HttpServletRequest.class))); + verifyNoInteractions(service); + } + + @Test + void diagnosticOutputDoesNotExposeCodeButPayloadStillContainsIt() { + var request = + new OtpController.VerifyOtpRequest( + "cus_ABCDEFGHIJKLMNOPQRSTUV", OtpPurpose.LOGIN, "123456", null); + assertEquals("123456", request.code()); + assertFalse(request.toString().contains("123456")); + assertFalse( + new OtpVerificationCommand( + OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"), + CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), + OtpPurpose.LOGIN, + "123456", + null, + null) + .toString() + .contains("123456")); + assertFalse( + new VerifyOtpCommand(OtpPurpose.LOGIN, "123456", null, Instant.now(), false) + .toString() + .contains("123456")); + } +} diff --git a/pom.xml b/pom.xml index 4af7e03..4522c0b 100644 --- a/pom.xml +++ b/pom.xml @@ -18,7 +18,7 @@ payguard-wallet-service payguard-loan-service payguard-collateral-service - payguard-otp-service + payguard-identity-service From 2a71a7d5b1cc851f3fe334d08290ddfd8d4f53d6 Mon Sep 17 00:00:00 2001 From: Amir Golmoradi Date: Mon, 12 Oct 2026 10:52:29 +0330 Subject: [PATCH 3/5] Use native Flyway environment variables --- docs/guides/postgresql-vault-migration.md | 4 +++- docs/tasks/identity-publication-repair.md | 4 ++-- pom.xml | 7 +++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/guides/postgresql-vault-migration.md b/docs/guides/postgresql-vault-migration.md index 1e0ec13..7805680 100644 --- a/docs/guides/postgresql-vault-migration.md +++ b/docs/guides/postgresql-vault-migration.md @@ -120,6 +120,8 @@ python3 scripts/local/verify_applications.py Skipping tests in the first command builds helper classpaths; it does not claim verification. Run the suites below. Wallet has no schema migration command because no business schema was supplied. +The parent Maven Flyway plugin reads connection settings directly from `FLYWAY_URL`, `FLYWAY_USER`, `FLYWAY_PASSWORD` and `FLYWAY_INIT_SQL` in the invoking process. They are not Maven property references, so ordinary IDE imports and builds do not need migration credentials. An explicitly managed Maven migration must receive a verified TLS URL, a separately issued migration login and `FLYWAY_INIT_SQL` selecting the service's durable owner, for example `SET ROLE identity_owner`. That variable overrides the plugin's default SQL, which refuses database operations when owner-role selection is omitted. The supported local commands above use `migrate.py` and `SchemaMigrator`, which retain their internal `PAYGUARD_MIGRATION_*` variables, derive the owner from the validated service name, apply the post-migration grants and revoke the operator token. A raw Maven invocation does not perform that surrounding workflow. [Flyway Maven environment configuration](https://documentation.red-gate.com/fd/maven-goal-277579365.html). + Preparation creates mode-0600 files under a mode-0700 ignored directory: synthetic random bootstrap passwords, CA/private keys and per-server TLS certificates. Both application-to-database and Vault-to-database URLs use `sslmode=verify-full` with the local CA. The CA/certificates expire after 30 days; this is explicit local rehearsal material, not production certificate lifecycle. TLS remains enabled on persistent Vault/Raft. Testcontainers tests use isolated disposable test passwords/plain local JDBC; they are not the non-disposable design. `PostgreSqlTls` validates the configured single-host JDBC URL before any connection. It requires an absolute CA path and exactly one `sslmode=verify-full`; duplicate/encoded overrides, URL passwords, userinfo and custom SSL factories/hostname verifiers are refused. Multi-host failover is not implemented by this local topology. Owner default EXECUTE is revoked globally for future functions because a per-schema revoke cannot remove PostgreSQL's global PUBLIC default grant. @@ -224,7 +226,7 @@ Before PostgreSQL accepts writes, rollback can redirect to the frozen Oracle sou | Repository-relative path | Before → resulting behavior | Reason / evidence | | --- | --- | --- | -| `pom.xml` | Oracle dependency management removed; separate PostgreSQL Flyway plugin configured | Managed compatible versions; Java/Spring unchanged; helper library path exercised, alternate Maven plugin path not yet exercised | +| `pom.xml` | Oracle dependency management removed; PostgreSQL Flyway plugin reads native `FLYWAY_*` variables at execution time, with required owner-role selection | Managed compatible versions; Java/Spring unchanged; parent validation passed; isolated PostgreSQL 17.11 Maven migration applied Loan V1, verified five tables/two sequences owned by `loan_owner`, and rejected omitted owner initialization. The Vault-managed helper remains the complete local migration workflow | | `payguard-{identity,loan,collateral,wallet}-service/pom.xml` | Oracle JDBC/Flyway/test containers → PostgreSQL and actuator | Actual service persistence approach retained; compilation and real database suites | | `payguard-{identity,loan,collateral,wallet}-service/src/main/resources/application.yaml` | Oracle/env runtime passwords → PostgreSQL/Vault pool, explicit readiness | No pepper/password fallback; runtime Flyway disabled; separate migration path | | `payguard-identity-service/src/main/resources/db/migration/postgresql/V1__identity_schema.sql` | New vendor history in final prefixed-ID shape | UUID audit/string identity/timestamp/check constraints | diff --git a/docs/tasks/identity-publication-repair.md b/docs/tasks/identity-publication-repair.md index 436ec5f..ba973f2 100644 --- a/docs/tasks/identity-publication-repair.md +++ b/docs/tasks/identity-publication-repair.md @@ -11,9 +11,9 @@ The repair branch `amg-complete-identity-service` starts from local main and ret - [x] Inspect the committed snapshot, working tree, migration notes and CI. - [x] Preserve the original deletion commit and create an amg branch from main. - [x] Record original file hashes and tracked edits in the ignored local repair directory. -- [ ] Commit the complete Identity module selection and source dependency closure. +- [x] Commit the complete Identity module selection and source dependency closure (`23f4a77`). - [ ] Verify the committed source from a clean worktree; resolve actual formatting, compilation, test and analysis failures without weakening checks. -- [ ] Commit the independently verified native Flyway environment fix and documentation. +- [x] Commit the independently verified native Flyway environment fix and documentation separately. - [ ] Push normally, open a PR to main and inspect GitHub quality/security/CodeQL results. - [ ] Leave local main aligned with remote main, preserving the owner's deletion commit on the repair branch. diff --git a/pom.xml b/pom.xml index 4522c0b..72b5783 100644 --- a/pom.xml +++ b/pom.xml @@ -80,10 +80,9 @@ flyway-maven-plugin 12.4.0 - ${env.PAYGUARD_MIGRATION_JDBC_URL} - ${env.PAYGUARD_MIGRATION_USERNAME} - ${env.PAYGUARD_MIGRATION_PASSWORD} - SET ROLE ${env.PAYGUARD_MIGRATION_OWNER} + + + DO 'BEGIN RAISE EXCEPTION ''FLYWAY_INIT_SQL must select the service owner''; END;' payguard payguard false From fd29d357b6f5ecd8bac9ddb4f4478f274a1abf02 Mon Sep 17 00:00:00 2001 From: Amir Golmoradi Date: Mon, 12 Oct 2026 11:00:50 +0330 Subject: [PATCH 4/5] Preserve OTP identity and reject invalid verification times --- docs/tasks/identity-publication-repair.md | 6 ++++++ payguard-identity-service/pom.xml | 2 +- .../customer/domain/value_object/OtpId.java | 14 ++++++------- .../verification/domain/model/OTP.java | 20 ++----------------- .../domain/policy/OtpVerificationPolicy.java | 3 +++ 5 files changed, 19 insertions(+), 26 deletions(-) diff --git a/docs/tasks/identity-publication-repair.md b/docs/tasks/identity-publication-repair.md index ba973f2..719745d 100644 --- a/docs/tasks/identity-publication-repair.md +++ b/docs/tasks/identity-publication-repair.md @@ -20,3 +20,9 @@ The repair branch `amg-complete-identity-service` starts from local main and ret ## Evidence boundary Verification is pending for this publication. Earlier 95-test migration results were from a different full working checkout and do not establish this branch's result. This task does not implement new authentication, migrate live Oracle data, deploy services, disable repository rules, rebase history or force-push. + +## First clean verification and bounded repairs + +`./mvnw --batch-mode --no-transfer-progress verify` on committed `2a71a7d` executed 91 tests, with four failures and zero errors/skips. Two existing test classes reproduced mutable snapshot hashing and pre-creation verification regressions. The repair restores inherited immutable-identity equality/hashing and rejects pre-creation active verification before checking hashes or recording attempts. Tests retain their assertions. OtpId's private field is renamed to avoid the existing PMD field/accessor finding; the public value() contract and identifier bytes are preserved. The five unpublished future identifier classes are outside this branch. Full re-verification is pending. + +Local main now tracks origin/main at `61832f9`; the owner deletion commit remains an ancestor of the repair branch. Live GitHub branch rules were inspected: pull requests and CodeQL scanning are required. diff --git a/payguard-identity-service/pom.xml b/payguard-identity-service/pom.xml index 567cec4..66092c3 100644 --- a/payguard-identity-service/pom.xml +++ b/payguard-identity-service/pom.xml @@ -11,7 +11,7 @@ payguard-identity-service - PayGuard OTP Service + PayGuard Identity Service 2.1.1 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 index e4158a1..9649aff 100644 --- 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 @@ -4,13 +4,13 @@ import java.util.Objects; import org.jspecify.annotations.Nullable; -/** Immutable identifier. Parsing validateAndReturns syntax, not existence or authorization. */ +/** Immutable identifier. Parsing validates syntax, not existence or authorization. */ public final class OtpId implements ValueObject { private static final String OTP_ID_PREFIX = "otp_"; - private final String value; + private final String identifier; private OtpId(String value) { - this.value = PrefixedIdValidator.validateAndReturn(value, OTP_ID_PREFIX); + this.identifier = PrefixedIdValidator.validateAndReturn(value, OTP_ID_PREFIX); } public static OtpId parse(String value) { @@ -18,22 +18,22 @@ public static OtpId parse(String value) { } public String value() { - return value; + return identifier; } @Override public boolean equals(@Nullable Object other) { - return this == other || (other instanceof OtpId that && value.equals(that.value)); + return this == other || (other instanceof OtpId that && identifier.equals(that.identifier)); } @Override public int hashCode() { - return Objects.hashCode(value); + return Objects.hashCode(identifier); } @Override public String toString() { - return value; + return identifier; } @Override 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 index 9998b96..2fe39e0 100644 --- 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 @@ -12,7 +12,6 @@ import domain.DomainEvent; import java.util.Objects; import java.util.Optional; -import org.jspecify.annotations.Nullable; /** * An OTP challenge associated with a PayGuard customer. @@ -20,6 +19,8 @@ *

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

Equality and hashing use the immutable challenge identity inherited from the aggregate base. + * *

Issuance and restoration

* *

Issuance requires an ACTIVE snapshot with zero recorded attempts and no consumption time. @@ -134,21 +135,4 @@ public CustomerId getCustomerId() { 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/policy/OtpVerificationPolicy.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java index cf1ef52..5d44d06 100644 --- 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 @@ -26,6 +26,9 @@ public static Optional findRejection( private static Optional evaluateActiveStateGuards( VerifyOtpCommand command, OtpSnapshot snapshot, OtpCodeHasher hasher) { + if (command.now().isBefore(snapshot.createdAt())) { + throw new IllegalArgumentException("verification time must not precede challenge creation"); + } if (!command.now().isBefore(snapshot.expiresAt())) { return Optional.of(OtpVerificationResult.EXPIRED); } From b230d030489209ad49b2d2583578d70b3dcf4ab3 Mon Sep 17 00:00:00 2001 From: Amir Golmoradi Date: Mon, 12 Oct 2026 11:10:17 +0330 Subject: [PATCH 5/5] Document the verified Identity integration --- README.md | 10 +++++----- docs/guides/postgresql-vault-migration.md | 18 ++++++++++-------- docs/issues/postgresql-vault-follow-up.md | 6 ++++-- docs/tasks/identity-publication-repair.md | 16 ++++++++++++---- docs/tasks/postgresql-vault-migration.md | 2 +- 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 847f023..7391626 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![CI](https://img.shields.io/badge/CI-GitHub%20Actions-2088FF?style=flat-square&logo=githubactions&logoColor=white)](#) [![Java](https://img.shields.io/badge/Java-25-orange?style=flat-square&logo=openjdk)](https://openjdk.org) -[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.0.1-brightgreen?style=flat-square&logo=springboot)](https://spring.io/projects/spring-boot) +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.1.1-brightgreen?style=flat-square&logo=springboot)](https://spring.io/projects/spring-boot) [![Architecture](https://img.shields.io/badge/Architecture-Hexagonal%20%7C%20DDD-blueviolet?style=flat-square)](https://alistair.cockburn.us/hexagonal-architecture/) [![License](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](LICENSE) @@ -64,9 +64,9 @@ It is not a payment-card switch or a full core-banking platform. The scope is in | Service | Responsibility | Persistence | Current State | |---|---|---|---| -| `wallet-service` | Account balances, double-entry ledger, holds/captures | Dedicated Oracle database via **jOOQ** (append-only, no ORM overhead) | In development | -| `loan-service` | Loan lifecycle, amortization schedules, repayments | Dedicated Oracle database via JPA/Hibernate | In development | -| `collateral-service` | Collateral locking, price valuation, LTV monitoring, liquidation | Dedicated Oracle database via JPA/Hibernate | In development | +| `wallet-service` | Account balances, double-entry ledger, holds/captures | Dedicated PostgreSQL database with jOOQ Open Source configured; SQL adapter pending | In development | +| `loan-service` | Loan lifecycle, amortization schedules, repayments | Dedicated PostgreSQL schema; JPA repository adapter pending | In development | +| `collateral-service` | Collateral locking, price valuation, LTV monitoring, liquidation | Dedicated PostgreSQL schema; JPA repository adapter pending | In development | | `api-gateway` | Single entry point; REST from clients, gRPC to internal services | — | Planned | > `wallet-service` deliberately uses jOOQ instead of JPA: a ledger is append-only, and jOOQ gives explicit control over `INSERT`-only SQL with no risk of an accidental `UPDATE` slipping in through dirty-checking. `loan-service` and `collateral-service` manage mutable, CRUD-shaped state, where JPA is a better fit. @@ -109,7 +109,7 @@ Use JDK **25**, Docker/Compose, Python 3 and OpenSSL. The Maven wrapper is inclu ./mvnw -B -ntp clean verify ``` -The full build still exposes existing domain/static-analysis defects described in the implementation guide. Do not bypass those checks to claim a passing build. +The committed Identity integration passed full Maven verification with 91 tests and zero failures, errors or skips, including the existing domain regressions and real PostgreSQL tests. Formatting and static-analysis checks also passed. See [the publication repair log](docs/tasks/identity-publication-repair.md) for the exact source revision and evidence. For the isolated, persistent TLS PostgreSQL/Vault rehearsal, follow [the implementation guide](docs/guides/postgresql-vault-migration.md). It gives the exact preparation, initialization, migration and test commands. Generated credentials, certificates and unseal material live under ignored `.local/payguard` with protected permissions. `.env.example` contains only public settings. Never copy secrets into an image or publish that directory. diff --git a/docs/guides/postgresql-vault-migration.md b/docs/guides/postgresql-vault-migration.md index 7805680..71eb771 100644 --- a/docs/guides/postgresql-vault-migration.md +++ b/docs/guides/postgresql-vault-migration.md @@ -2,17 +2,17 @@ ## Scope and evidence boundary -This guide records the migration implementation originally developed in `amg-postgresql-vault` and published separately on `amg-postgres-vault`. The checkout already contained substantial owner changes, including the replacement of the retired OTP service by Identity. Those changes were preserved. No commit, push, PR, existing-volume deletion or live-data cutover was performed. +This guide records the migration originally developed in `amg-postgresql-vault`, published in PR #35, and integrated with the owner's Identity replacement on `amg-complete-identity-service`. No existing-volume deletion or live-data cutover was performed. Available business services are Identity, Wallet, Loan and Collateral. Gateway and Proto have no owned database. Identity contains four package modules in one deployment, so it keeps one owned database. Source inspection found actual Identity JPA challenge/audit adapters, but only repository ports in Loan/Collateral and no Wallet schema, SQL adapter or jOOQ code-generation setup. Configuring their PostgreSQL/Vault infrastructure does not establish persisted financial workflows that do not yet exist. -In the complete implementation checkout, normal runtime/build/service tests select PostgreSQL. Oracle migrations remain historical input, unchanged, in vendor-separated legacy locations. No running Oracle source or approved historical UUID-to-prefixed-ID mapping was supplied; live-data migration remains untested and blocked on those inputs. Absence of a running database was never treated as evidence that data could be discarded. +Normal runtime/build/service tests on the integration branch select PostgreSQL. Oracle migrations remain historical input, unchanged, in vendor-separated legacy locations. No running Oracle source or approved historical UUID-to-prefixed-ID mapping was supplied; live-data migration remains untested and blocked on those inputs. Absence of a running database was never treated as evidence that data could be discarded. ## Publication scope and dependencies -The migration-only branch intentionally excludes the owner's separate Identity domain work in PR #33 (`amg-identity-otp-domain`), unrelated local edits, retirement of the old OTP module, and the root module switch. Those changes are not attributed to this migration commit. Some pre-existing Identity application/port/bootstrap files also remain outside this branch because they were not changed during the migration. +Migration-only PR #35 deliberately excluded the owner's Identity domain PR #33, OTP retirement, root module switch and unchanged application foundations. PRs #33 and #35 were subsequently merged. The integration branch preserves the owner's retirement commit, registers Identity in the reactor and includes its required application, bootstrap, adapter and test files. Five unused future identifier classes and unrelated local files remain outside the publication. -The test/image/readiness results below were executed against the complete local implementation checkout, including those owner foundations. They are not claims that this migration-only commit builds independently from current main. Apply/merge the separately owned Identity integration and retire the old module before treating the reproduction commands as a clean-branch result. The PR records this dependency and the existing PMD findings explicitly; do not resolve them by committing someone else's pending work or disabling checks. +The historical migration rehearsal results below came from the earlier full local checkout. The current integration was separately verified from a clean committed worktree: full Maven verification on `fd29d35` passed 91 tests and every configured quality gate. The first clean run reproduced two domain regressions; bounded production fixes restored stable identity hashing and rejection before challenge creation, with the existing test assertions retained. See [the publication repair log](../tasks/identity-publication-repair.md) for current publication and CI status. ## Versions and decisions @@ -270,9 +270,11 @@ Do not run clean builds concurrently with the Java Vault runner: it reads shared Baseline full verify failed existing shared import formatting before tests. Baseline Identity diagnostic run executed 53 tests with 4 failures, 25 errors and 8 skips. Required nullable lifecycle fixes made persisted flows testable. An earlier verification executed 76 tests with two domain-contract failures; the current checkout contains owner changes to aggregate identity hashing and pre-creation validation, and those regressions now pass. They are recorded as historical findings, not unresolved defects. -Observed evidence as of this checkpoint: +Current committed integration evidence: `./mvnw --batch-mode --no-transfer-progress verify` on `fd29d35` exited 0 in a clean worktree with **91 tests, zero failures/errors/skips** (Identity 74, other modules 17). Spotless, Checkstyle, PMD, CPD and SpotBugs passed. This includes the 14 PostgreSQL Identity infrastructure cases and existing concurrency/domain/application tests. Native Maven Flyway configuration was also verified on disposable PostgreSQL 17.11: Loan V1 installed, all five tables/two sequences were owned by `loan_owner`, and missing owner initialization was rejected. The Vault-managed helper remains the complete local migration workflow. -- Current normal `./mvnw -B -ntp verify`: **95 tests passed, zero failures/errors/skips**. Shared 4, Proto 1, Gateway 2, Wallet 1, Loan 5, Collateral 4, Identity 78. Exit code **1** because Identity PMD reports six existing `AvoidFieldNameMatchingMethodName` findings; no checks were disabled. Log `/tmp/payguard-final-current-verify.log`. +Historical migration rehearsal evidence from the earlier full working checkout: + +- Earlier normal `./mvnw -B -ntp verify`: **95 tests passed, zero failures/errors/skips**. Shared 4, Proto 1, Gateway 2, Wallet 1, Loan 5, Collateral 4, Identity 78. Exit code **1** because Identity PMD reported six existing `AvoidFieldNameMatchingMethodName` findings; no checks were disabled. Log `/tmp/payguard-final-current-verify.log`. - All modules before Identity passed normal Spotless/Checkstyle/PMD/CPD/SpotBugs. Independent Identity SpotBugs passed with zero bug/error instances; its six typed-ID PMD naming findings remain separate work. Package build with tests skipped succeeded for all four service JARs; it is not counted as test evidence. - Identity real PostgreSQL suite: 14 passing tests, including competing verification, failed attempts, first issuance/replacement, final boundaries, restored states, ownership, case and rollback. Application contracts: 11 passing tests. Loan/Collateral tests exercise exact schema amounts/UUID/time/null/constraints; absent persisted financial flows remain unproved. - Synthetic export conversion: 3 Python tests passed, zero skipped. @@ -291,7 +293,7 @@ Surefire reports are under each module's `target/surefire-reports`; the Vault ru ## Remaining Oracle references and limits -The migration-only publication also retains the original tracked `payguard-otp-service` and root module selection, because their retirement/switch was owner work excluded from this commit. They still reference Oracle until that separate integration is applied. The list below describes the complete implementation checkout after that integration, not a claim that the migration-only branch has already removed those owner-controlled paths. +PR #35 retained the old OTP module because its retirement was separate owner work. The integration branch removes that module and selects Identity. The remaining Oracle references are historical migrations, documentation and unrelated price-oracle terminology: - `payguard-{identity,loan,collateral}-service/src/main/resources/db/legacy/oracle/*.sql`: exact historical vendor migrations and the legacy populated-data guard. - `OtpInfrastructureIntegrationTest.archivedOracleMigrationRetainsPopulatedDataGuard`: reads the legacy guard as text, without an Oracle engine. `PostgresqlMigrationRehearsalTest` mentions the unavailable Oracle source in Javadoc; it executes PostgreSQL only. @@ -301,6 +303,6 @@ The migration-only publication also retains the original tracked `payguard-otp-s Full remaining-reference search is `rg -n -i 'oracle|ojdbc|varchar2|sys_guid|gvenzl|pl/sql' --glob '!**/target/**' --glob '!.local/**'`. Treat matched market-oracle names and URLs to Java documentation separately from JDBC/database references. All original Oracle script bytes are retained; no clean/truncation command is added. -Implemented: available PostgreSQL dependency/config/schema paths, Identity database concurrency, service-scoped Vault client/pool/secrets, persistent local TLS/SCRAM/Raft/audit provisioning, separate migrations and reviewable rehearsal tooling. Locally tested scope is listed above and in the work log. Remaining quality gate: six existing typed-ID PMD naming findings. Production-dependent: certificate/SecretID/operator supervision, HA/auto-unseal, encrypted volume/backup hosting, custody, audit retention, capacity and disaster recovery. Not implemented: missing financial repositories, authenticator enrollment/recovery, external auth/Keycloak, durable event/delivery recovery or a new IAM system. +Implemented: available PostgreSQL dependency/config/schema paths, complete Identity module registration, database concurrency, service-scoped Vault client/pool/secrets, persistent local TLS/SCRAM/Raft/audit provisioning, separate migrations and reviewable rehearsal tooling. Current committed Maven verification passes; five unpublished future ID classes remain local owner work. Production-dependent: certificate/SecretID/operator supervision, HA/auto-unseal, encrypted volume/backup hosting, custody, audit retention, capacity and disaster recovery. Not implemented: missing financial repositories, authenticator enrollment/recovery, external auth/Keycloak, durable event/delivery recovery or a new IAM system. Review [separate GitHub Issue drafts](../issues/postgresql-vault-follow-up.md) for acceptance criteria. Security references describe the PayGuard design, not invented internal company deployments: [OWASP secrets management](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html), [cryptographic storage](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html), [Vault AppRole](https://developer.hashicorp.com/vault/docs/auth/approle), [KV v2](https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2), [Transit](https://developer.hashicorp.com/vault/docs/secrets/transit), [Stripe customer API-key guidance](https://docs.stripe.com/keys-best-practices). diff --git a/docs/issues/postgresql-vault-follow-up.md b/docs/issues/postgresql-vault-follow-up.md index 820210c..32b7cbe 100644 --- a/docs/issues/postgresql-vault-follow-up.md +++ b/docs/issues/postgresql-vault-follow-up.md @@ -4,7 +4,7 @@ These are reviewable drafts, not issues posted to GitHub. They describe existing ## Keep OTP identity hashing stable across state changes -**Current status:** Historical finding, resolved in the current checkout's owner domain changes. The normal verification on 2026-10-12 passed the existing stable-hash and collection-membership regressions. Do not open this as an unresolved issue without a new failing reproduction. +**Current status:** Resolved in committed Identity integration `fd29d35`. The clean publication build reproduced the regression before the fix; full verification then passed 91 tests, including the existing stable-hash and collection-membership cases. Do not open this as an unresolved issue without a new failing reproduction. **Original problem:** `OTP.equals`/`hashCode` included mutable snapshot state. `OTPTest.aggregateHashCodeRemainsStableAfterStateTransition` failed when consumption changed state; a stored hash collection key could become unreachable. @@ -12,7 +12,7 @@ These are reviewable drafts, not issues posted to GitHub. They describe existing ## Decide verification timestamps before challenge creation -**Current status:** Historical finding, resolved in the current checkout's owner domain changes. Both pre-creation rejection regressions passed normal verification on 2026-10-12, including wrong-code input without state mutation. +**Current status:** Resolved in committed Identity integration `fd29d35`. Both pre-creation rejection regressions passed the clean 91-test verification, including wrong-code input without state mutation. **Original problem:** `OTPTest.verificationBeforeCreationIsRejectedWithoutMutation` expected `IllegalArgumentException`; validation threw `OtpConsumedTimeOutsideValidityPeriodException`. This was a domain/API rejection contract decision, separate from PostgreSQL microsecond precision. @@ -50,6 +50,8 @@ These are reviewable drafts, not issues posted to GitHub. They describe existing ## Resolve existing typed-identifier PMD naming findings +**Current status:** The published OtpId finding is resolved by renaming its private field while preserving value(). The other five classes are unused local owner work and remain outside this publication. Normal committed PMD/CPD/SpotBugs and full verification pass; no analysis rule was disabled. + **Problem:** Identity's existing AccountId, MembershipId, OtpAuditId, OtpId, RegistrationId and TenantId each use a `value` field and `value()` accessor. PMD flags these six `AvoidFieldNameMatchingMethodName` findings. The migration preserves their actual accessor/API contracts. **Acceptance criteria:** Decide a repository-wide convention or a justified precise analysis configuration for these value objects. Preserve serialized prefixed values and public accessors; do not silence broader checks or rename CustomerId.getValue incidentally. Run normal PMD/CPD/SpotBugs and full verify while retaining the passing domain regressions. diff --git a/docs/tasks/identity-publication-repair.md b/docs/tasks/identity-publication-repair.md index 719745d..96db933 100644 --- a/docs/tasks/identity-publication-repair.md +++ b/docs/tasks/identity-publication-repair.md @@ -12,17 +12,25 @@ The repair branch `amg-complete-identity-service` starts from local main and ret - [x] Preserve the original deletion commit and create an amg branch from main. - [x] Record original file hashes and tracked edits in the ignored local repair directory. - [x] Commit the complete Identity module selection and source dependency closure (`23f4a77`). -- [ ] Verify the committed source from a clean worktree; resolve actual formatting, compilation, test and analysis failures without weakening checks. +- [x] Verify the committed source from a clean worktree; resolve actual formatting, compilation, test and analysis failures without weakening checks. - [x] Commit the independently verified native Flyway environment fix and documentation separately. - [ ] Push normally, open a PR to main and inspect GitHub quality/security/CodeQL results. -- [ ] Leave local main aligned with remote main, preserving the owner's deletion commit on the repair branch. +- [x] Leave local main aligned with remote main, preserving the owner's deletion commit on the repair branch. ## Evidence boundary -Verification is pending for this publication. Earlier 95-test migration results were from a different full working checkout and do not establish this branch's result. This task does not implement new authentication, migrate live Oracle data, deploy services, disable repository rules, rebase history or force-push. +Local full verification passed for this publication; GitHub checks are pending. Earlier 95-test migration results were from a different full working checkout and do not establish this branch's result. This task does not implement new authentication, migrate live Oracle data, deploy services, disable repository rules, rebase history or force-push. ## First clean verification and bounded repairs -`./mvnw --batch-mode --no-transfer-progress verify` on committed `2a71a7d` executed 91 tests, with four failures and zero errors/skips. Two existing test classes reproduced mutable snapshot hashing and pre-creation verification regressions. The repair restores inherited immutable-identity equality/hashing and rejects pre-creation active verification before checking hashes or recording attempts. Tests retain their assertions. OtpId's private field is renamed to avoid the existing PMD field/accessor finding; the public value() contract and identifier bytes are preserved. The five unpublished future identifier classes are outside this branch. Full re-verification is pending. +`./mvnw --batch-mode --no-transfer-progress verify` on committed `2a71a7d` executed 91 tests, with four failures and zero errors/skips. Two existing test classes reproduced mutable snapshot hashing and pre-creation verification regressions. The repair restores inherited immutable-identity equality/hashing and rejects pre-creation active verification before checking hashes or recording attempts. Tests retain their assertions. OtpId's private field is renamed to avoid the existing PMD field/accessor finding; the public value() contract and identifier bytes are preserved. The five unpublished future identifier classes are outside this branch. Local main now tracks origin/main at `61832f9`; the owner deletion commit remains an ancestor of the repair branch. Live GitHub branch rules were inspected: pull requests and CodeQL scanning are required. + +## Current local verification + +The same full Maven command on committed `fd29d35` in `/tmp/payguard-identity-repair-verification` exited 0: **91 tests, zero failures/errors/skips**. Identity executed 74; Shared 4, Proto 1, Gateway 2, Wallet 1, Loan 5 and Collateral 4. Spotless, Checkstyle, PMD, CPD and SpotBugs passed without disabling checks. Identity's 14 real PostgreSQL infrastructure tests and 11 application contracts passed, including competing verification/issuance and attempt/replay boundaries. Reports were inspected under each module's target/surefire-reports; the command log is `/tmp/payguard-identity-repair-verify.log`. + +Native Flyway environment configuration was separately verified against disposable PostgreSQL 17.11: Loan V1 applied; five tables/two sequences were owned by loan_owner; omission of FLYWAY_INIT_SQL failed with the intended owner-selection error. The disposable containers and their own anonymous volumes were removed. Existing PostgreSQL/Vault infrastructure was not involved in those configuration checks. + +The publication excludes protected local artifacts and actual local credential markers; changed tracked files were checked without printing those values. GitHub CodeQL and the mandatory database-security/image jobs still need their own remote results. diff --git a/docs/tasks/postgresql-vault-migration.md b/docs/tasks/postgresql-vault-migration.md index 19639f8..168adf7 100644 --- a/docs/tasks/postgresql-vault-migration.md +++ b/docs/tasks/postgresql-vault-migration.md @@ -1,6 +1,6 @@ # PostgreSQL and Vault implementation work log -Status: scoped implementation and local verification complete. Normal full verify is blocked by six existing Identity PMD naming findings; live-data and missing financial-adapter work remain outside the proven scope. See the guide for production-dependent limitations. Current source is authoritative. +Status: historical migration implementation/rehearsal record. Subsequent committed Identity integration passed full verification with 91 tests and all quality gates; see [the publication repair log](identity-publication-repair.md) for current source, publication and CI evidence. The results below describe the earlier full working checkout, including its then-unresolved PMD findings. Live-data and missing financial-adapter work remain outside the proven scope. Current source is authoritative. ## Authorization and preservation