Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
19 changes: 19 additions & 0 deletions docs/decission/ADR-0007-otp-code-and-totp-strategy.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions docs/decission/ADR-0008-otp-rate-limits-and-step-up-token.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions docs/decission/ADR-0009-otp-device-binding.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions docs/decission/ADR-0010-otp-retention-and-audit.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions docs/events/otp-event-v1.schema.json
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions payguard-otp-service/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
44 changes: 44 additions & 0 deletions payguard-otp-service/README.md
Original file line number Diff line number Diff line change
@@ -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.
92 changes: 92 additions & 0 deletions payguard-otp-service/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>dev.amg.payguard</groupId>
<artifactId>payguard-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>

<artifactId>payguard-otp-service</artifactId>
<name>PayGuard OTP Service</name>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-oracle</artifactId>
</dependency>
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc17</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-oracle-xe</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.redis</groupId>
<artifactId>testcontainers-redis</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
@@ -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) {}
Loading
Loading