diff --git a/.env.example b/.env.example
index 5f6083d..73eae4e 100644
--- a/.env.example
+++ b/.env.example
@@ -16,6 +16,15 @@ COLLATERAL_ORACLE_APP_PASSWORD=change-collateral-app-password
WALLET_ORACLE_JDBC_URL=jdbc:oracle:thin:@localhost:1521/FREE
LOAN_ORACLE_JDBC_URL=jdbc:oracle:thin:@localhost:1522/FREE
COLLATERAL_ORACLE_JDBC_URL=jdbc:oracle:thin:@localhost:1523/FREE
+OTP_ORACLE_PORT=1524
+OTP_ORACLE_SYSTEM_PASSWORD=change-otp-system-password
+OTP_ORACLE_APP_USER=payguard_otp
+OTP_ORACLE_APP_PASSWORD=change-otp-app-password
+OTP_ORACLE_JDBC_URL=jdbc:oracle:thin:@localhost:1524/FREE
+OTP_SECURITY_PEPPER=replace-with-a-random-secret-at-least-32-characters
+OTP_SERVICE_PORT=8084
+REDIS_HOST=localhost
+REDIS_PORT=6379
WALLET_SERVICE_PORT=8081
LOAN_SERVICE_PORT=8082
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 1c1a410..b8c0190 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -22,6 +22,17 @@ services:
redis:
image: redis:8-alpine
container_name: payguard-redis
+ ports:
+ - "${REDIS_PORT}:6379"
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ oracle-loan:
+ image: ${ORACLE_IMAGE}
+ container_name: ${COMPOSE_PROJECT_NAME}-oracle-loan
ports:
- "${LOAN_ORACLE_PORT}:1521"
environment:
@@ -37,6 +48,24 @@ services:
volumes:
- oracle_loan_data:/opt/oracle/oradata
+ oracle-otp:
+ image: ${ORACLE_IMAGE}
+ container_name: ${COMPOSE_PROJECT_NAME}-oracle-otp
+ ports:
+ - "${OTP_ORACLE_PORT}:1521"
+ environment:
+ ORACLE_PASSWORD: ${OTP_ORACLE_SYSTEM_PASSWORD}
+ APP_USER: ${OTP_ORACLE_APP_USER}
+ APP_USER_PASSWORD: ${OTP_ORACLE_APP_PASSWORD}
+ healthcheck:
+ test: ["CMD-SHELL", "/opt/oracle/checkDBStatus.sh"]
+ interval: 10s
+ timeout: 5s
+ retries: 20
+ start_period: 45s
+ volumes:
+ - oracle_otp_data:/opt/oracle/oradata
+
oracle-collateral:
image: ${ORACLE_IMAGE}
container_name: ${COMPOSE_PROJECT_NAME}-oracle-collateral
@@ -132,8 +161,29 @@ services:
oracle-collateral: { condition: service_healthy }
kafka: { condition: service_healthy }
+ otp-service:
+ build:
+ context: .
+ dockerfile: payguard-otp-service/Dockerfile
+ container_name: ${COMPOSE_PROJECT_NAME}-otp-service
+ ports:
+ - "${OTP_SERVICE_PORT}:8084"
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:oracle:thin:@oracle-otp:1521/FREE
+ SPRING_DATASOURCE_USERNAME: ${OTP_ORACLE_APP_USER}
+ SPRING_DATASOURCE_PASSWORD: ${OTP_ORACLE_APP_PASSWORD}
+ OTP_SECURITY_PEPPER: ${OTP_SECURITY_PEPPER}
+ REDIS_HOST: redis
+ REDIS_PORT: 6379
+ KAFKA_BOOTSTRAP_SERVERS: kafka:29092
+ depends_on:
+ oracle-otp: { condition: service_healthy }
+ redis: { condition: service_healthy }
+ kafka: { condition: service_healthy }
+
volumes:
oracle_wallet_data:
oracle_loan_data:
oracle_collateral_data:
kafka_data:
+ oracle_otp_data:
diff --git a/docs/decission/ADR-0007-otp-code-and-totp-strategy.md b/docs/decission/ADR-0007-otp-code-and-totp-strategy.md
new file mode 100644
index 0000000..2de8bba
--- /dev/null
+++ b/docs/decission/ADR-0007-otp-code-and-totp-strategy.md
@@ -0,0 +1,19 @@
+# ADR-0007: Use random numeric OTPs for delivery and RFC 6238 for authenticator apps
+
+## Status
+
+Accepted
+
+## Decision
+
+SMS, email, and push challenges use six-digit values generated by
+`SecureRandom`. The service stores only a salted HMAC-SHA256 hash with a
+server-side pepper. Authenticator-app challenges use RFC 6238 TOTP with a
+30-second step and a one-step clock-skew window. TOTP secrets are resolved
+through `AuthenticatorSecretPort` and are not persisted in this service.
+
+## Consequences
+
+The delivery adapters can be replaced without changing the domain. TOTP
+verification remains compatible with standard authenticator applications, while
+secret custody stays with the identity/vault boundary.
diff --git a/docs/decission/ADR-0008-otp-rate-limits-and-step-up-token.md b/docs/decission/ADR-0008-otp-rate-limits-and-step-up-token.md
new file mode 100644
index 0000000..0c13917
--- /dev/null
+++ b/docs/decission/ADR-0008-otp-rate-limits-and-step-up-token.md
@@ -0,0 +1,19 @@
+# ADR-0008: Redis rate limits and opaque one-time step-up tokens
+
+## Status
+
+Accepted
+
+## Decision
+
+Redis counters allow three issuance attempts and twenty verification attempts
+per user, purpose, and source IP in a fifteen-minute window. A successful
+challenge creates a cryptographically random opaque token in Redis with a
+three-minute TTL. Consumption uses Redis get-and-delete and validates the exact
+user and purpose.
+
+## Consequences
+
+High-churn state does not burden Oracle. A token cannot be replayed or used for
+another purpose, and downstream services can validate it through the OTP
+service's gateway contract.
diff --git a/docs/decission/ADR-0009-otp-device-binding.md b/docs/decission/ADR-0009-otp-device-binding.md
new file mode 100644
index 0000000..d139c7b
--- /dev/null
+++ b/docs/decission/ADR-0009-otp-device-binding.md
@@ -0,0 +1,18 @@
+# ADR-0009: Optional device/session binding for OTP challenges
+
+## Status
+
+Accepted
+
+## Decision
+
+Callers may provide a device/session fingerprint. The OTP service stores only a
+peppered hash and requires the same fingerprint during verification. Binding is
+optional for compatibility with login flows that do not yet expose a stable
+device identifier.
+
+## Consequences
+
+A stolen code cannot be used from a different bound device. Fingerprints remain
+opaque and are not logged; callers must avoid putting raw device identifiers in
+the request logs.
diff --git a/docs/decission/ADR-0010-otp-retention-and-audit.md b/docs/decission/ADR-0010-otp-retention-and-audit.md
new file mode 100644
index 0000000..063f84c
--- /dev/null
+++ b/docs/decission/ADR-0010-otp-retention-and-audit.md
@@ -0,0 +1,18 @@
+# ADR-0010: Minimized OTP retention and immutable audit facts
+
+## Status
+
+Accepted
+
+## Decision
+
+Challenge metadata and audit facts are retained for 90 days by default, with a
+jurisdiction-configurable `retention_until` field. OTP codes and delivery
+destinations are never persisted. Audit rows are append-only and indexed by
+opaque subject and time to support GDPR accountability and breach scoping.
+
+## Consequences
+
+The service supports fraud investigation without retaining short-lived MFA
+secrets longer than necessary. A scheduled purge may delete rows after
+`retention_until`; financial records in other bounded contexts are unaffected.
diff --git a/docs/events/otp-event-v1.schema.json b/docs/events/otp-event-v1.schema.json
new file mode 100644
index 0000000..7315acf
--- /dev/null
+++ b/docs/events/otp-event-v1.schema.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://payguard.dev/events/otp-event-v1.schema.json",
+ "title": "PayGuard OTP event v1",
+ "type": "object",
+ "required": ["type", "challengeId", "userId", "purpose", "channel", "occurredAt"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["otp.challenge-issued", "otp.verified", "otp.failed", "otp.locked-out"]
+ },
+ "challengeId": {"type": "string", "format": "uuid"},
+ "userId": {"type": "string", "minLength": 1},
+ "purpose": {"type": "string"},
+ "channel": {"type": "string"},
+ "result": {"type": ["string", "null"]},
+ "occurredAt": {"type": "string", "format": "date-time"},
+ "reason": {"type": ["string", "null"]}
+ },
+ "additionalProperties": false
+}
diff --git a/payguard-otp-service/Dockerfile b/payguard-otp-service/Dockerfile
new file mode 100644
index 0000000..9d740dc
--- /dev/null
+++ b/payguard-otp-service/Dockerfile
@@ -0,0 +1,13 @@
+# 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
new file mode 100644
index 0000000..c8efd8c
--- /dev/null
+++ b/payguard-otp-service/README.md
@@ -0,0 +1,44 @@
+# 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
new file mode 100644
index 0000000..90238b7
--- /dev/null
+++ b/payguard-otp-service/pom.xml
@@ -0,0 +1,92 @@
+
+
+ 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
new file mode 100644
index 0000000..b1d5365
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/OtpServiceApplication.java
@@ -0,0 +1,12 @@
+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
new file mode 100644
index 0000000..27fa7ec
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssueOtpCommand.java
@@ -0,0 +1,12 @@
+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
new file mode 100644
index 0000000..3f57a66
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssuedOtp.java
@@ -0,0 +1,9 @@
+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
new file mode 100644
index 0000000..15f0c74
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpApplicationService.java
@@ -0,0 +1,182 @@
+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
new file mode 100644
index 0000000..df19fd4
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpChallengeNotFoundException.java
@@ -0,0 +1,12 @@
+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
new file mode 100644
index 0000000..9ed363f
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpRateLimitExceededException.java
@@ -0,0 +1,10 @@
+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
new file mode 100644
index 0000000..95814de
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpVerificationException.java
@@ -0,0 +1,18 @@
+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
new file mode 100644
index 0000000..b9f10d7
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifiedOtp.java
@@ -0,0 +1,6 @@
+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
new file mode 100644
index 0000000..90066fd
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifyOtpCommand.java
@@ -0,0 +1,12 @@
+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
new file mode 100644
index 0000000..b2d0f23
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/AuthenticatorCodeVerifier.java
@@ -0,0 +1,9 @@
+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
new file mode 100644
index 0000000..fcadf54
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/DeliveryPort.java
@@ -0,0 +1,15 @@
+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
new file mode 100644
index 0000000..d31c2c3
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpAuditLogPort.java
@@ -0,0 +1,11 @@
+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
new file mode 100644
index 0000000..f6f1452
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallenge.java
@@ -0,0 +1,236 @@
+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
new file mode 100644
index 0000000..ebfdad4
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeRepository.java
@@ -0,0 +1,13 @@
+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
new file mode 100644
index 0000000..492a7f6
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeStatus.java
@@ -0,0 +1,9 @@
+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
new file mode 100644
index 0000000..048564e
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChannel.java
@@ -0,0 +1,8 @@
+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
new file mode 100644
index 0000000..5a45023
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeGenerator.java
@@ -0,0 +1,7 @@
+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
new file mode 100644
index 0000000..b0b9d9d
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeHasher.java
@@ -0,0 +1,8 @@
+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
new file mode 100644
index 0000000..40e6aa0
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpEventPublisher.java
@@ -0,0 +1,20 @@
+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
new file mode 100644
index 0000000..3f7b398
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpPurpose.java
@@ -0,0 +1,10 @@
+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
new file mode 100644
index 0000000..00aa23e
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationMode.java
@@ -0,0 +1,6 @@
+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
new file mode 100644
index 0000000..2b1b8b9
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationResult.java
@@ -0,0 +1,12 @@
+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
new file mode 100644
index 0000000..d2d47db
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/RateLimiter.java
@@ -0,0 +1,8 @@
+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
new file mode 100644
index 0000000..f9e27c2
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/StepUpTokenPort.java
@@ -0,0 +1,12 @@
+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
new file mode 100644
index 0000000..b301dd9
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/config/OtpConfiguration.java
@@ -0,0 +1,58 @@
+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
new file mode 100644
index 0000000..9cf1b97
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/EmailDeliveryAdapter.java
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 0000000..112c177
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/MockDeliveryAdapter.java
@@ -0,0 +1,19 @@
+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
new file mode 100644
index 0000000..fbd0038
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/PushDeliveryAdapter.java
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 0000000..6e0d081
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/SmsDeliveryAdapter.java
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 0000000..4aeff54
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/kafka/KafkaOtpEventPublisher.java
@@ -0,0 +1,33 @@
+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
new file mode 100644
index 0000000..0ee2220
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpAuditLogAdapter.java
@@ -0,0 +1,24 @@
+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
new file mode 100644
index 0000000..a377d36
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpChallengeRepositoryAdapter.java
@@ -0,0 +1,55 @@
+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
new file mode 100644
index 0000000..081a596
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogEntity.java
@@ -0,0 +1,54 @@
+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
new file mode 100644
index 0000000..af87577
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogJpaRepository.java
@@ -0,0 +1,6 @@
+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
new file mode 100644
index 0000000..e3a2da5
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeEntity.java
@@ -0,0 +1,126 @@
+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
new file mode 100644
index 0000000..a97a54f
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeJpaRepository.java
@@ -0,0 +1,13 @@
+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
new file mode 100644
index 0000000..7f61194
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisRateLimiterAdapter.java
@@ -0,0 +1,53 @@
+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
new file mode 100644
index 0000000..a5386e8
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisStepUpTokenAdapter.java
@@ -0,0 +1,69 @@
+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
new file mode 100644
index 0000000..24945d1
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/AuthenticatorSecretPort.java
@@ -0,0 +1,9 @@
+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
new file mode 100644
index 0000000..e092043
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasher.java
@@ -0,0 +1,63 @@
+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
new file mode 100644
index 0000000..572fbff
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/SecureNumericOtpGenerator.java
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 0000000..3393ef0
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/TotpAuthenticatorCodeVerifier.java
@@ -0,0 +1,81 @@
+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
new file mode 100644
index 0000000..defb71b
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java
@@ -0,0 +1,14 @@
+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
new file mode 100644
index 0000000..78d96e1
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpController.java
@@ -0,0 +1,90 @@
+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
new file mode 100644
index 0000000..6c01fd3
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpExceptionHandler.java
@@ -0,0 +1,42 @@
+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
new file mode 100644
index 0000000..4466703
--- /dev/null
+++ b/payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/StepUpTokenController.java
@@ -0,0 +1,39 @@
+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
new file mode 100644
index 0000000..dc0c2d2
--- /dev/null
+++ b/payguard-otp-service/src/main/resources/application.yaml
@@ -0,0 +1,46 @@
+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
new file mode 100644
index 0000000..83938d4
--- /dev/null
+++ b/payguard-otp-service/src/main/resources/banner.txt
@@ -0,0 +1,6 @@
+ ____ ____ _
+ | _ \ __ _ _ _/ ___|_ _ __ _ _ __ __| |
+ | |_) / _` | | | \___ \ | | |/ _` | '__/ _` |
+ | __/ (_| | |_| |___) || |_| (_| | | | (_| |
+ |_| \__,_|\__, |____/ \__,\__,_|_| \__,_|
+ |___/
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
new file mode 100644
index 0000000..5f6084a
--- /dev/null
+++ b/payguard-otp-service/src/main/resources/db/migration/V1__otp_schema.sql
@@ -0,0 +1,41 @@
+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
new file mode 100644
index 0000000..3c12e36
--- /dev/null
+++ b/payguard-otp-service/src/test/java/dev/amg/payguard/otp/application/OtpApplicationServiceTest.java
@@ -0,0 +1,176 @@
+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
new file mode 100644
index 0000000..8f80c7f
--- /dev/null
+++ b/payguard-otp-service/src/test/java/dev/amg/payguard/otp/domain/OtpChallengeTest.java
@@ -0,0 +1,102 @@
+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
new file mode 100644
index 0000000..19104dd
--- /dev/null
+++ b/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/OtpInfrastructureIntegrationTest.java
@@ -0,0 +1,29 @@
+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
new file mode 100644
index 0000000..779a0c4
--- /dev/null
+++ b/payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasherTest.java
@@ -0,0 +1,23 @@
+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
new file mode 100644
index 0000000..46c8f0f
--- /dev/null
+++ b/payguard-otp-service/src/test/resources/application-test.yaml
@@ -0,0 +1,9 @@
+spring:
+ flyway:
+ enabled: false
+ jpa:
+ open-in-view: false
+
+otp:
+ security:
+ pepper: test-pepper-with-at-least-32-characters
diff --git a/pom.xml b/pom.xml
index 13f1819..0dc3afa 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,6 +18,7 @@
payguard-wallet-service
payguard-loan-service
payguard-collateral-service
+ payguard-otp-service