From 1420f248f7fb6ae77e501bf391cecfb2bc88374e Mon Sep 17 00:00:00 2001 From: Amir Golmoradi Date: Sun, 27 Sep 2026 20:14:26 +0330 Subject: [PATCH] Add secure OTP bounded context --- .env.example | 9 + docker-compose.yaml | 50 ++++ .../ADR-0007-otp-code-and-totp-strategy.md | 19 ++ ...-0008-otp-rate-limits-and-step-up-token.md | 19 ++ docs/decission/ADR-0009-otp-device-binding.md | 18 ++ .../ADR-0010-otp-retention-and-audit.md | 18 ++ docs/events/otp-event-v1.schema.json | 21 ++ 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 + pom.xml | 1 + 65 files changed, 2305 insertions(+) create mode 100644 docs/decission/ADR-0007-otp-code-and-totp-strategy.md create mode 100644 docs/decission/ADR-0008-otp-rate-limits-and-step-up-token.md create mode 100644 docs/decission/ADR-0009-otp-device-binding.md create mode 100644 docs/decission/ADR-0010-otp-retention-and-audit.md create mode 100644 docs/events/otp-event-v1.schema.json create mode 100644 payguard-otp-service/Dockerfile create mode 100644 payguard-otp-service/README.md create mode 100644 payguard-otp-service/pom.xml create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/OtpServiceApplication.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssueOtpCommand.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/IssuedOtp.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpApplicationService.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpChallengeNotFoundException.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpRateLimitExceededException.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/OtpVerificationException.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifiedOtp.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/application/VerifyOtpCommand.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/AuthenticatorCodeVerifier.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/DeliveryPort.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpAuditLogPort.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallenge.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeRepository.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChallengeStatus.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpChannel.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeGenerator.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpCodeHasher.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpEventPublisher.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpPurpose.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationMode.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/OtpVerificationResult.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/RateLimiter.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/domain/StepUpTokenPort.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/config/OtpConfiguration.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/EmailDeliveryAdapter.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/MockDeliveryAdapter.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/PushDeliveryAdapter.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/delivery/SmsDeliveryAdapter.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/kafka/KafkaOtpEventPublisher.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpAuditLogAdapter.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/JpaOtpChallengeRepositoryAdapter.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogEntity.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpAuditLogJpaRepository.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeEntity.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/persistence/OtpChallengeJpaRepository.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisRateLimiterAdapter.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/redis/RedisStepUpTokenAdapter.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/AuthenticatorSecretPort.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasher.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/SecureNumericOtpGenerator.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/TotpAuthenticatorCodeVerifier.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpController.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/OtpExceptionHandler.java create mode 100644 payguard-otp-service/src/main/java/dev/amg/payguard/otp/interfaces/rest/StepUpTokenController.java create mode 100644 payguard-otp-service/src/main/resources/application.yaml create mode 100644 payguard-otp-service/src/main/resources/banner.txt create mode 100644 payguard-otp-service/src/main/resources/db/migration/V1__otp_schema.sql create mode 100644 payguard-otp-service/src/test/java/dev/amg/payguard/otp/application/OtpApplicationServiceTest.java create mode 100644 payguard-otp-service/src/test/java/dev/amg/payguard/otp/domain/OtpChallengeTest.java create mode 100644 payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/OtpInfrastructureIntegrationTest.java create mode 100644 payguard-otp-service/src/test/java/dev/amg/payguard/otp/infrastructure/security/HmacOtpCodeHasherTest.java create mode 100644 payguard-otp-service/src/test/resources/application-test.yaml 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