diff --git a/.app.env.example b/.app.env.example new file mode 100644 index 0000000..ef425f6 --- /dev/null +++ b/.app.env.example @@ -0,0 +1,23 @@ +MONGO_URI=mongodb://root:password@db:27017 +DATABASE_NAME=tfgrid-kyc-db +PORT=8080 +CHALLENGE_WINDOW=120 +CHALLENGE_DOMAIN=kyc.dev.grid.tf +VERIFICATION_SUSPICIOUS_VERIFICATION_OUTCOME=APPROVED +VERIFICATION_EXPIRED_DOCUMENT_OUTCOME=APPROVED +VERIFICATION_MIN_BALANCE_TO_VERIFY_ACCOUNT=1000000 +IDENFY_BASE_URL=https://ivs.idenfy.com +IDENFY_API_KEY= +IDENFY_API_SECRET= +IDENFY_CALLBACK_SIGN_KEY= +IDENFY_WHITELISTED_IPS= +IDENFY_DEV_MODE=false +TFCHAIN_WS_PROVIDER_URL=wss://tfchain.dev.grid.tf +IP_LIMITER_MAX_TOKEN_REQUESTS=5 +IP_LIMITER_TOKEN_EXPIRATION=1440 +ID_LIMITER_MAX_TOKEN_REQUESTS=5 +ID_LIMITER_TOKEN_EXPIRATION=1440 +DEBUG=false +IDENFY_CALLBACK_URL=https://kyc.dev.grid.tf/webhooks/idenfy/verification-update +IDENFY_NAMESPACE= +VERIFICATION_ALWAYS_VERIFIED_IDS= \ No newline at end of file diff --git a/.db.env.example b/.db.env.example new file mode 100644 index 0000000..077b4a8 --- /dev/null +++ b/.db.env.example @@ -0,0 +1,2 @@ +MONGO_INITDB_ROOT_USERNAME=root +MONGO_INITDB_ROOT_PASSWORD=password \ No newline at end of file diff --git a/.express.env.example b/.express.env.example new file mode 100644 index 0000000..bf876a6 --- /dev/null +++ b/.express.env.example @@ -0,0 +1,4 @@ +ME_CONFIG_MONGODB_AUTH_USERNAME=root +ME_CONFIG_MONGODB_AUTH_PASSWORD=password +ME_CONFIG_BASICAUTH_USERNAME=admin +ME_CONFIG_BASICAUTH_PASSWORD=password \ No newline at end of file diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..dc9cdb7 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,58 @@ +name: Build and Publish Docker Image + +on: + release: + types: [published] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Generate tags + id: tags + run: | + if [ "${{ github.event_name }}" = "release" ]; then + VERSION=${GITHUB_REF#refs/tags/} + echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_OUTPUT + else + SHA=$(git rev-parse --short HEAD) + echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:edge-${SHA},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:edge-latest" >> $GITHUB_OUTPUT + fi + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.tags.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 6f72f89..2f5558a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.dll *.so *.dylib +bin/ # Test binary, built with `go test -c` *.test @@ -21,5 +22,9 @@ go.work go.work.sum -# env file +# env files .env +.*.env + +# other files +main diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8f0e7f4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache git + +COPY go.mod go.sum ./ + +RUN go mod download + +COPY . . +RUN VERSION=$(git describe --tags --always) && \ + CGO_ENABLED=0 GOOS=linux go build -o tfkycv -ldflags "-X github.com/threefoldtech/tf-kyc-verifier/internal/build.Version=$VERSION" cmd/api/main.go + +FROM alpine:3.19 + +COPY --from=builder /app/tfkycv . +RUN apk --no-cache add curl + +ENTRYPOINT ["/tfkycv"] + +EXPOSE 8080 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..69e0706 --- /dev/null +++ b/Makefile @@ -0,0 +1,132 @@ +# Variables +APP_NAME := tfkycv +IMAGE_NAME := ghcr.io/threefoldtech/tf-kyc-verifier +MAIN_PATH := cmd/api/main.go +SWAGGER_GENERAL_API_INFO_PATH := internal/handlers/handlers.go +DOCKER_COMPOSE := docker compose + +# Go related variables +GOBASE := $(shell pwd) +GOBIN := $(GOBASE)/bin +GOFILES := $(wildcard *.go) + +# Git related variables +GIT_COMMIT := $(shell git rev-parse --short HEAD) +VERSION := $(shell git describe --tags --always) + +# Build flags +LDFLAGS := -X github.com/threefoldtech/tf-kyc-verifier/internal/build.Version=$(VERSION) + +.PHONY: all build clean test coverage lint swagger run docker-build docker-up docker-down help + +# Default target +all: clean build + +# Build the application +build: + @echo "Building $(APP_NAME)..." + @go build -ldflags "$(LDFLAGS)" -o $(GOBIN)/$(APP_NAME) $(MAIN_PATH) + +# Clean build artifacts +clean: + @echo "Cleaning..." + @rm -rf $(GOBIN) + @go clean + +# Run tests +test: + @echo "Running tests..." + @go test -v ./... + +# Run tests with coverage +coverage: + @echo "Running tests with coverage..." + @go test -coverprofile=coverage.out ./... + @go tool cover -html=coverage.out + @rm coverage.out + +# Run linter +lint: + @echo "Running linter..." + @golangci-lint run + +# Generate swagger documentation +swagger: + @echo "Generating Swagger documentation..." + @export PATH=$PATH:$(go env GOPATH)/bin + @swag init -g $(SWAGGER_GENERAL_API_INFO_PATH) --output api/docs + +# Run the application locally +run: swagger build + @echo "Running $(APP_NAME)..." + @set -o allexport; . ./.app.env; set +o allexport; $(GOBIN)/$(APP_NAME) + +# Build docker image +docker-build: + @echo "Building Docker image..." + @docker build -t $(IMAGE_NAME):$(VERSION) . + +# Start docker compose services +docker-up: + @echo "Starting Docker services..." + @$(DOCKER_COMPOSE) up --build -d + +# Stop docker compose services +docker-down: + @echo "Stopping Docker services..." + @$(DOCKER_COMPOSE) down + +# Start development environment +dev: swagger docker-up + @echo "Starting development environment..." + @$(DOCKER_COMPOSE) logs -f api + +# Update dependencies +deps-update: + @echo "Updating dependencies..." + @go get -u ./... + @go mod tidy + +# Verify dependencies +deps-verify: + @echo "Verifying dependencies..." + @go mod verify + +# Check for security vulnerabilities +security-check: + @echo "Checking for security vulnerabilities..." + @gosec ./... + +# Format code +fmt: + @echo "Formatting code..." + @go fmt ./... + +# Show help +help: + @echo "Available targets:" + @echo " make : Build the application after cleaning" + @echo " make build : Build the application" + @echo " make clean : Clean build artifacts" + @echo " make test : Run tests" + @echo " make coverage : Run tests with coverage report" + @echo " make lint : Run linter" + @echo " make swagger : Generate Swagger documentation" + @echo " make run : Run the application locally" + @echo " make docker-build : Build Docker image" + @echo " make docker-up : Start Docker services" + @echo " make docker-down : Stop Docker services" + @echo " make dev : Start development environment" + @echo " make deps-update : Update dependencies" + @echo " make deps-verify : Verify dependencies" + @echo " make security-check: Check for security vulnerabilities" + @echo " make fmt : Format code" + @echo " make install-tools: Install development tools" + +# Install development tools +.PHONY: install-tools +install-tools: + @echo "Installing development tools..." + @go install github.com/swaggo/swag/cmd/swag@latest + @go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + @go install github.com/securego/gosec/v2/cmd/gosec@latest diff --git a/README.md b/README.md new file mode 100644 index 0000000..2977b47 --- /dev/null +++ b/README.md @@ -0,0 +1,319 @@ +# TF KYC Service + +## Overview + +TF KYC Service is a Go-based service that provides Know Your Customer (KYC) functionality for the TF Grid. It integrates with iDenfy for identity verification. + +## Features + +- Identity verification using iDenfy +- Blockchain integration with TFChain (Substrate-based) +- MongoDB for data persistence +- RESTful API endpoints for KYC operations +- Swagger documentation +- Containerized deployment + +## Prerequisites + +- Go 1.22+ +- MongoDB 4.4+ +- Docker and Docker Compose (for containerized deployment) +- iDenfy API credentials + +## Installation + +1. Clone the repository: + + ```bash + git clone https://github.com/yourusername/tf-kyc-verifier.git + cd tf-kyc-verifier + ``` + +2. Set up your environment variables: + + ```bash + cp .app.env.example .app.env + cp .db.env.example .db.env + ``` + +Edit `.app.env` and `.db.env` with your specific configuration details. + +## Configuration + +The application uses environment variables for configuration. Here's a list of all available configuration options: + +### Database Configuration + +- `MONGO_URI`: MongoDB connection URI (default: "mongodb://localhost:27017") +- `DATABASE_NAME`: Name of the MongoDB database (default: "tf-kyc-db") + +### Server Configuration + +- `PORT`: Port on which the server will run (default: "8080") + +### iDenfy Configuration + +- `IDENFY_API_KEY`: API key for iDenfy service (required) (note: make sure to use correct iDenfy API key for the environment dev, test, and production) (iDenfy dev -> TFChain Devnet, iDenfy test -> TFChain QAnet, iDenfy prod -> TFChain Testnet and Mainnet) +- `IDENFY_API_SECRET`: API secret for iDenfy service (required) +- `IDENFY_BASE_URL`: Base URL for iDenfy API (default: "") +- `IDENFY_CALLBACK_SIGN_KEY`: Callback signing key for iDenfy webhooks (required) (note: should match the signing key in iDenfy dashboard for the related environment and should be at least 32 characters long) +- `IDENFY_WHITELISTED_IPS`: Comma-separated list of whitelisted IPs for iDenfy callbacks +- `IDENFY_DEV_MODE`: Enable development mode for iDenfy integration (default: false) (note: works only in iDenfy dev environment, enabling it in test or production environment will cause iDenfy to reject the requests) +- `IDENFY_CALLBACK_URL`: URL for iDenfy verification update callbacks. (example: `https://{KYC-SERVICE-DOMAIN}/webhooks/idenfy/verification-update`) +- `IDENFY_NAMESPACE`: Namespace for isolating diffrent TF KYC verifier services data in same iDenfy backend (default: "") (note: if you are using the same iDenfy backend for multiple services on same tfchain network, you can set this to the unique identifier of the service to isolate the data. don't touch unless you know what you are doing) + +### TFChain Configuration + +- `TFCHAIN_WS_PROVIDER_URL`: WebSocket provider URL for TFChain (default: "wss://tfchain.grid.tf") + +### Verification Settings + +- `VERIFICATION_SUSPICIOUS_VERIFICATION_OUTCOME`: Outcome for suspicious verifications (default: "APPROVED") +- `VERIFICATION_EXPIRED_DOCUMENT_OUTCOME`: Outcome for expired documents (default: "REJECTED") +- `VERIFICATION_MIN_BALANCE_TO_VERIFY_ACCOUNT`: Minimum balance in unitTFT required to verify an account (default: 10000000) +- `VERIFICATION_ALWAYS_VERIFIED_IDS`: Comma-separated list of TFChain SS58Addresses that are always verified (default: "") + +### Rate Limiting + +#### IP-based Rate Limiting + +- `IP_LIMITER_MAX_TOKEN_REQUESTS`: Maximum number of token requests per IP (default: 4) +- `IP_LIMITER_TOKEN_EXPIRATION`: Token expiration time in minutes (default: 1440) + +#### ID-based Rate Limiting + +- `ID_LIMITER_MAX_TOKEN_REQUESTS`: Maximum number of token requests per ID (default: 4) +- `ID_LIMITER_TOKEN_EXPIRATION`: Token expiration time in minutes (default: 1440) + +### Challenge Configuration + +- `CHALLENGE_WINDOW`: Time window in seconds for challenge validation (default: 8) +- `CHALLENGE_DOMAIN`: Current service domain name for challenge validation (required) (example: `tfkyc.dev.grid.tf`) + +### Logging + +- `DEBUG`: Enable debug logging (default: false) + +To configure these options, you can either set them as environment variables or include them in your `.env` file. + +Regarding the iDenfy signing key, it's best to use key composed of alphanumeric characters to avoid such issues. +You can generate a random key using the following command: + +```bash +cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1 +``` + +Refer to `internal/configs/config.go` for the implementation details of these configuration options. + +## Running the Application + +### Using Docker Compose + +First make sure to create and set the environment variables in the `.app.env`, `.db.env` files. +Examples can be found in `.app.env.example`, `.db.env.example`. +In beta releases, we include the mongo-express container, but you can opt to disable it. + +To start only the core services (API and MongoDB) using Docker Compose: + +```bash +docker compose up -d +``` + +To include mongo-express for development, make sure to create and set the environment variables in the `.express.env` file as well, then run: + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d +``` + +To start only mongo-express if core services are already running, run: + +```bash +docker compose -f docker-compose.dev.yml up -d mongo-express +``` + +### Running Locally + +To run the application locally: + +1. Ensure MongoDB is running and accessible. +2. export the environment variables: + + ```bash + set -a + source .app.env + set +a + ``` + +3. Run the application: + + ```bash + go run cmd/api/main.go + ``` + +## API Endpoints + +### Client Endpoints + +#### Token Management + +- `POST /api/v1/token` + - Get or create a verification token + - Required Headers: + - `X-Client-ID`: TFChain SS58Address (48 chars) + - `X-Challenge`: Hex-encoded message `{api-domain}:{timestamp}` + - `X-Signature`: Hex-encoded sr25519|ed25519 signature (128 chars) + - Responses: + - `200`: Existing token retrieved + - `201`: New token created + - `400`: Bad request + - `401`: Unauthorized + - `402`: Payment required + - `409`: Conflict + +#### Verification + +- `GET /api/v1/data` + - Get verification data for a client + - Required Headers: + - `X-Client-ID`: TFChain SS58Address (48 chars) + - `X-Challenge`: Hex-encoded message `{api-domain}:{timestamp}` + - `X-Signature`: Hex-encoded sr25519|ed25519 signature (128 chars) + - Responses: + - `200`: Success + - `400`: Bad request + - `401`: Unauthorized + - `404`: Not found + +- `GET /api/v1/status` + - Get verification status + - Query Parameters (at least one required): + - `client_id`: TFChain SS58Address (48 chars) + - `twin_id`: Twin ID + - Responses: + - `200`: Success + - `400`: Bad request + - `404`: Not found + +### Webhook Endpoints + +- `POST /webhooks/idenfy/verification-update` + - Process verification update from iDenfy + - Required Headers: + - `Idenfy-Signature`: Verification signature + - Responses: + - `200`: Success + - `400`: Bad request + +- `POST /webhooks/idenfy/id-expiration` + - Process document expiration notification (Not implemented) + - Responses: + - `501`: Not implemented + +### Health Check + +- `GET /api/v1/health` + - Check service health status + - Responses: + - `200`: Returns health status + - `healthy`: All systems operational + - `degraded`: Some systems experiencing issues + +### Miscellaneous + +- `GET /api/v1/version` + - Get application version + - Responses: + - `200`: Returns application version + - `version`: Application version + +- `GET /api/v1/configs` + - Get application configurations + - Responses: + - `200`: Returns application configurations + +### Documentation + +- `GET /docs` + - Swagger documentation interface + - Provides interactive API documentation and testing interface + +Refer to the Swagger documentation at `/docs` endpoint for detailed information about request/response formats and examples. + +## Swagger Documentation + +Swagger documentation is available. To view it, run the application and navigate to the `/docs` endpoint in your browser. + +## Project Structure + +- `cmd/`: Application entrypoints + - `api/`: Main API server +- `internal/`: Internal packages + - `clients/`: External service clients + - `configs/`: Configuration handling + - `errors/`: Custom error types + - `handlers/`: HTTP request handlers + - `logger/`: Logging configuration + - `middlewares/`: HTTP middlewares + - `models/`: Data models + - `repositories/`: Data access layer + - `responses/`: API response structures + - `server/`: Server setup and routing + - `services/`: Business logic +- `api/`: API documentation + - `docs/`: Swagger documentation files +- `.github/`: GitHub specific files + - `workflows/`: GitHub Actions workflows +- `scripts/`: Utility and Development scripts +- `docs/`: Documentation + +- Configuration files: + - `.app.env.example`: Example application environment variables + - `.db.env.example`: Example database environment variables + - `Dockerfile`: Container build instructions + - `docker-compose.yml`: Multi-container Docker setup + - `go.mod`: Go module definition + - `go.sum`: Go module checksums + +## Development + +### Running Tests + +To run the test suite: + +TODO: Add tests + +### Building the Docker Image + +To build the Docker image: + +```bash +docker build -t tf_kyc_verifier . +``` + +### Running the Docker Container + +To run the Docker container and use .env variables: + +```bash +docker run -d -p 8080:8080 --env-file .app.env tf_kyc_verifier +``` + +### Creating database dump + +Most of the normal tools will work, although their usage might be a little convoluted in some cases to ensure they have access to the mongod server. A simple way to ensure this is to use docker exec and run the tool from the same container, similar to the following: + +```bash +docker exec sh -c 'exec mongodump -d --archive' > /some/path/on/your/host/all-collections.archive +``` + +## Production + +Refer to the [Production Setup](./docs/production.md) documentation for production setup details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the Apache 2.0 License. See the `LICENSE` file for more details. diff --git a/api/docs/docs.go b/api/docs/docs.go new file mode 100644 index 0000000..73194ed --- /dev/null +++ b/api/docs/docs.go @@ -0,0 +1,836 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "threefold.io", + "url": "https://threefold.io", + "email": "info@threefold.io" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/v1/configs": { + "get": { + "description": "Returns the service configs", + "tags": [ + "Misc" + ], + "summary": "Get Service Configs", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.AppConfigsResponse" + } + } + } + } + } + } + }, + "/api/v1/data": { + "get": { + "description": "Returns the verification data for a client", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Verification" + ], + "summary": "Get Verification Data", + "parameters": [ + { + "maxLength": 48, + "minLength": 48, + "type": "string", + "description": "TFChain SS58Address", + "name": "X-Client-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "hex-encoded message ` + "`" + `{api-domain}:{timestamp}` + "`" + `", + "name": "X-Challenge", + "in": "header", + "required": true + }, + { + "maxLength": 128, + "minLength": 128, + "type": "string", + "description": "hex-encoded sr25519|ed25519 signature", + "name": "X-Signature", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.VerificationDataResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v1/health": { + "get": { + "description": "Returns the health status of the service", + "tags": [ + "Health" + ], + "summary": "Health Check", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.HealthResponse" + } + } + } + } + } + } + }, + "/api/v1/status": { + "get": { + "description": "Returns the verification status for a client", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Verification" + ], + "summary": "Get Verification Status", + "parameters": [ + { + "maxLength": 48, + "minLength": 48, + "type": "string", + "description": "TFChain SS58Address", + "name": "client_id", + "in": "query" + }, + { + "minLength": 1, + "type": "string", + "description": "Twin ID", + "name": "twin_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.VerificationStatusResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v1/token": { + "post": { + "description": "Returns a token for a client", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Token" + ], + "summary": "Get or Generate iDenfy Verification Token", + "parameters": [ + { + "maxLength": 48, + "minLength": 48, + "type": "string", + "description": "TFChain SS58Address", + "name": "X-Client-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "hex-encoded message ` + "`" + `{api-domain}:{timestamp}` + "`" + `", + "name": "X-Challenge", + "in": "header", + "required": true + }, + { + "maxLength": 128, + "minLength": 128, + "type": "string", + "description": "hex-encoded sr25519|ed25519 signature", + "name": "X-Signature", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "Existing token retrieved", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.TokenResponse" + } + } + } + }, + "201": { + "description": "New token created", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.TokenResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "402": { + "description": "Payment Required", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v1/version": { + "get": { + "description": "Returns the service version", + "tags": [ + "Misc" + ], + "summary": "Get Service Version", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.AppVersionResponse" + } + } + } + } + } + } + }, + "/webhooks/idenfy/id-expiration": { + "post": { + "description": "Processes the doc expiration notification for a client", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Webhooks" + ], + "summary": "Process Doc Expiration Notification", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/webhooks/idenfy/verification-update": { + "post": { + "description": "Processes the verification update for a client", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Webhooks" + ], + "summary": "Process Verification Update", + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "definitions": { + "config.Challenge": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "window": { + "type": "integer" + } + } + }, + "config.IDLimiter": { + "type": "object", + "properties": { + "maxTokenRequests": { + "type": "integer" + }, + "tokenExpiration": { + "type": "integer" + } + } + }, + "config.IPLimiter": { + "type": "object", + "properties": { + "maxTokenRequests": { + "type": "integer" + }, + "tokenExpiration": { + "type": "integer" + } + } + }, + "config.Idenfy": { + "type": "object", + "properties": { + "apikey": { + "type": "string" + }, + "apisecret": { + "type": "string" + }, + "baseURL": { + "type": "string" + }, + "callbackSignKey": { + "type": "string" + }, + "callbackUrl": { + "type": "string" + }, + "devMode": { + "type": "boolean" + }, + "namespace": { + "type": "string" + }, + "whitelistedIPs": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "config.Log": { + "type": "object", + "properties": { + "debug": { + "type": "boolean" + } + } + }, + "config.MongoDB": { + "type": "object", + "properties": { + "databaseName": { + "type": "string" + }, + "uri": { + "type": "string" + } + } + }, + "config.Server": { + "type": "object", + "properties": { + "port": { + "type": "string" + } + } + }, + "config.TFChain": { + "type": "object", + "properties": { + "wsProviderURL": { + "type": "string" + } + } + }, + "config.Verification": { + "type": "object", + "properties": { + "alwaysVerifiedIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "expiredDocumentOutcome": { + "type": "string" + }, + "minBalanceToVerifyAccount": { + "type": "integer" + }, + "suspiciousVerificationOutcome": { + "type": "string" + } + } + }, + "responses.AppConfigsResponse": { + "type": "object", + "properties": { + "challenge": { + "$ref": "#/definitions/config.Challenge" + }, + "idenfy": { + "$ref": "#/definitions/config.Idenfy" + }, + "idlimiter": { + "$ref": "#/definitions/config.IDLimiter" + }, + "iplimiter": { + "$ref": "#/definitions/config.IPLimiter" + }, + "log": { + "$ref": "#/definitions/config.Log" + }, + "mongoDB": { + "$ref": "#/definitions/config.MongoDB" + }, + "server": { + "$ref": "#/definitions/config.Server" + }, + "tfchain": { + "$ref": "#/definitions/config.TFChain" + }, + "verification": { + "$ref": "#/definitions/config.Verification" + } + } + }, + "responses.AppVersionResponse": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + } + }, + "responses.HealthResponse": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "$ref": "#/definitions/responses.HealthStatus" + }, + "timestamp": { + "type": "string" + } + } + }, + "responses.HealthStatus": { + "type": "string", + "enum": [ + "Healthy", + "Degraded" + ], + "x-enum-varnames": [ + "HealthStatusHealthy", + "HealthStatusDegraded" + ] + }, + "responses.Outcome": { + "type": "string", + "enum": [ + "VERIFIED", + "REJECTED" + ], + "x-enum-varnames": [ + "OutcomeVerified", + "OutcomeRejected" + ] + }, + "responses.TokenResponse": { + "type": "object", + "properties": { + "authToken": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "digitString": { + "type": "string" + }, + "expiryTime": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "scanRef": { + "type": "string" + }, + "sessionLength": { + "type": "integer" + }, + "tokenType": { + "type": "string" + } + } + }, + "responses.VerificationDataResponse": { + "type": "object", + "properties": { + "additionalData": {}, + "address": { + "type": "string" + }, + "addressVerification": {}, + "ageEstimate": { + "type": "string" + }, + "authority": { + "type": "string" + }, + "birthPlace": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientIpProxyRiskLevel": { + "type": "string" + }, + "docBirthName": { + "type": "string" + }, + "docDateOfIssue": { + "type": "string" + }, + "docDob": { + "type": "string" + }, + "docExpiry": { + "type": "string" + }, + "docFirstName": { + "type": "string" + }, + "docIssuingCountry": { + "type": "string" + }, + "docLastName": { + "type": "string" + }, + "docNationality": { + "type": "string" + }, + "docNumber": { + "type": "string" + }, + "docPersonalCode": { + "type": "string" + }, + "docSex": { + "type": "string" + }, + "docTemporaryAddress": { + "type": "string" + }, + "docType": { + "type": "string" + }, + "driverLicenseCategory": { + "type": "string" + }, + "duplicateDocFaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "duplicateFaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "fullName": { + "type": "string" + }, + "idenfyRef": { + "type": "string" + }, + "manuallyDataChanged": { + "type": "boolean" + }, + "mothersMaidenName": { + "type": "string" + }, + "orgAddress": { + "type": "string" + }, + "orgAuthority": { + "type": "string" + }, + "orgBirthName": { + "type": "string" + }, + "orgBirthPlace": { + "type": "string" + }, + "orgFirstName": { + "type": "string" + }, + "orgLastName": { + "type": "string" + }, + "orgMothersMaidenName": { + "type": "string" + }, + "orgNationality": { + "type": "string" + }, + "orgTemporaryAddress": { + "type": "string" + }, + "selectedCountry": { + "type": "string" + } + } + }, + "responses.VerificationStatusResponse": { + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "final": { + "type": "boolean" + }, + "idenfyRef": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/responses.Outcome" + } + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "0.2.0", + Host: "", + BasePath: "/", + Schemes: []string{}, + Title: "TFGrid KYC API", + Description: "This is a KYC service for TFGrid.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/api/docs/swagger.json b/api/docs/swagger.json new file mode 100644 index 0000000..b37ce8d --- /dev/null +++ b/api/docs/swagger.json @@ -0,0 +1,811 @@ +{ + "swagger": "2.0", + "info": { + "description": "This is a KYC service for TFGrid.", + "title": "TFGrid KYC API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "threefold.io", + "url": "https://threefold.io", + "email": "info@threefold.io" + }, + "version": "0.2.0" + }, + "basePath": "/", + "paths": { + "/api/v1/configs": { + "get": { + "description": "Returns the service configs", + "tags": [ + "Misc" + ], + "summary": "Get Service Configs", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.AppConfigsResponse" + } + } + } + } + } + } + }, + "/api/v1/data": { + "get": { + "description": "Returns the verification data for a client", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Verification" + ], + "summary": "Get Verification Data", + "parameters": [ + { + "maxLength": 48, + "minLength": 48, + "type": "string", + "description": "TFChain SS58Address", + "name": "X-Client-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "hex-encoded message `{api-domain}:{timestamp}`", + "name": "X-Challenge", + "in": "header", + "required": true + }, + { + "maxLength": 128, + "minLength": 128, + "type": "string", + "description": "hex-encoded sr25519|ed25519 signature", + "name": "X-Signature", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.VerificationDataResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v1/health": { + "get": { + "description": "Returns the health status of the service", + "tags": [ + "Health" + ], + "summary": "Health Check", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.HealthResponse" + } + } + } + } + } + } + }, + "/api/v1/status": { + "get": { + "description": "Returns the verification status for a client", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Verification" + ], + "summary": "Get Verification Status", + "parameters": [ + { + "maxLength": 48, + "minLength": 48, + "type": "string", + "description": "TFChain SS58Address", + "name": "client_id", + "in": "query" + }, + { + "minLength": 1, + "type": "string", + "description": "Twin ID", + "name": "twin_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.VerificationStatusResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v1/token": { + "post": { + "description": "Returns a token for a client", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Token" + ], + "summary": "Get or Generate iDenfy Verification Token", + "parameters": [ + { + "maxLength": 48, + "minLength": 48, + "type": "string", + "description": "TFChain SS58Address", + "name": "X-Client-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "hex-encoded message `{api-domain}:{timestamp}`", + "name": "X-Challenge", + "in": "header", + "required": true + }, + { + "maxLength": 128, + "minLength": 128, + "type": "string", + "description": "hex-encoded sr25519|ed25519 signature", + "name": "X-Signature", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "Existing token retrieved", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.TokenResponse" + } + } + } + }, + "201": { + "description": "New token created", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.TokenResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "402": { + "description": "Payment Required", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v1/version": { + "get": { + "description": "Returns the service version", + "tags": [ + "Misc" + ], + "summary": "Get Service Version", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/responses.AppVersionResponse" + } + } + } + } + } + } + }, + "/webhooks/idenfy/id-expiration": { + "post": { + "description": "Processes the doc expiration notification for a client", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Webhooks" + ], + "summary": "Process Doc Expiration Notification", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/webhooks/idenfy/verification-update": { + "post": { + "description": "Processes the verification update for a client", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Webhooks" + ], + "summary": "Process Verification Update", + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "definitions": { + "config.Challenge": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "window": { + "type": "integer" + } + } + }, + "config.IDLimiter": { + "type": "object", + "properties": { + "maxTokenRequests": { + "type": "integer" + }, + "tokenExpiration": { + "type": "integer" + } + } + }, + "config.IPLimiter": { + "type": "object", + "properties": { + "maxTokenRequests": { + "type": "integer" + }, + "tokenExpiration": { + "type": "integer" + } + } + }, + "config.Idenfy": { + "type": "object", + "properties": { + "apikey": { + "type": "string" + }, + "apisecret": { + "type": "string" + }, + "baseURL": { + "type": "string" + }, + "callbackSignKey": { + "type": "string" + }, + "callbackUrl": { + "type": "string" + }, + "devMode": { + "type": "boolean" + }, + "namespace": { + "type": "string" + }, + "whitelistedIPs": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "config.Log": { + "type": "object", + "properties": { + "debug": { + "type": "boolean" + } + } + }, + "config.MongoDB": { + "type": "object", + "properties": { + "databaseName": { + "type": "string" + }, + "uri": { + "type": "string" + } + } + }, + "config.Server": { + "type": "object", + "properties": { + "port": { + "type": "string" + } + } + }, + "config.TFChain": { + "type": "object", + "properties": { + "wsProviderURL": { + "type": "string" + } + } + }, + "config.Verification": { + "type": "object", + "properties": { + "alwaysVerifiedIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "expiredDocumentOutcome": { + "type": "string" + }, + "minBalanceToVerifyAccount": { + "type": "integer" + }, + "suspiciousVerificationOutcome": { + "type": "string" + } + } + }, + "responses.AppConfigsResponse": { + "type": "object", + "properties": { + "challenge": { + "$ref": "#/definitions/config.Challenge" + }, + "idenfy": { + "$ref": "#/definitions/config.Idenfy" + }, + "idlimiter": { + "$ref": "#/definitions/config.IDLimiter" + }, + "iplimiter": { + "$ref": "#/definitions/config.IPLimiter" + }, + "log": { + "$ref": "#/definitions/config.Log" + }, + "mongoDB": { + "$ref": "#/definitions/config.MongoDB" + }, + "server": { + "$ref": "#/definitions/config.Server" + }, + "tfchain": { + "$ref": "#/definitions/config.TFChain" + }, + "verification": { + "$ref": "#/definitions/config.Verification" + } + } + }, + "responses.AppVersionResponse": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + } + }, + "responses.HealthResponse": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "$ref": "#/definitions/responses.HealthStatus" + }, + "timestamp": { + "type": "string" + } + } + }, + "responses.HealthStatus": { + "type": "string", + "enum": [ + "Healthy", + "Degraded" + ], + "x-enum-varnames": [ + "HealthStatusHealthy", + "HealthStatusDegraded" + ] + }, + "responses.Outcome": { + "type": "string", + "enum": [ + "VERIFIED", + "REJECTED" + ], + "x-enum-varnames": [ + "OutcomeVerified", + "OutcomeRejected" + ] + }, + "responses.TokenResponse": { + "type": "object", + "properties": { + "authToken": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "digitString": { + "type": "string" + }, + "expiryTime": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "scanRef": { + "type": "string" + }, + "sessionLength": { + "type": "integer" + }, + "tokenType": { + "type": "string" + } + } + }, + "responses.VerificationDataResponse": { + "type": "object", + "properties": { + "additionalData": {}, + "address": { + "type": "string" + }, + "addressVerification": {}, + "ageEstimate": { + "type": "string" + }, + "authority": { + "type": "string" + }, + "birthPlace": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientIpProxyRiskLevel": { + "type": "string" + }, + "docBirthName": { + "type": "string" + }, + "docDateOfIssue": { + "type": "string" + }, + "docDob": { + "type": "string" + }, + "docExpiry": { + "type": "string" + }, + "docFirstName": { + "type": "string" + }, + "docIssuingCountry": { + "type": "string" + }, + "docLastName": { + "type": "string" + }, + "docNationality": { + "type": "string" + }, + "docNumber": { + "type": "string" + }, + "docPersonalCode": { + "type": "string" + }, + "docSex": { + "type": "string" + }, + "docTemporaryAddress": { + "type": "string" + }, + "docType": { + "type": "string" + }, + "driverLicenseCategory": { + "type": "string" + }, + "duplicateDocFaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "duplicateFaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "fullName": { + "type": "string" + }, + "idenfyRef": { + "type": "string" + }, + "manuallyDataChanged": { + "type": "boolean" + }, + "mothersMaidenName": { + "type": "string" + }, + "orgAddress": { + "type": "string" + }, + "orgAuthority": { + "type": "string" + }, + "orgBirthName": { + "type": "string" + }, + "orgBirthPlace": { + "type": "string" + }, + "orgFirstName": { + "type": "string" + }, + "orgLastName": { + "type": "string" + }, + "orgMothersMaidenName": { + "type": "string" + }, + "orgNationality": { + "type": "string" + }, + "orgTemporaryAddress": { + "type": "string" + }, + "selectedCountry": { + "type": "string" + } + } + }, + "responses.VerificationStatusResponse": { + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "final": { + "type": "boolean" + }, + "idenfyRef": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/responses.Outcome" + } + } + } + } +} \ No newline at end of file diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml new file mode 100644 index 0000000..465ce7b --- /dev/null +++ b/api/docs/swagger.yaml @@ -0,0 +1,533 @@ +basePath: / +definitions: + config.Challenge: + properties: + domain: + type: string + window: + type: integer + type: object + config.IDLimiter: + properties: + maxTokenRequests: + type: integer + tokenExpiration: + type: integer + type: object + config.IPLimiter: + properties: + maxTokenRequests: + type: integer + tokenExpiration: + type: integer + type: object + config.Idenfy: + properties: + apikey: + type: string + apisecret: + type: string + baseURL: + type: string + callbackSignKey: + type: string + callbackUrl: + type: string + devMode: + type: boolean + namespace: + type: string + whitelistedIPs: + items: + type: string + type: array + type: object + config.Log: + properties: + debug: + type: boolean + type: object + config.MongoDB: + properties: + databaseName: + type: string + uri: + type: string + type: object + config.Server: + properties: + port: + type: string + type: object + config.TFChain: + properties: + wsProviderURL: + type: string + type: object + config.Verification: + properties: + alwaysVerifiedIDs: + items: + type: string + type: array + expiredDocumentOutcome: + type: string + minBalanceToVerifyAccount: + type: integer + suspiciousVerificationOutcome: + type: string + type: object + responses.AppConfigsResponse: + properties: + challenge: + $ref: '#/definitions/config.Challenge' + idenfy: + $ref: '#/definitions/config.Idenfy' + idlimiter: + $ref: '#/definitions/config.IDLimiter' + iplimiter: + $ref: '#/definitions/config.IPLimiter' + log: + $ref: '#/definitions/config.Log' + mongoDB: + $ref: '#/definitions/config.MongoDB' + server: + $ref: '#/definitions/config.Server' + tfchain: + $ref: '#/definitions/config.TFChain' + verification: + $ref: '#/definitions/config.Verification' + type: object + responses.AppVersionResponse: + properties: + version: + type: string + type: object + responses.HealthResponse: + properties: + errors: + items: + type: string + type: array + status: + $ref: '#/definitions/responses.HealthStatus' + timestamp: + type: string + type: object + responses.HealthStatus: + enum: + - Healthy + - Degraded + type: string + x-enum-varnames: + - HealthStatusHealthy + - HealthStatusDegraded + responses.Outcome: + enum: + - VERIFIED + - REJECTED + type: string + x-enum-varnames: + - OutcomeVerified + - OutcomeRejected + responses.TokenResponse: + properties: + authToken: + type: string + clientId: + type: string + digitString: + type: string + expiryTime: + type: integer + message: + type: string + scanRef: + type: string + sessionLength: + type: integer + tokenType: + type: string + type: object + responses.VerificationDataResponse: + properties: + additionalData: {} + address: + type: string + addressVerification: {} + ageEstimate: + type: string + authority: + type: string + birthPlace: + type: string + clientId: + type: string + clientIpProxyRiskLevel: + type: string + docBirthName: + type: string + docDateOfIssue: + type: string + docDob: + type: string + docExpiry: + type: string + docFirstName: + type: string + docIssuingCountry: + type: string + docLastName: + type: string + docNationality: + type: string + docNumber: + type: string + docPersonalCode: + type: string + docSex: + type: string + docTemporaryAddress: + type: string + docType: + type: string + driverLicenseCategory: + type: string + duplicateDocFaces: + items: + type: string + type: array + duplicateFaces: + items: + type: string + type: array + fullName: + type: string + idenfyRef: + type: string + manuallyDataChanged: + type: boolean + mothersMaidenName: + type: string + orgAddress: + type: string + orgAuthority: + type: string + orgBirthName: + type: string + orgBirthPlace: + type: string + orgFirstName: + type: string + orgLastName: + type: string + orgMothersMaidenName: + type: string + orgNationality: + type: string + orgTemporaryAddress: + type: string + selectedCountry: + type: string + type: object + responses.VerificationStatusResponse: + properties: + clientId: + type: string + final: + type: boolean + idenfyRef: + type: string + status: + $ref: '#/definitions/responses.Outcome' + type: object +info: + contact: + email: info@threefold.io + name: threefold.io + url: https://threefold.io + description: This is a KYC service for TFGrid. + termsOfService: http://swagger.io/terms/ + title: TFGrid KYC API + version: 0.2.0 +paths: + /api/v1/configs: + get: + description: Returns the service configs + responses: + "200": + description: OK + schema: + properties: + result: + $ref: '#/definitions/responses.AppConfigsResponse' + type: object + summary: Get Service Configs + tags: + - Misc + /api/v1/data: + get: + consumes: + - application/json + description: Returns the verification data for a client + parameters: + - description: TFChain SS58Address + in: header + maxLength: 48 + minLength: 48 + name: X-Client-ID + required: true + type: string + - description: hex-encoded message `{api-domain}:{timestamp}` + in: header + name: X-Challenge + required: true + type: string + - description: hex-encoded sr25519|ed25519 signature + in: header + maxLength: 128 + minLength: 128 + name: X-Signature + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + result: + $ref: '#/definitions/responses.VerificationDataResponse' + type: object + "400": + description: Bad Request + schema: + properties: + error: + type: string + type: object + "401": + description: Unauthorized + schema: + properties: + error: + type: string + type: object + "404": + description: Not Found + schema: + properties: + error: + type: string + type: object + "500": + description: Internal Server Error + schema: + properties: + error: + type: string + type: object + summary: Get Verification Data + tags: + - Verification + /api/v1/health: + get: + description: Returns the health status of the service + responses: + "200": + description: OK + schema: + properties: + result: + $ref: '#/definitions/responses.HealthResponse' + type: object + summary: Health Check + tags: + - Health + /api/v1/status: + get: + consumes: + - application/json + description: Returns the verification status for a client + parameters: + - description: TFChain SS58Address + in: query + maxLength: 48 + minLength: 48 + name: client_id + type: string + - description: Twin ID + in: query + minLength: 1 + name: twin_id + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + result: + $ref: '#/definitions/responses.VerificationStatusResponse' + type: object + "400": + description: Bad Request + schema: + properties: + error: + type: string + type: object + "404": + description: Not Found + schema: + properties: + error: + type: string + type: object + "500": + description: Internal Server Error + schema: + properties: + error: + type: string + type: object + "503": + description: Service Unavailable + schema: + properties: + error: + type: string + type: object + summary: Get Verification Status + tags: + - Verification + /api/v1/token: + post: + consumes: + - application/json + description: Returns a token for a client + parameters: + - description: TFChain SS58Address + in: header + maxLength: 48 + minLength: 48 + name: X-Client-ID + required: true + type: string + - description: hex-encoded message `{api-domain}:{timestamp}` + in: header + name: X-Challenge + required: true + type: string + - description: hex-encoded sr25519|ed25519 signature + in: header + maxLength: 128 + minLength: 128 + name: X-Signature + required: true + type: string + produces: + - application/json + responses: + "200": + description: Existing token retrieved + schema: + properties: + result: + $ref: '#/definitions/responses.TokenResponse' + type: object + "201": + description: New token created + schema: + properties: + result: + $ref: '#/definitions/responses.TokenResponse' + type: object + "400": + description: Bad Request + schema: + properties: + error: + type: string + type: object + "401": + description: Unauthorized + schema: + properties: + error: + type: string + type: object + "402": + description: Payment Required + schema: + properties: + error: + type: string + type: object + "409": + description: Conflict + schema: + properties: + error: + type: string + type: object + "500": + description: Internal Server Error + schema: + properties: + error: + type: string + type: object + "503": + description: Service Unavailable + schema: + properties: + error: + type: string + type: object + summary: Get or Generate iDenfy Verification Token + tags: + - Token + /api/v1/version: + get: + description: Returns the service version + responses: + "200": + description: OK + schema: + properties: + result: + $ref: '#/definitions/responses.AppVersionResponse' + type: object + summary: Get Service Version + tags: + - Misc + /webhooks/idenfy/id-expiration: + post: + consumes: + - application/json + description: Processes the doc expiration notification for a client + produces: + - application/json + responses: + "200": + description: OK + summary: Process Doc Expiration Notification + tags: + - Webhooks + /webhooks/idenfy/verification-update: + post: + consumes: + - application/json + description: Processes the verification update for a client + produces: + - application/json + responses: + "200": + description: OK + summary: Process Verification Update + tags: + - Webhooks +swagger: "2.0" diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..8a87440 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "log/slog" + "os" + + _ "github.com/threefoldtech/tf-kyc-verifier/api/docs" + "github.com/threefoldtech/tf-kyc-verifier/internal/config" + "github.com/threefoldtech/tf-kyc-verifier/internal/server" +) + +func main() { + config, err := config.LoadConfigFromEnv() + if err != nil { + slog.Error("Failed to load configuration:", "error", err) + os.Exit(1) + } + logLevel := slog.LevelInfo + if config.Log.Debug { + logLevel = slog.LevelDebug + } + logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})) + logger.Debug("Configuration loaded successfully", "config", config.GetPublicConfig()) + + server, err := server.New(config, logger) + if err != nil { + logger.Error("Failed to create server:", "error", err) + os.Exit(1) + } + + logger.Info("Starting server on port", "port", config.Server.Port) + err = server.Run() + if err != nil { + logger.Error("Server exited with error", "error", err) + os.Exit(1) + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..fbc2afc --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,20 @@ +services: + mongo-express: + image: mongo-express:latest + container_name: mongo_express + environment: + - ME_CONFIG_MONGODB_SERVER=db + - ME_CONFIG_MONGODB_PORT=27017 + depends_on: + - db + ports: + - "8888:8081" + env_file: + - .express.env + networks: + - default + +networks: + default: + external: true + name: tf_kyc_network \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e77eb38 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: tf_kyc_api + image: ghcr.io/threefoldtech/tf-kyc-verifier:latest + restart: unless-stopped + ports: + - "8080:8080" + depends_on: + db: + condition: service_healthy + env_file: + - .app.env + healthcheck: + test: ["CMD", "curl", "-f", "-s", "http://localhost:8080/api/v1/health"] + interval: 10s + timeout: 10s + retries: 3 + start_period: 10s + + db: + image: mongo:8 + container_name: tf_kyc_db + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + env_file: + - .db.env + healthcheck: + test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet + interval: 10s + timeout: 10s + retries: 3 + start_period: 10s + restart: unless-stopped + + mongo-express: + image: mongo-express:latest + container_name: mongo_express + environment: + - ME_CONFIG_MONGODB_SERVER=db + - ME_CONFIG_MONGODB_PORT=27017 + depends_on: + - db + ports: + - "8888:8081" + env_file: + - .express.env + +volumes: + mongodb_data: + +networks: + default: + name: tf_kyc_network + driver: bridge \ No newline at end of file diff --git a/docs/production.md b/docs/production.md new file mode 100644 index 0000000..2a00680 --- /dev/null +++ b/docs/production.md @@ -0,0 +1,27 @@ +# Production Setup + +In an ideal production setup, we will need multiple instances of the service running behind a load balancer to distribute traffic evenly. This ensures scalability, prevents any single instance from being overwhelmed, and provides fault tolerance. + +The services should connect to a self-hosted MongoDB database configured for high availability. This involves setting up a replica set to ensure data redundancy and automatic failover, minimizing downtime in case of node failures. + +## Key Components + +### Load Balancer + +- Distributes incoming requests across multiple service instances to optimize performance and prevent overload. +- Provides fault tolerance by rerouting traffic if any instance goes offline. +- Example: Nginx, HAProxy, or Traefik. + +### High-Availability MongoDB Setup (Self-Hosted) + +- Replica Set: + - Consists of one primary node (for reads/writes) and one or more secondary nodes (for replication). + - If the primary node fails, a secondary is automatically promoted to primary. +- Arbiter Node: (Optional) + - Participates in elections without storing data, ensuring smooth primary promotion in case of failure. +- Backup Strategy: + - Regular backups to avoid data loss. Backups should be stored encrypted in a remote location to ensure data security and durability. +- Example Setup: 1 primary, 2 secondary nodes, and 1 optional arbiter for quorum. +- Hosted on virtual machines or bare metal servers to ensure full control over the infrastructure. + +This architecture ensures that the system remains operational even during node failures, while the load balancer helps manage traffic efficiently. Regular backups and monitoring of the MongoDB cluster will further enhance reliability and minimize the risk of data loss. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..308aab7 --- /dev/null +++ b/go.mod @@ -0,0 +1,82 @@ +module github.com/threefoldtech/tf-kyc-verifier + +go 1.22 + +require ( + github.com/gofiber/fiber/v2 v2.52.5 + github.com/gofiber/storage/mongodb v1.3.9 + github.com/gofiber/swagger v1.1.0 + github.com/ilyakaznacheev/cleanenv v1.5.0 + github.com/stretchr/testify v1.9.0 + github.com/swaggo/swag v1.16.3 + github.com/threefoldtech/tfchain/clients/tfchain-client-go v0.0.0-20241007205731-5e76664a3cc4 + github.com/valyala/fasthttp v1.51.0 + github.com/vedhavyas/go-subkey/v2 v2.0.0 + go.mongodb.org/mongo-driver v1.17.1 +) + +require ( + github.com/BurntSushi/toml v1.2.1 // indirect + github.com/ChainSafe/go-schnorrkel v1.1.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/cenkalti/backoff v2.2.1+incompatible // indirect + github.com/centrifuge/go-substrate-rpc-client/v4 v4.0.12 // indirect + github.com/cosmos/go-bip39 v1.0.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/deckarep/golang-set v1.8.0 // indirect + github.com/decred/base58 v1.0.4 // indirect + github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect + github.com/ethereum/go-ethereum v1.10.20 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-stack/stack v1.8.1 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/gtank/merlin v0.1.1 // indirect + github.com/gtank/ristretto255 v0.1.2 // indirect + github.com/jbenet/go-base58 v0.0.0-20150317085156-6237cf65f3a6 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.17.2 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/philhofer/fwd v1.1.2 // indirect + github.com/pierrec/xxHash v0.1.5 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect + github.com/rs/cors v1.8.2 // indirect + github.com/rs/zerolog v1.33.0 // indirect + github.com/swaggo/files/v2 v2.0.0 // indirect + github.com/tinylib/msgp v1.1.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + github.com/vedhavyas/go-subkey v1.0.3 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.23.0 // indirect + golang.org/x/text v0.17.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect +) + +replace github.com/threefoldtech/tf-kyc-verifier => ./ diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d831879 --- /dev/null +++ b/go.sum @@ -0,0 +1,241 @@ +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/ChainSafe/go-schnorrkel v1.1.0 h1:rZ6EU+CZFCjB4sHUE1jIu8VDoB/wRKZxoe1tkcO71Wk= +github.com/ChainSafe/go-schnorrkel v1.1.0/go.mod h1:ABkENxiP+cvjFiByMIZ9LYbRoNNLeBLiakC1XeTFxfE= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/btcsuite/btcd v0.22.0-beta h1:LTDpDKUM5EeOFBPM8IXpinEcmZ6FWfNZbE3lfrfdnWo= +github.com/btcsuite/btcd/btcec/v2 v2.2.0 h1:fzn1qaOt32TuLjFlkzYSsBC35Q3KUjT1SwPxiMSCF5k= +github.com/btcsuite/btcd/btcec/v2 v2.2.0/go.mod h1:U7MHm051Al6XmscBQ0BoNydpOTsFAn707034b5nY8zU= +github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce h1:YtWJF7RHm2pYCvA5t0RPmAaLUhREsKuKd+SLhxFbFeQ= +github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce/go.mod h1:0DVlHczLPewLcPGEIeUEzfOJhqGPQ0mJJRDBtD307+o= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/centrifuge/go-substrate-rpc-client/v4 v4.0.12 h1:DCYWIBOalB0mKKfUg2HhtGgIkBbMA1fnlnkZp7fHB18= +github.com/centrifuge/go-substrate-rpc-client/v4 v4.0.12/go.mod h1:5g1oM4Zu3BOaLpsKQ+O8PAv2kNuq+kPcA1VzFbsSqxE= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cosmos/go-bip39 v1.0.0 h1:pcomnQdrdH22njcAatO0yWojsUnCO3y2tNoV1cb6hHY= +github.com/cosmos/go-bip39 v1.0.0/go.mod h1:RNJv0H/pOIVgxw6KS7QeX2a0Uo0aKUlfhZ4xuwvCdJw= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4= +github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo= +github.com/decred/base58 v1.0.4 h1:QJC6B0E0rXOPA8U/kw2rP+qiRJsUaE2Er+pYb3siUeA= +github.com/decred/base58 v1.0.4/go.mod h1:jJswKPEdvpFpvf7dsDvFZyLT22xZ9lWqEByX38oGd9E= +github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/ethereum/go-ethereum v1.10.20 h1:75IW830ClSS40yrQC1ZCMZCt5I+zU16oqId2SiQwdQ4= +github.com/ethereum/go-ethereum v1.10.20/go.mod h1:LWUN82TCHGpxB3En5HVmLLzPD7YSrEUFmFfN1nKkVN0= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= +github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= +github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/gofiber/storage/mongodb v1.3.9 h1:uoFHBuGLWjlNsYFsMZkGxfzpryIq64mfGAUox8WRLkc= +github.com/gofiber/storage/mongodb v1.3.9/go.mod h1:HMl4mg6iUWovowiY8SexUnsyeJwZtihB1rlNjLlpIKA= +github.com/gofiber/swagger v1.1.0 h1:ff3rg1fB+Rp5JN/N8jfxTiZtMKe/9tB9QDc79fPiJKQ= +github.com/gofiber/swagger v1.1.0/go.mod h1:pRZL0Np35sd+lTODTE5The0G+TMHfNY+oC4hM2/i5m8= +github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= +github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.1.1-0.20200604201612-c04b05f3adfa h1:Q75Upo5UN4JbPFURXZ8nLKYUvF85dyFRop/vQ0Rv+64= +github.com/google/gofuzz v1.1.1-0.20200604201612-c04b05f3adfa/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gtank/merlin v0.1.1 h1:eQ90iG7K9pOhtereWsmyRJ6RAwcP4tHTDBHXNg+u5is= +github.com/gtank/merlin v0.1.1/go.mod h1:T86dnYJhcGOh5BjZFCJWTDeTK7XW8uE+E21Cy/bIQ+s= +github.com/gtank/ristretto255 v0.1.2 h1:JEqUCPA1NvLq5DwYtuzigd7ss8fwbYay9fi4/5uMzcc= +github.com/gtank/ristretto255 v0.1.2/go.mod h1:Ph5OpO6c7xKUGROZfWVLiJf9icMDwUeIvY4OmlYW69o= +github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= +github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= +github.com/jbenet/go-base58 v0.0.0-20150317085156-6237cf65f3a6 h1:4zOlv2my+vf98jT1nQt4bT/yKWUImevYPJ2H344CloE= +github.com/jbenet/go-base58 v0.0.0-20150317085156-6237cf65f3a6/go.mod h1:r/8JmuR0qjuCiEhAolkfvdZgmPiHTnJaG0UXCSeR1Zo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= +github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mimoo/StrobeGo v0.0.0-20181016162300-f8f6d4d2b643/go.mod h1:43+3pMjjKimDBf5Kr4ZFNGbLql1zKkbImw+fZbw3geM= +github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b h1:QrHweqAtyJ9EwCaGHBu1fghwxIPiopAHV06JlXrMHjk= +github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b/go.mod h1:xxLb2ip6sSUts3g1irPVHyk/DGslwQsNOo9I7smJfNU= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= +github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= +github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo= +github.com/pierrec/xxHash v0.1.5/go.mod h1:w2waW5Zoa/Wc4Yqe0wgrIYAGKqRMf7czn2HNKXmuL+I= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rs/cors v1.8.2 h1:KCooALfAYGs415Cwu5ABvv9n9509fSiG5SQJn/AQo4U= +github.com/rs/cors v1.8.2/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= +github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= +github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= +github.com/threefoldtech/tfchain/clients/tfchain-client-go v0.0.0-20241007205731-5e76664a3cc4 h1:XIXVdFrum50Wnxv62sS+cEgqHtvdInWB2Co8AJVJ8xs= +github.com/threefoldtech/tfchain/clients/tfchain-client-go v0.0.0-20241007205731-5e76664a3cc4/go.mod h1:cOL5YgHUmDG5SAXrsZxFjUECRQQuAqOoqvXhZG5sEUw= +github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= +github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= +github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo= +github.com/tklauser/go-sysconf v0.3.9/go.mod h1:11DU/5sG7UexIrp/O6g35hrWzu0JxlwQ3LSFUzyeuhs= +github.com/tklauser/numcpus v0.2.2 h1:oyhllyrScuYI6g+h/zUvNXNp1wy7x8qQy3t/piefldA= +github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZFu0T9wgjM= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/vedhavyas/go-subkey v1.0.3 h1:iKR33BB/akKmcR2PMlXPBeeODjWLM90EL98OrOGs8CA= +github.com/vedhavyas/go-subkey v1.0.3/go.mod h1:CloUaFQSSTdWnINfBRFjVMkWXZANW+nd8+TI5jYcl6Y= +github.com/vedhavyas/go-subkey/v2 v2.0.0 h1:LemDIsrVtRSOkp0FA8HxP6ynfKjeOj3BY2U9UNfeDMA= +github.com/vedhavyas/go-subkey/v2 v2.0.0/go.mod h1:95aZ+XDCWAUUynjlmi7BtPExjXgXxByE0WfBwbmIRH4= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.17.1 h1:Wic5cJIwJgSpBhe3lx3+/RybR5PiYRMpVFgO7cOHyIM= +go.mongodb.org/mongo-driver v1.17.1/go.mod h1:wwWm/+BuOddhcq3n68LKRmgk2wXzmF6s0SFOa0GINL4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= diff --git a/internal/build/build.go b/internal/build/build.go new file mode 100644 index 0000000..65a4b98 --- /dev/null +++ b/internal/build/build.go @@ -0,0 +1,8 @@ +/* +Package build contains the build information for the application. +This package is responsible for providing the build information to the application. +This information is injected at build time using ldflags. +*/ +package build + +var Version string = "unknown" diff --git a/internal/clients/idenfy/idenfy.go b/internal/clients/idenfy/idenfy.go new file mode 100644 index 0000000..91d26e3 --- /dev/null +++ b/internal/clients/idenfy/idenfy.go @@ -0,0 +1,121 @@ +/* +Package idenfy contains the iDenfy client for the application. +This layer is responsible for interacting with the iDenfy API. the main operations are: +- creating a verification session +- verifying the callback signature +*/ +package idenfy + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/threefoldtech/tf-kyc-verifier/internal/models" + "github.com/valyala/fasthttp" +) + +type Idenfy struct { + client *fasthttp.Client // TODO: Interface + config IdenfyConfig // TODO: Interface + logger *slog.Logger +} + +const ( + VerificationSessionEndpoint = "/api/v2/token" +) + +func New(config IdenfyConfig, logger *slog.Logger) *Idenfy { + return &Idenfy{ + client: &fasthttp.Client{}, + config: config, + logger: logger, + } +} + +func (c *Idenfy) CreateVerificationSession(ctx context.Context, clientID string) (models.Token, error) { // TODO: Refactor + url := c.config.GetBaseURL() + VerificationSessionEndpoint + + req := fasthttp.AcquireRequest() + defer fasthttp.ReleaseRequest(req) + + req.SetRequestURI(url) + req.Header.SetMethod(fasthttp.MethodPost) + req.Header.Set("Content-Type", "application/json") + + // Set basic auth + authStr := c.config.GetAPIKey() + ":" + c.config.GetAPISecret() + auth := base64.StdEncoding.EncodeToString([]byte(authStr)) + req.Header.Set("Authorization", "Basic "+auth) + + RequestBody := c.createVerificationSessionRequestBody(clientID, c.config.GetDevMode()) + + jsonBody, err := json.Marshal(RequestBody) + if err != nil { + return models.Token{}, fmt.Errorf("marshaling request body: %w", err) + } + req.SetBody(jsonBody) + // Set deadline from context + deadline, ok := ctx.Deadline() + if ok { + req.SetTimeout(time.Until(deadline)) + } + + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + c.logger.Debug("Preparing iDenfy verification session request", "request", jsonBody) + err = c.client.Do(req, resp) + if err != nil { + return models.Token{}, fmt.Errorf("sending token request to iDenfy: %w", err) + } + + if resp.StatusCode() < 200 || resp.StatusCode() >= 300 { + c.logger.Debug("Received unexpected status code from iDenfy", "status", resp.StatusCode(), "error", string(resp.Body())) + return models.Token{}, fmt.Errorf("unexpected status code from iDenfy: %d", resp.StatusCode()) + } + c.logger.Debug("Received response from iDenfy", "response", string(resp.Body())) + + var result models.Token + if err := json.Unmarshal(resp.Body(), &result); err != nil { + return models.Token{}, fmt.Errorf("decoding token response from iDenfy: %w", err) + } + + return result, nil +} + +// verify signature of the callback +func (c *Idenfy) VerifyCallbackSignature(ctx context.Context, body []byte, sigHeader string) error { + sig, err := hex.DecodeString(sigHeader) + if err != nil { + return err + } + mac := hmac.New(sha256.New, []byte(c.config.GetCallbackSignKey())) + + mac.Write(body) + + if !hmac.Equal(sig, mac.Sum(nil)) { + return errors.New("signature verification failed") + } + return nil +} + +// function to create a request body for the verification session +func (c *Idenfy) createVerificationSessionRequestBody(clientID string, devMode bool) map[string]interface{} { + RequestBody := map[string]interface{}{ + "clientId": clientID, + "generateDigitString": true, + "callbackUrl": c.config.GetCallbackUrl(), + } + if devMode { + RequestBody["expiryTime"] = 30 + RequestBody["dummyStatus"] = "APPROVED" + } + return RequestBody +} diff --git a/internal/clients/idenfy/idenfy_test.go b/internal/clients/idenfy/idenfy_test.go new file mode 100644 index 0000000..9d642b0 --- /dev/null +++ b/internal/clients/idenfy/idenfy_test.go @@ -0,0 +1,108 @@ +package idenfy + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/threefoldtech/tf-kyc-verifier/internal/config" + "github.com/threefoldtech/tf-kyc-verifier/internal/models" +) + +func TestClient_DecodeReaderIdentityCallback(t *testing.T) { + expectedSig := "249d9a838e9b981935324b02367ca72552aa430fc766f45f77fab7a81f9f3b9d" + logger := slog.Default() + client := New(&config.Idenfy{ + CallbackSignKey: "TestingKey", + }, logger) + + assert.NotNil(t, client, "Client is nil") + webhook1, err := os.ReadFile("testdata/webhook.1.json") + assert.NoError(t, err, "Could not open test data") + err = client.VerifyCallbackSignature(context.Background(), webhook1, expectedSig) + assert.NoError(t, err) + var resp models.Verification + decoder := json.NewDecoder(bytes.NewReader(webhook1)) + err = decoder.Decode(&resp) + assert.NoError(t, err) + // Basic verification info + logger.Info("resp", "resp", resp) + assert.Equal(t, "123", resp.ClientID) + assert.Equal(t, "scan-ref", resp.IdenfyRef) + assert.Equal(t, "external-ref", resp.ExternalRef) + assert.Equal(t, models.Platform("MOBILE_APP"), resp.Platform) + assert.Equal(t, int64(1554726960), resp.StartTime) + assert.Equal(t, int64(1554727002), resp.FinishTime) + assert.Equal(t, "192.0.2.0", resp.ClientIP) + assert.Equal(t, "LT", resp.ClientIPCountry) + assert.Equal(t, "Kaunas, Lithuania", resp.ClientLocation) + assert.False(t, *resp.Final) + + // Status checks + assert.Equal(t, models.Overall("APPROVED"), *resp.Status.Overall) + assert.Empty(t, resp.Status.SuspicionReasons) + assert.Empty(t, resp.Status.MismatchTags) + assert.Empty(t, resp.Status.FraudTags) + assert.Equal(t, "DOC_VALIDATED", resp.Status.AutoDocument) + assert.Equal(t, "FACE_MATCH", resp.Status.AutoFace) + assert.Equal(t, "DOC_VALIDATED", resp.Status.ManualDocument) + assert.Equal(t, "FACE_MATCH", resp.Status.ManualFace) + assert.Nil(t, resp.Status.AdditionalSteps) + + // Document data + assert.Equal(t, "FIRST-NAME-EXAMPLE", resp.Data.DocFirstName) + assert.Equal(t, "LAST-NAME-EXAMPLE", resp.Data.DocLastName) + assert.Equal(t, "XXXXXXXXX", resp.Data.DocNumber) + assert.Equal(t, "XXXXXXXXX", resp.Data.DocPersonalCode) + assert.Equal(t, "YYYY-MM-DD", resp.Data.DocExpiry) + assert.Equal(t, "YYYY-MM-DD", resp.Data.DocDOB) + assert.Equal(t, "2018-03-02", resp.Data.DocDateOfIssue) + assert.Equal(t, models.DocumentType("ID_CARD"), *resp.Data.DocType) + assert.Equal(t, models.Sex("UNDEFINED"), *resp.Data.DocSex) + assert.Equal(t, "LT", resp.Data.DocNationality) + assert.Equal(t, "LT", resp.Data.DocIssuingCountry) + assert.Equal(t, "BIRTH PLACE", resp.Data.BirthPlace) + assert.Equal(t, "AUTHORITY EXAMPLE", resp.Data.Authority) + assert.Equal(t, "ADDRESS EXAMPLE", resp.Data.Address) + assert.Equal(t, "FULL-NAME-EXAMPLE", resp.Data.FullName) + assert.Equal(t, "LT", resp.Data.SelectedCountry) + assert.False(t, *resp.Data.ManuallyDataChanged) + assert.Equal(t, models.AgeEstimate("OVER_25"), *resp.Data.AgeEstimate) + assert.Equal(t, "LOW", resp.Data.ClientIPProxyRiskLevel) + + // Original data + assert.Equal(t, "FIRST-NAME-EXAMPLE", resp.Data.OrgFirstName) + assert.Equal(t, "LAST-NAME-EXAMPLE", resp.Data.OrgLastName) + assert.Equal(t, "LIETUVOS", resp.Data.OrgNationality) + assert.Equal(t, "Å ILUVA", resp.Data.OrgBirthPlace) + + // File URLs + expectedURLs := map[string]string{ + "FRONT": "https://s3.eu-west-1.amazonaws.com/production.users.storage/users_storage/users//FRONT.png?AWSAccessKeyId=&Signature=&Expires=", + "BACK": "https://s3.eu-west-1.amazonaws.com/production.users.storage/users_storage/users//BACK.png?AWSAccessKeyId=&Signature=&Expires=", + "FACE": "https://s3.eu-west-1.amazonaws.com/production.users.storage/users_storage/users//FACE.png?AWSAccessKeyId=&Signature=&Expires=", + } + assert.Equal(t, expectedURLs, resp.FileUrls) + + // AML and LID checks + assert.Len(t, resp.AML, 1) + assert.Equal(t, "PilotApiAmlV2", resp.AML[0].ServiceName) + assert.Equal(t, "AML", resp.AML[0].ServiceGroupType) + assert.Equal(t, "OHT8GR5ESRF5XROWE5ZGCC123", resp.AML[0].UID) + assert.True(t, *resp.AML[0].Status.CheckSuccessful) + assert.Equal(t, "NOT_SUSPECTED", resp.AML[0].Status.OverallStatus) + + assert.Len(t, resp.LID, 1) + assert.Equal(t, "IrdInvalidPapers", resp.LID[0].ServiceName) + assert.Equal(t, "LID", resp.LID[0].ServiceGroupType) + assert.Equal(t, "OHT8GR5ESRF5XROWE5ZGCC123", resp.LID[0].UID) + assert.True(t, *resp.LID[0].Status.CheckSuccessful) + assert.Equal(t, "NOT_SUSPECTED", resp.LID[0].Status.OverallStatus) + + // Additional data + assert.Empty(t, resp.AdditionalStepPdfUrls) +} diff --git a/internal/clients/idenfy/interface.go b/internal/clients/idenfy/interface.go new file mode 100644 index 0000000..36ef539 --- /dev/null +++ b/internal/clients/idenfy/interface.go @@ -0,0 +1,23 @@ +package idenfy + +import ( + "context" + + "github.com/threefoldtech/tf-kyc-verifier/internal/models" +) + +type IdenfyConfig interface { + GetBaseURL() string + GetCallbackUrl() string + GetNamespace() string + GetDevMode() bool + GetWhitelistedIPs() []string + GetAPIKey() string + GetAPISecret() string + GetCallbackSignKey() string +} + +type IdenfyClient interface { + CreateVerificationSession(ctx context.Context, clientID string) (models.Token, error) + VerifyCallbackSignature(ctx context.Context, body []byte, sigHeader string) error +} diff --git a/internal/clients/idenfy/testdata/webhook.1.json b/internal/clients/idenfy/testdata/webhook.1.json new file mode 100644 index 0000000..dd935f7 --- /dev/null +++ b/internal/clients/idenfy/testdata/webhook.1.json @@ -0,0 +1,109 @@ +{ + "clientId": "123", + "scanRef": "scan-ref", + "externalRef": "external-ref", + "platform": "MOBILE_APP", + "startTime": 1554726960, + "finishTime": 1554727002, + "clientIp": "192.0.2.0", + "clientIpCountry": "LT", + "clientLocation": "Kaunas, Lithuania", + "final": false, + "status": { + "overall": "APPROVED", + "suspicionReasons": [], + "mismatchTags": [], + "fraudTags": [], + "autoDocument": "DOC_VALIDATED", + "autoFace": "FACE_MATCH", + "manualDocument": "DOC_VALIDATED", + "manualFace": "FACE_MATCH", + "additionalSteps": null + }, + "data": { + "docFirstName": "FIRST-NAME-EXAMPLE", + "docLastName": "LAST-NAME-EXAMPLE", + "docNumber": "XXXXXXXXX", + "docPersonalCode": "XXXXXXXXX", + "docExpiry": "YYYY-MM-DD", + "docDob": "YYYY-MM-DD", + "docDateOfIssue": "2018-03-02", + "docType": "ID_CARD", + "docSex": "UNDEFINED", + "docNationality": "LT", + "docIssuingCountry": "LT", + "docTemporaryAddress": null, + "docBirthName": null, + "birthPlace": "BIRTH PLACE", + "authority": "AUTHORITY EXAMPLE", + "address": "ADDRESS EXAMPLE", + "fullName": "FULL-NAME-EXAMPLE", + "selectedCountry": "LT", + "mothersMaidenName": null, + "driverLicenseCategory": null, + "manuallyDataChanged": false, + "orgFirstName": "FIRST-NAME-EXAMPLE", + "orgLastName": "LAST-NAME-EXAMPLE", + "orgNationality": "LIETUVOS", + "orgBirthPlace": "Å ILUVA", + "orgAuthority": null, + "orgAddress": null, + "orgTemporaryAddress": null, + "orgMothersMaidenName": null, + "orgBirthName": null, + "ageEstimate": "OVER_25", + "clientIpProxyRiskLevel": "LOW", + "duplicateDocFaces": null, + "duplicateFaces": null, + "additionalData": { + "UTILITY_BILL": { + "ssn": { + "value": "ssn number", + "status": "MATCH" + } + } + }, + "manualAddress": null, + "manualAddressMatch": false, + "registryCenterCheck": null, + "addressVerification": null + }, + "fileUrls": { + "FRONT": "https://s3.eu-west-1.amazonaws.com/production.users.storage/users_storage/users//FRONT.png?AWSAccessKeyId=&Signature=&Expires=", + "BACK": "https://s3.eu-west-1.amazonaws.com/production.users.storage/users_storage/users//BACK.png?AWSAccessKeyId=&Signature=&Expires=", + "FACE": "https://s3.eu-west-1.amazonaws.com/production.users.storage/users_storage/users//FACE.png?AWSAccessKeyId=&Signature=&Expires=" + }, + "additionalStepPdfUrls": {}, + "AML": [ + { + "status": { + "serviceSuspected": false, + "checkSuccessful": true, + "serviceFound": true, + "serviceUsed": true, + "overallStatus": "NOT_SUSPECTED" + }, + "data": [], + "serviceName": "PilotApiAmlV2", + "serviceGroupType": "AML", + "uid": "OHT8GR5ESRF5XROWE5ZGCC123", + "errorMessage": null + } + ], + "LID": [ + { + "status": { + "serviceSuspected": false, + "checkSuccessful": true, + "serviceFound": true, + "serviceUsed": true, + "overallStatus": "NOT_SUSPECTED" + }, + "data": [], + "serviceName": "IrdInvalidPapers", + "serviceGroupType": "LID", + "uid": "OHT8GR5ESRF5XROWE5ZGCC123", + "errorMessage": null + } + ] + } \ No newline at end of file diff --git a/internal/clients/substrate/substrate.go b/internal/clients/substrate/substrate.go new file mode 100644 index 0000000..868202c --- /dev/null +++ b/internal/clients/substrate/substrate.go @@ -0,0 +1,78 @@ +/* +Package substrate contains the Substrate client for the application. +This layer is responsible for interacting with the Substrate API. It wraps the tfchain go client and provide basic operations. +*/ +package substrate + +import ( + "fmt" + "log/slog" + + tfchain "github.com/threefoldtech/tfchain/clients/tfchain-client-go" +) + +type WsProviderURLGetter interface { + GetWsProviderURL() string +} + +type SubstrateClient interface { + GetChainName() (string, error) + GetAddressByTwinID(twinID uint32) (string, error) + GetAccountBalance(address string) (uint64, error) +} + +type Substrate struct { + api *tfchain.Substrate + logger *slog.Logger +} + +func New(config WsProviderURLGetter, logger *slog.Logger) (*Substrate, error) { + mgr := tfchain.NewManager(config.GetWsProviderURL()) + api, err := mgr.Substrate() + if err != nil { + return nil, fmt.Errorf("initializing Substrate client: %w", err) + } + + return &Substrate{ + api: api, + logger: logger, + }, nil +} + +func (c *Substrate) GetAccountBalance(address string) (uint64, error) { + pubkeyBytes, err := tfchain.FromAddress(address) + if err != nil { + return 0, fmt.Errorf("decoding ss58 address: %w", err) + } + accountID := tfchain.AccountID(pubkeyBytes) + balance, err := c.api.GetBalance(accountID) + if err != nil { + if err.Error() == "account not found" { + return 0, nil + } + return 0, fmt.Errorf("getting account balance: %w", err) + } + + return balance.Free.Uint64(), nil +} + +func (c *Substrate) GetAddressByTwinID(twinID uint32) (string, error) { + twin, err := c.api.GetTwin(twinID) + if err != nil { + return "", fmt.Errorf("getting twin from tfchain: %w", err) + } + return twin.Account.String(), nil +} + +// get chain name from ws provider url +func (c *Substrate) GetChainName() (string, error) { + api, _, err := c.api.GetClient() + if err != nil { + return "", fmt.Errorf("getting substrate inner client: %w", err) + } + chain, err := api.RPC.System.Chain() + if err != nil { + return "", fmt.Errorf("getting chain name: %w", err) + } + return string(chain), nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7e05a7a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,172 @@ +/* +Package config contains the configuration for the application. +This layer is responsible for loading the configuration from the environment variables and validating it. +*/ +package config + +import ( + "errors" + "fmt" + "log/slog" + "net/url" + "slices" + + "github.com/ilyakaznacheev/cleanenv" +) + +type Config struct { + MongoDB MongoDB + Server Server + Idenfy Idenfy + TFChain TFChain + Verification Verification + IPLimiter IPLimiter + IDLimiter IDLimiter + Challenge Challenge + Log Log +} + +type MongoDB struct { + URI string `env:"MONGO_URI" env-default:"mongodb://localhost:27017"` + DatabaseName string `env:"DATABASE_NAME" env-default:"tf-kyc-db"` +} +type Server struct { + Port string `env:"PORT" env-default:"8080"` +} +type Idenfy struct { + APIKey string `env:"IDENFY_API_KEY" env-required:"true"` + APISecret string `env:"IDENFY_API_SECRET" env-required:"true"` + BaseURL string `env:"IDENFY_BASE_URL" env-default:"https://ivs.idenfy.com"` + CallbackSignKey string `env:"IDENFY_CALLBACK_SIGN_KEY" env-required:"true"` + WhitelistedIPs []string `env:"IDENFY_WHITELISTED_IPS" env-separator:","` + DevMode bool `env:"IDENFY_DEV_MODE" env-default:"false"` + CallbackUrl string `env:"IDENFY_CALLBACK_URL" env-required:"false"` + Namespace string `env:"IDENFY_NAMESPACE" env-default:""` +} + +// implement getter for Idenfy +func (c *Idenfy) GetCallbackUrl() string { + return c.CallbackUrl +} +func (c *Idenfy) GetNamespace() string { + return c.Namespace +} +func (c *Idenfy) GetDevMode() bool { + return c.DevMode +} +func (c *Idenfy) GetWhitelistedIPs() []string { + return c.WhitelistedIPs +} +func (c *Idenfy) GetAPIKey() string { + return c.APIKey +} +func (c *Idenfy) GetAPISecret() string { + return c.APISecret +} +func (c *Idenfy) GetBaseURL() string { + return c.BaseURL +} +func (c *Idenfy) GetCallbackSignKey() string { + return c.CallbackSignKey +} + +type TFChain struct { + WsProviderURL string `env:"TFCHAIN_WS_PROVIDER_URL" env-default:"wss://tfchain.grid.tf"` +} + +// implement getter for TFChain +func (c *TFChain) GetWsProviderURL() string { + return c.WsProviderURL +} + +type Verification struct { + SuspiciousVerificationOutcome string `env:"VERIFICATION_SUSPICIOUS_VERIFICATION_OUTCOME" env-default:"APPROVED"` + ExpiredDocumentOutcome string `env:"VERIFICATION_EXPIRED_DOCUMENT_OUTCOME" env-default:"REJECTED"` + MinBalanceToVerifyAccount uint64 `env:"VERIFICATION_MIN_BALANCE_TO_VERIFY_ACCOUNT" env-default:"10000000"` + AlwaysVerifiedIDs []string `env:"VERIFICATION_ALWAYS_VERIFIED_IDS" env-separator:","` +} +type IPLimiter struct { + MaxTokenRequests uint `env:"IP_LIMITER_MAX_TOKEN_REQUESTS" env-default:"4"` + TokenExpiration uint `env:"IP_LIMITER_TOKEN_EXPIRATION" env-default:"1440"` +} +type IDLimiter struct { + MaxTokenRequests uint `env:"ID_LIMITER_MAX_TOKEN_REQUESTS" env-default:"4"` + TokenExpiration uint `env:"ID_LIMITER_TOKEN_EXPIRATION" env-default:"1440"` +} +type Log struct { + Debug bool `env:"DEBUG" env-default:"false"` +} +type Challenge struct { + Window int64 `env:"CHALLENGE_WINDOW" env-default:"8"` + Domain string `env:"CHALLENGE_DOMAIN" env-required:"true"` +} + +func LoadConfigFromEnv() (*Config, error) { + cfg := &Config{} + err := cleanenv.ReadEnv(cfg) + if err != nil { + return nil, fmt.Errorf("loading config: %w", err) + } + // cfg.Validate() + return cfg, nil +} + +func (c Config) GetPublicConfig() Config { + // deducting the secret fields + config := c + config.Idenfy.APIKey = "[REDACTED]" + config.Idenfy.APISecret = "[REDACTED]" + config.Idenfy.CallbackSignKey = "[REDACTED]" + config.MongoDB.URI = "[REDACTED]" + return config +} + +// validate config +func (c *Config) Validate() error { + // iDenfy base URL should be https://ivs.idenfy.com. This is the only supported base URL for now. + if c.Idenfy.BaseURL != "https://ivs.idenfy.com" { + return errors.New("invalid iDenfy base URL. it should be https://ivs.idenfy.com") + } + // CallbackUrl should be valid URL + parsedCallbackUrl, err := url.ParseRequestURI(c.Idenfy.CallbackUrl) + if err != nil { + return errors.New("invalid CallbackUrl") + } + // CallbackSignKey should not be empty + if len(c.Idenfy.CallbackSignKey) < 16 { + return errors.New("invalid callbackSignKey. it should be at least 16 characters long") + } + // WsProviderURL should be valid URL and start with wss:// + if u, err := url.ParseRequestURI(c.TFChain.WsProviderURL); err != nil || u.Scheme != "wss" { + return errors.New("invalid WsProviderURL") + } + // domain should not be empty and same as domain in CallbackUrl + if parsedCallbackUrl.Host != c.Challenge.Domain { + return errors.New("invalid Challenge Domain. It should be same as domain in CallbackUrl") + } + // Window should be greater than 2 + if c.Challenge.Window < 2 { + return errors.New("invalid Challenge Window. It should be greater than 2 otherwise it will be too short and verification can fail in slow networks") + } + // SuspiciousVerificationOutcome should be either APPROVED or REJECTED + if !slices.Contains([]string{"APPROVED", "REJECTED"}, c.Verification.SuspiciousVerificationOutcome) { + return errors.New("invalid SuspiciousVerificationOutcome. should be either APPROVED or REJECTED") + } + // ExpiredDocumentOutcome should be either APPROVED or REJECTED + if !slices.Contains([]string{"APPROVED", "REJECTED"}, c.Verification.ExpiredDocumentOutcome) { + return errors.New("invalid ExpiredDocumentOutcome. should be either APPROVED or REJECTED") + } + // MinBalanceToVerifyAccount + if c.Verification.MinBalanceToVerifyAccount < 20000000 { + slog.Warn("Verification MinBalanceToVerifyAccount is less than 20000000. This is not recommended and can lead to security issues. If you are sure about this, you can ignore this message.") + } + // DevMode + if c.Idenfy.DevMode { + slog.Warn("iDenfy DevMode is enabled. This is not intended for environments other than development. If you are sure about this, you can ignore this message.") + } + // Namespace + if c.Idenfy.Namespace != "" { + slog.Warn("iDenfy Namespace is set. This ideally should be empty. If you are sure about this, you can ignore this message.") + } + return nil +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..d91aded --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,92 @@ +/* +Package errors contains custom error types and constructors for the application. +This layer is responsible for defining the error types and constructors for the application. +*/ +package errors + +import "fmt" + +// ErrorType represents the type of error +type ErrorType string + +const ( + // Error types + ErrorTypeValidation ErrorType = "VALIDATION_ERROR" + ErrorTypeAuthorization ErrorType = "AUTHORIZATION_ERROR" + ErrorTypeNotFound ErrorType = "NOT_FOUND" + ErrorTypeConflict ErrorType = "CONFLICT" + ErrorTypeInternal ErrorType = "INTERNAL_ERROR" + ErrorTypeExternal ErrorType = "EXTERNAL_SERVICE_ERROR" + ErrorTypeNotSufficientBalance ErrorType = "NOT_SUFFICIENT_BALANCE" +) + +// ServiceError represents a service-level error +type ServiceError struct { + Type ErrorType + Msg string + Err error +} + +func (e *ServiceError) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s: %s: %v", e.Type, e.Msg, e.Err) + } + return fmt.Sprintf("%s: %s", e.Type, e.Msg) +} + +// Error constructors +func NewValidationError(msg string, err error) *ServiceError { + return &ServiceError{ + Type: ErrorTypeValidation, + Msg: msg, + Err: err, + } +} + +func NewAuthorizationError(msg string, err error) *ServiceError { + return &ServiceError{ + Type: ErrorTypeAuthorization, + Msg: msg, + Err: err, + } +} + +func NewNotFoundError(msg string, err error) *ServiceError { + return &ServiceError{ + Type: ErrorTypeNotFound, + Msg: msg, + Err: err, + } +} + +func NewConflictError(msg string, err error) *ServiceError { + return &ServiceError{ + Type: ErrorTypeConflict, + Msg: msg, + Err: err, + } +} + +func NewInternalError(msg string, err error) *ServiceError { + return &ServiceError{ + Type: ErrorTypeInternal, + Msg: msg, + Err: err, + } +} + +func NewExternalError(msg string, err error) *ServiceError { + return &ServiceError{ + Type: ErrorTypeExternal, + Msg: msg, + Err: err, + } +} + +func NewNotSufficientBalanceError(msg string, err error) *ServiceError { + return &ServiceError{ + Type: ErrorTypeNotSufficientBalance, + Msg: msg, + Err: err, + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..30aa72d --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,291 @@ +/* +Package handlers contains the handlers for the API. +This layer is responsible for handling the requests and responses, in more details: +- validating the requests +- formatting the responses +- handling the errors +- delegating the requests to the services +*/ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/gofiber/fiber/v2" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/readpref" + + "github.com/threefoldtech/tf-kyc-verifier/internal/build" + "github.com/threefoldtech/tf-kyc-verifier/internal/config" + "github.com/threefoldtech/tf-kyc-verifier/internal/errors" + "github.com/threefoldtech/tf-kyc-verifier/internal/models" + "github.com/threefoldtech/tf-kyc-verifier/internal/responses" + "github.com/threefoldtech/tf-kyc-verifier/internal/services" +) + +type Handler struct { + kycService *services.KYCService + config *config.Config + logger *slog.Logger +} + +// @title TFGrid KYC API +// @version 0.2.0 +// @description This is a KYC service for TFGrid. +// @termsOfService http://swagger.io/terms/ + +// @contact.name threefold.io +// @contact.url https://threefold.io +// @contact.email info@threefold.io +// @BasePath / +func NewHandler(kycService *services.KYCService, config *config.Config, logger *slog.Logger) *Handler { + return &Handler{kycService: kycService, config: config, logger: logger} +} + +// @Summary Get or Generate iDenfy Verification Token +// @Description Returns a token for a client +// @Tags Token +// @Accept json +// @Produce json +// @Param X-Client-ID header string true "TFChain SS58Address" minlength(48) maxlength(48) +// @Param X-Challenge header string true "hex-encoded message `{api-domain}:{timestamp}`" +// @Param X-Signature header string true "hex-encoded sr25519|ed25519 signature" minlength(128) maxlength(128) +// @Success 200 {object} object{result=responses.TokenResponse} "Existing token retrieved" +// @Success 201 {object} object{result=responses.TokenResponse} "New token created" +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 402 {object} object{error=string} +// @Failure 409 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Failure 503 {object} object{error=string} +// @Router /api/v1/token [post] +func (h *Handler) GetOrCreateVerificationToken() fiber.Handler { + return func(c *fiber.Ctx) error { + clientID := c.Get("X-Client-ID") + ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second) + defer cancel() + token, isNewToken, err := h.kycService.GetOrCreateVerificationToken(ctx, clientID) + if err != nil { + return HandleError(c, err) + } + response := responses.NewTokenResponseWithStatus(token, isNewToken) + if isNewToken { + return responses.RespondWithData(c, fiber.StatusCreated, response) + } + return responses.RespondWithData(c, fiber.StatusOK, response) + } +} + +// @Summary Get Verification Data +// @Description Returns the verification data for a client +// @Tags Verification +// @Accept json +// @Produce json +// @Param X-Client-ID header string true "TFChain SS58Address" minlength(48) maxlength(48) +// @Param X-Challenge header string true "hex-encoded message `{api-domain}:{timestamp}`" +// @Param X-Signature header string true "hex-encoded sr25519|ed25519 signature" minlength(128) maxlength(128) +// @Success 200 {object} object{result=responses.VerificationDataResponse} +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Router /api/v1/data [get] +func (h *Handler) GetVerificationData() fiber.Handler { + return func(c *fiber.Ctx) error { + clientID := c.Get("X-Client-ID") + ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second) + defer cancel() + verification, err := h.kycService.GetVerificationData(ctx, clientID) + if err != nil { + return HandleError(c, err) + } + if verification == nil { + return responses.RespondWithError(c, fiber.StatusNotFound, fmt.Errorf("verification not found for client")) + } + response := responses.NewVerificationDataResponse(verification) + return responses.RespondWithData(c, fiber.StatusOK, response) + } +} + +// @Summary Get Verification Status +// @Description Returns the verification status for a client +// @Tags Verification +// @Accept json +// @Produce json +// @Param client_id query string false "TFChain SS58Address" minlength(48) maxlength(48) +// @Param twin_id query string false "Twin ID" minlength(1) +// @Success 200 {object} object{result=responses.VerificationStatusResponse} +// @Failure 400 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Failure 503 {object} object{error=string} +// @Router /api/v1/status [get] +func (h *Handler) GetVerificationStatus() fiber.Handler { + return func(c *fiber.Ctx) error { + clientID := c.Query("client_id") + twinID := c.Query("twin_id") + + if clientID == "" && twinID == "" { + h.logger.Warn("Bad request: missing client_id and twin_id") + return responses.RespondWithError(c, fiber.StatusBadRequest, fmt.Errorf("either client_id or twin_id must be provided")) + } + var verification *models.VerificationOutcome + var err error + ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second) + defer cancel() + if clientID != "" { + verification, err = h.kycService.GetVerificationStatus(ctx, clientID) + } else { + verification, err = h.kycService.GetVerificationStatusByTwinID(ctx, twinID) + } + if err != nil { + h.logger.Error("Failed to get verification status", "clientID", clientID, "twinID", twinID, "error", err) + return HandleError(c, err) + } + if verification == nil { + h.logger.Info("Verification not found", "clientID", clientID, "twinID", twinID) + return responses.RespondWithError(c, fiber.StatusNotFound, fmt.Errorf("verification not found")) + } + response := responses.NewVerificationStatusResponse(verification) + return responses.RespondWithData(c, fiber.StatusOK, response) + } +} + +// @Summary Process Verification Update +// @Description Processes the verification update for a client +// @Tags Webhooks +// @Accept json +// @Produce json +// @Success 200 +// @Router /webhooks/idenfy/verification-update [post] +func (h *Handler) ProcessVerificationResult() fiber.Handler { + return func(c *fiber.Ctx) error { + h.logger.Debug("Received verification update", + "body", string(c.Body()), + "headers", &c.Request().Header, + ) + sigHeader := c.Get("Idenfy-Signature") + if len(sigHeader) < 1 { + return responses.RespondWithError(c, fiber.StatusBadRequest, fmt.Errorf("no signature provided")) + } + body := c.Body() + var result models.Verification + decoder := json.NewDecoder(bytes.NewReader(body)) + err := decoder.Decode(&result) + if err != nil { + h.logger.Error("Error decoding verification update", "error", err) + return responses.RespondWithError(c, fiber.StatusBadRequest, err) + } + h.logger.Debug("Verification update after decoding", "result", result) + ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second) + defer cancel() + err = h.kycService.ProcessVerificationResult(ctx, body, sigHeader, result) + if err != nil { + return HandleError(c, err) + } + return responses.RespondWithData(c, fiber.StatusOK, nil) + } +} + +// @Summary Process Doc Expiration Notification +// @Description Processes the doc expiration notification for a client +// @Tags Webhooks +// @Accept json +// @Produce json +// @Success 200 +// @Router /webhooks/idenfy/id-expiration [post] +func (h *Handler) ProcessDocExpirationNotification() fiber.Handler { + return func(c *fiber.Ctx) error { + // TODO: implement + h.logger.Error("Received ID expiration notification but not implemented") + return c.SendStatus(fiber.StatusNotImplemented) + } +} + +// @Summary Health Check +// @Description Returns the health status of the service +// @Tags Health +// @Success 200 {object} object{result=responses.HealthResponse} +// @Router /api/v1/health [get] +func (h *Handler) HealthCheck(dbClient *mongo.Client) fiber.Handler { + return func(c *fiber.Ctx) error { + ctx, cancel := context.WithTimeout(c.Context(), 5*time.Second) + defer cancel() + err := dbClient.Ping(ctx, readpref.Primary()) + if err != nil { + // status degraded + health := responses.HealthResponse{ + Status: responses.HealthStatusDegraded, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Errors: []string{err.Error()}, + } + return responses.RespondWithData(c, fiber.StatusOK, health) + } + health := responses.HealthResponse{ + Status: responses.HealthStatusHealthy, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Errors: []string{}, + } + + return responses.RespondWithData(c, fiber.StatusOK, health) + } +} + +// @Summary Get Service Configs +// @Description Returns the service configs +// @Tags Misc +// @Success 200 {object} object{result=responses.AppConfigsResponse} +// @Router /api/v1/configs [get] +func (h *Handler) GetServiceConfigs() fiber.Handler { + return func(c *fiber.Ctx) error { + return responses.RespondWithData(c, fiber.StatusOK, h.config.GetPublicConfig()) + } +} + +// @Summary Get Service Version +// @Description Returns the service version +// @Tags Misc +// @Success 200 {object} object{result=responses.AppVersionResponse} +// @Router /api/v1/version [get] +func (h *Handler) GetServiceVersion() fiber.Handler { + return func(c *fiber.Ctx) error { + response := responses.AppVersionResponse{Version: build.Version} + return responses.RespondWithData(c, fiber.StatusOK, response) + } +} + +func HandleError(c *fiber.Ctx, err error) error { + if serviceErr, ok := err.(*errors.ServiceError); ok { + return HandleServiceError(c, serviceErr) + } + return responses.RespondWithError(c, fiber.StatusInternalServerError, err) +} + +func HandleServiceError(c *fiber.Ctx, err *errors.ServiceError) error { + statusCode := getStatusCode(err.Type) + return responses.RespondWithError(c, statusCode, err) +} + +func getStatusCode(errorType errors.ErrorType) int { + switch errorType { + case errors.ErrorTypeValidation: + return fiber.StatusBadRequest + case errors.ErrorTypeAuthorization: + return fiber.StatusUnauthorized + case errors.ErrorTypeNotFound: + return fiber.StatusNotFound + case errors.ErrorTypeConflict: + return fiber.StatusConflict + case errors.ErrorTypeExternal: + return fiber.StatusServiceUnavailable + case errors.ErrorTypeNotSufficientBalance: + return fiber.StatusPaymentRequired + default: + return fiber.StatusInternalServerError + } +} diff --git a/internal/logger/interface.go b/internal/logger/interface.go new file mode 100644 index 0000000..90c66f6 --- /dev/null +++ b/internal/logger/interface.go @@ -0,0 +1 @@ +package logger diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..f70107d --- /dev/null +++ b/internal/middleware/middleware.go @@ -0,0 +1,161 @@ +package middleware + +import ( + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/threefoldtech/tf-kyc-verifier/internal/config" + "github.com/threefoldtech/tf-kyc-verifier/internal/errors" + "github.com/threefoldtech/tf-kyc-verifier/internal/handlers" + "github.com/threefoldtech/tf-kyc-verifier/internal/responses" + "github.com/vedhavyas/go-subkey/v2" + "github.com/vedhavyas/go-subkey/v2/ed25519" + "github.com/vedhavyas/go-subkey/v2/sr25519" +) + +// AuthMiddleware is a middleware that validates the authentication credentials +func AuthMiddleware(config config.Challenge) fiber.Handler { + return func(c *fiber.Ctx) error { + clientID := c.Get("X-Client-ID") + signature := c.Get("X-Signature") + challenge := c.Get("X-Challenge") + + if clientID == "" || signature == "" || challenge == "" { + return responses.RespondWithError(c, fiber.StatusBadRequest, fmt.Errorf("missing authentication credentials")) + } + + // Verify the clientID and signature here + err := ValidateChallenge(clientID, signature, challenge, config.Domain, config.Window) + if err != nil { + // cast error to service error and convert it to http status code + serviceError, ok := err.(*errors.ServiceError) + if ok { + return handlers.HandleServiceError(c, serviceError) + } + return responses.RespondWithError(c, fiber.StatusBadRequest, err) + } + // Verify the signature + err = VerifySubstrateSignature(clientID, signature, challenge) + if err != nil { + serviceError, ok := err.(*errors.ServiceError) + if ok { + return handlers.HandleServiceError(c, serviceError) + } + return responses.RespondWithError(c, fiber.StatusUnauthorized, err) + } + + return c.Next() + } +} + +func fromHex(hex string) ([]byte, bool) { + return subkey.DecodeHex(hex) +} + +func VerifySubstrateSignature(address, signature, challenge string) error { + challengeBytes, ok := fromHex(challenge) + if !ok { + return errors.NewValidationError("malformed challenge: failed to decode hex-encoded challenge", nil) + } + // hex to string + sig, ok := fromHex(signature) + if !ok { + return errors.NewValidationError("malformed signature: failed to decode hex-encoded signature", nil) + } + // Convert address to public key + _, pubkeyBytes, err := subkey.SS58Decode(address) + if err != nil { + return errors.NewValidationError("malformed address:failed to decode ss58 address", err) + } + + // Create a new ed25519 public key + pubkeyEd25519, err := ed25519.Scheme{}.FromPublicKey(pubkeyBytes) + if err != nil { + return errors.NewValidationError("creating ed25519 public key", err) + } + + if !pubkeyEd25519.Verify(challengeBytes, sig) { + // Create a new sr25519 public key + pubkeySr25519, err := sr25519.Scheme{}.FromPublicKey(pubkeyBytes) + if err != nil { + return errors.NewValidationError("creating sr25519 public key", err) + } + if !pubkeySr25519.Verify(challengeBytes, sig) { + return errors.NewAuthorizationError("bad signature: signature does not match", nil) + } + } + + return nil +} + +func ValidateChallenge(address, signature, challenge, expectedDomain string, challengeWindow int64) error { + // Parse and validate the challenge + challengeBytes, ok := fromHex(challenge) + if !ok { + return errors.NewValidationError("malformed challenge: failed to decode hex-encoded challenge", nil) + } + parts := strings.Split(string(challengeBytes), ":") + if len(parts) != 2 { + return errors.NewValidationError("malformed challenge: invalid challenge format", nil) + } + + // Check the domain + if parts[0] != expectedDomain { + return errors.NewValidationError("bad challenge: unexpected domain", nil) + } + + // Check the timestamp + timestamp, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return errors.NewValidationError("bad challenge: invalid timestamp", nil) + } + + // Check if the timestamp is within an acceptable range (e.g., last 1 minutes) + if time.Now().Unix()-timestamp > challengeWindow { + return errors.NewValidationError("bad challenge: challenge expired", nil) + } + return nil +} + +func NewLoggingMiddleware(logger *slog.Logger) fiber.Handler { + return func(c *fiber.Ctx) error { + start := time.Now() + path := c.Path() + method := c.Method() + ip := c.IP() + + // Log request + logger.Info("Incoming request", slog.Any("method", method), slog.Any("path", path), slog.Any("queries", c.Queries()), slog.Any("ip", ip), slog.Any("user_agent", string(c.Request().Header.UserAgent())), slog.Any("headers", c.GetReqHeaders())) + + // Handle request + err := c.Next() + + // Calculate duration + duration := time.Since(start) + status := c.Response().StatusCode() + + // Get response size + responseSize := len(c.Response().Body()) + + // Log the response + logger := logger.With(slog.Any("method", method), slog.Any("path", path), slog.Any("ip", ip), slog.Any("status", status), slog.Any("duration", duration), slog.Any("response_size", responseSize)) + + // Add error if present + if err != nil { + logger = logger.With(slog.Any("error", err)) + if status >= 500 { + logger.Error("Request failed") + } else { + logger.Info("Request failed") + } + } else { + logger.Info("Request completed") + } + + return err + } +} diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go new file mode 100644 index 0000000..2449c46 --- /dev/null +++ b/internal/middleware/middleware_test.go @@ -0,0 +1,256 @@ +package middleware + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/threefoldtech/tf-kyc-verifier/internal/config" + "github.com/vedhavyas/go-subkey/v2" + "github.com/vedhavyas/go-subkey/v2/ed25519" + "github.com/vedhavyas/go-subkey/v2/sr25519" +) + +func TestAuthMiddleware(t *testing.T) { + // Setup + app := fiber.New() + cfg := config.Challenge{ + Window: 8, + Domain: "test.grid.tf", + } + + // Mock handler that should be called after middleware + successHandler := func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + } + + // Apply middleware + app.Use(AuthMiddleware(cfg)) + app.Get("/test", successHandler) + + // Generate keys + krSr25519, err := generateTestSr25519Keys() + if err != nil { + t.Fatal(err) + } + krEd25519, err := generateTestEd25519Keys() + if err != nil { + t.Fatal(err) + } + clientIDSr := krSr25519.SS58Address(42) + clientIDEd := krEd25519.SS58Address(42) + invalidChallenge := createInvalidSignMessageInvalidFormat(cfg.Domain) + expiredChallenge := createInvalidSignMessageExpired(cfg.Domain) + wrongDomainChallenge := createInvalidSignMessageWrongDomain() + validChallenge := createValidSignMessage(cfg.Domain) + sigSr, err := krSr25519.Sign([]byte(validChallenge)) + if err != nil { + t.Fatal(err) + } + sigEd, err := krEd25519.Sign([]byte(validChallenge)) + if err != nil { + t.Fatal(err) + } + sigSrHex := hex.EncodeToString(sigSr) + sigEdHex := hex.EncodeToString(sigEd) + tests := []struct { + name string + clientID string + signature string + challenge string + expectedStatus int + expectedError string + }{ + { + name: "Missing all credentials", + clientID: "", + signature: "", + challenge: "", + expectedStatus: fiber.StatusBadRequest, + expectedError: "missing authentication credentials", + }, + { + name: "Missing client ID", + clientID: "", + signature: sigSrHex, + challenge: toHex(validChallenge), + expectedStatus: fiber.StatusBadRequest, + expectedError: "missing authentication credentials", + }, + { + name: "Missing signature", + clientID: clientIDSr, + signature: "", + challenge: toHex(validChallenge), + expectedStatus: fiber.StatusBadRequest, + expectedError: "missing authentication credentials", + }, + { + name: "Missing challenge", + clientID: clientIDSr, + signature: sigSrHex, + challenge: "", + expectedStatus: fiber.StatusBadRequest, + expectedError: "missing authentication credentials", + }, + { + name: "Invalid client ID format", + clientID: toHex("invalid_client_id"), + signature: sigSrHex, + challenge: toHex(validChallenge), + expectedStatus: fiber.StatusBadRequest, + expectedError: "malformed address", + }, + { + name: "Invalid challenge format", + clientID: clientIDSr, + signature: sigSrHex, + challenge: toHex(invalidChallenge), + expectedStatus: fiber.StatusBadRequest, + expectedError: "invalid challenge format", + }, + { + name: "Expired challenge", + clientID: clientIDSr, + signature: sigSrHex, + challenge: toHex(expiredChallenge), + expectedStatus: fiber.StatusBadRequest, + expectedError: "challenge expired", + }, + { + name: "Invalid domain in challenge", + clientID: clientIDSr, + signature: sigSrHex, + challenge: toHex(wrongDomainChallenge), + expectedStatus: fiber.StatusBadRequest, + expectedError: "unexpected domain", + }, + { + name: "invalid signature format", + clientID: clientIDSr, + signature: "invalid_signature", + challenge: toHex(validChallenge), + expectedStatus: fiber.StatusBadRequest, + expectedError: "malformed signature", + }, + { + name: "bad signature", + clientID: clientIDSr, + signature: sigEdHex, + challenge: toHex(validChallenge), + expectedStatus: fiber.StatusUnauthorized, + expectedError: "signature does not match", + }, + { + name: "valid credentials SR25519", + clientID: clientIDSr, + signature: sigSrHex, + challenge: toHex(validChallenge), + expectedStatus: fiber.StatusOK, + }, + { + name: "valid credentials ED25519", + clientID: clientIDEd, + signature: sigEdHex, + challenge: toHex(validChallenge), + expectedStatus: fiber.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create request + req := createTestRequest(tt.clientID, tt.signature, tt.challenge) + resp, err := app.Test(req) + + // Assert response + assert.NoError(t, err) + assert.Equal(t, tt.expectedStatus, resp.StatusCode) + + // Check error message if expected + if tt.expectedError != "" { + var errorResp struct { + Error string `json:"error"` + } + err = parseResponse(resp, &errorResp) + assert.NoError(t, err) + assert.Contains(t, errorResp.Error, tt.expectedError) + } + }) + } +} + +// Helper function to create test requests +func createTestRequest(clientID, signature, challenge string) *http.Request { + req := httptest.NewRequest(fiber.MethodGet, "/test", nil) + if clientID != "" { + req.Header.Set("X-Client-ID", clientID) + } + if signature != "" { + req.Header.Set("X-Signature", signature) + } + if challenge != "" { + req.Header.Set("X-Challenge", challenge) + } + return req +} + +// Helper function to parse response body +func parseResponse(resp *http.Response, v interface{}) error { + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(body, v) +} + +func toHex(message string) string { + return hex.EncodeToString([]byte(message)) +} + +func createValidSignMessage(domain string) string { + // return a message with the domain and the current timestamp in hex + message := fmt.Sprintf("%s:%d", domain, time.Now().Unix()) + return message +} + +func createInvalidSignMessageWrongDomain() string { + // return a message with the domain and the current timestamp in hex + message := fmt.Sprintf("%s:%d", "wrong.domain", time.Now().Unix()) + return message +} + +func createInvalidSignMessageExpired(domain string) string { + // return a message with the domain and the current timestamp in hex + message := fmt.Sprintf("%s:%d", domain, time.Now().Add(-10*time.Minute).Unix()) + return message +} + +func createInvalidSignMessageInvalidFormat(domain string) string { + // return a message with the domain and the current timestamp in hex + message := fmt.Sprintf("%s%d", domain, time.Now().Unix()) + return message +} + +func generateTestSr25519Keys() (subkey.KeyPair, error) { + krSr25519, err := sr25519.Scheme{}.Generate() + if err != nil { + return nil, err + } + return krSr25519, nil +} + +func generateTestEd25519Keys() (subkey.KeyPair, error) { + krEd25519, err := ed25519.Scheme{}.Generate() + if err != nil { + return nil, err + } + return krEd25519, nil +} diff --git a/internal/models/token.go b/internal/models/token.go new file mode 100644 index 0000000..7a2b393 --- /dev/null +++ b/internal/models/token.go @@ -0,0 +1,44 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type Token struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + AuthToken string `bson:"authToken"` + ScanRef string `bson:"scanRef"` + ClientID string `bson:"clientId"` + FirstName string `bson:"firstName"` + LastName string `bson:"lastName"` + SuccessURL string `bson:"successUrl"` + ErrorURL string `bson:"errorUrl"` + UnverifiedURL string `bson:"unverifiedUrl"` + CallbackURL string `bson:"callbackUrl"` + Locale string `bson:"locale"` + ShowInstructions bool `bson:"showInstructions"` + Country string `bson:"country"` + ExpiryTime int `bson:"expiryTime"` + SessionLength int `bson:"sessionLength"` + Documents []string `bson:"documents"` + AllowedDocuments map[string][]string `bson:"allowedDocuments"` + DateOfBirth string `bson:"dateOfBirth"` + DateOfExpiry string `bson:"dateOfExpiry"` + DateOfIssue string `bson:"dateOfIssue"` + Nationality string `bson:"nationality"` + PersonalNumber string `bson:"personalNumber"` + DocumentNumber string `bson:"documentNumber"` + Sex string `bson:"sex"` + DigitString string `bson:"digitString"` + Address string `bson:"address"` + TokenType string `bson:"tokenType"` + ExternalRef string `bson:"externalRef"` + Questionnaire interface{} `bson:"questionnaire"` + UtilityBill bool `bson:"utilityBill"` + AdditionalSteps interface{} `bson:"additionalSteps"` + AdditionalData interface{} `bson:"additionalData"` + CreatedAt time.Time `bson:"createdAt"` + ExpiresAt time.Time `bson:"expiresAt"` +} diff --git a/internal/models/verification.go b/internal/models/verification.go new file mode 100644 index 0000000..7320f1d --- /dev/null +++ b/internal/models/verification.go @@ -0,0 +1,251 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type Verification struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"-"` + CreatedAt time.Time `bson:"createdAt" json:"-"` + Final *bool `bson:"final" json:"final"` // required + Platform Platform `bson:"platform" json:"platform"` // required + Status Status `bson:"status" json:"status"` // required + Data PersonData `bson:"data" json:"data"` // required + FileUrls map[string]string `bson:"fileUrls" json:"fileUrls"` // required + IdenfyRef string `bson:"scanRef" json:"scanRef"` // required + ClientID string `bson:"clientId" json:"clientId"` // required + StartTime int64 `bson:"startTime" json:"startTime"` // required + FinishTime int64 `bson:"finishTime" json:"finishTime"` // required + ClientIP string `bson:"clientIp" json:"clientIp"` // required + ClientIPCountry string `bson:"clientIpCountry" json:"clientIpCountry"` // required + ClientLocation string `bson:"clientLocation" json:"clientLocation"` // required + CompanyID string `bson:"companyId" json:"companyId"` // required + BeneficiaryID string `bson:"beneficiaryId" json:"beneficiaryId"` // required + RegistryCenterCheck interface{} `json:"registryCenterCheck,omitempty"` + AddressVerification interface{} `json:"addressVerification,omitempty"` + QuestionnaireAnswers interface{} `json:"questionnaireAnswers,omitempty"` + AdditionalSteps map[string]string `json:"additionalSteps,omitempty"` + UtilityData []string `json:"utilityData,omitempty"` + AdditionalStepPdfUrls map[string]string `json:"additionalStepPdfUrls,omitempty"` + AML []AMLCheck `bson:"AML" json:"AML,omitempty"` + LID []LID `bson:"LID" json:"LID,omitempty"` + ExternalRef string `bson:"externalRef" json:"externalRef,omitempty"` + ManualAddress string `bson:"manualAddress" json:"manualAddress,omitempty"` + ManualAddressMatch *bool `bson:"manualAddressMatch" json:"manualAddressMatch,omitempty"` +} + +type Platform string + +const ( + PlatformPC Platform = "PC" + PlatformMobile Platform = "MOBILE" + PlatformTablet Platform = "TABLET" + PlatformMobileApp Platform = "MOBILE_APP" + PlatformMobileSDK Platform = "MOBILE_SDK" + PlatformOther Platform = "OTHER" +) + +type Overall string + +const ( + OverallApproved Overall = "APPROVED" + OverallDenied Overall = "DENIED" + OverallSuspected Overall = "SUSPECTED" + OverallReviewing Overall = "REVIEWING" + OverallExpired Overall = "EXPIRED" + OverallActive Overall = "ACTIVE" + OverallDeleted Overall = "DELETED" + OverallArchived Overall = "ARCHIVED" +) + +type Status struct { + Overall *Overall `bson:"overall" json:"overall"` + SuspicionReasons []SuspicionReason `bson:"suspicionReasons" json:"suspicionReasons"` + DenyReasons []string `bson:"denyReasons" json:"denyReasons"` + FraudTags []string `bson:"fraudTags" json:"fraudTags"` + MismatchTags []string `bson:"mismatchTags" json:"mismatchTags"` + AutoFace string `bson:"autoFace" json:"autoFace,omitempty"` + ManualFace string `bson:"manualFace" json:"manualFace,omitempty"` + AutoDocument string `bson:"autoDocument" json:"autoDocument,omitempty"` + ManualDocument string `bson:"manualDocument" json:"manualDocument,omitempty"` + AdditionalSteps *AdditionalStep `bson:"additionalSteps" json:"additionalSteps,omitempty"` + AMLResultClass string `bson:"amlResultClass" json:"amlResultClass,omitempty"` + PEPSStatus string `bson:"pepsStatus" json:"pepsStatus,omitempty"` + SanctionsStatus string `bson:"sanctionsStatus" json:"sanctionsStatus,omitempty"` + AdverseMediaStatus string `bson:"adverseMediaStatus" json:"adverseMediaStatus,omitempty"` +} + +type SuspicionReason string + +const ( + SuspicionFaceSuspected SuspicionReason = "FACE_SUSPECTED" + SuspicionFaceBlacklisted SuspicionReason = "FACE_BLACKLISTED" + SuspicionDocFaceBlacklisted SuspicionReason = "DOC_FACE_BLACKLISTED" + SuspicionDocMobilePhoto SuspicionReason = "DOC_MOBILE_PHOTO" + SuspicionDevToolsOpened SuspicionReason = "DEV_TOOLS_OPENED" + SuspicionDocPrintSpoofed SuspicionReason = "DOC_PRINT_SPOOFED" + SuspicionFakePhoto SuspicionReason = "FAKE_PHOTO" + SuspicionAMLSuspection SuspicionReason = "AML_SUSPECTION" + SuspicionAMLFailed SuspicionReason = "AML_FAILED" + SuspicionLIDSuspection SuspicionReason = "LID_SUSPECTION" + SuspicionLIDFailed SuspicionReason = "LID_FAILED" + SuspicionSanctionsSuspection SuspicionReason = "SANCTIONS_SUSPECTION" + SuspicionSanctionsFailed SuspicionReason = "SANCTIONS_FAILED" + SuspicionRCFailed SuspicionReason = "RC_FAILED" + SuspicionAutoUnverifiable SuspicionReason = "AUTO_UNVERIFIABLE" +) + +type AdditionalStep string + +const ( + AdditionalStepValid AdditionalStep = "VALID" + AdditionalStepInvalid AdditionalStep = "INVALID" + AdditionalStepNotFound AdditionalStep = "NOT_FOUND" +) + +type DocumentType string + +const ( + ID_CARD DocumentType = "ID_CARD" + PASSPORT DocumentType = "PASSPORT" + RESIDENCE_PERMIT DocumentType = "RESIDENCE_PERMIT" + DRIVER_LICENSE DocumentType = "DRIVER_LICENSE" + PAN_CARD DocumentType = "PAN_CARD" + AADHAAR DocumentType = "AADHAAR" + OTHER DocumentType = "OTHER" + VISA DocumentType = "VISA" + BORDER_CROSSING DocumentType = "BORDER_CROSSING" + ASYLUM DocumentType = "ASYLUM" + NATIONAL_PASSPORT DocumentType = "NATIONAL_PASSPORT" + PROVISIONAL_DRIVER_LICENSE DocumentType = "PROVISIONAL_DRIVER_LICENSE" + VOTER_CARD DocumentType = "VOTER_CARD" + OLD_ID_CARD DocumentType = "OLD_ID_CARD" + TRAVEL_CARD DocumentType = "TRAVEL_CARD" + PHOTO_CARD DocumentType = "PHOTO_CARD" + MILITARY_CARD DocumentType = "MILITARY_CARD" + PROOF_OF_AGE_CARD DocumentType = "PROOF_OF_AGE_CARD" + DIPLOMATIC_ID DocumentType = "DIPLOMATIC_ID" +) + +type Sex string + +const ( + MALE Sex = "MALE" + FEMALE Sex = "FEMALE" + UNDEFINED Sex = "UNDEFINED" +) + +type AgeEstimate string + +const ( + UNDER_13 AgeEstimate = "UNDER_13" + OVER_13 AgeEstimate = "OVER_13" + OVER_18 AgeEstimate = "OVER_18" + OVER_22 AgeEstimate = "OVER_22" + OVER_25 AgeEstimate = "OVER_25" + OVER_30 AgeEstimate = "OVER_30" +) + +type PersonData struct { + DocFirstName string `bson:"docFirstName" json:"docFirstName"` + DocLastName string `bson:"docLastName" json:"docLastName"` + DocNumber string `bson:"docNumber" json:"docNumber"` + DocPersonalCode string `bson:"docPersonalCode" json:"docPersonalCode"` + DocExpiry string `bson:"docExpiry" json:"docExpiry"` + DocDOB string `bson:"docDob" json:"docDob"` + DocDateOfIssue string `bson:"docDateOfIssue" json:"docDateOfIssue"` + DocType *DocumentType `bson:"docType" json:"docType"` + DocSex *Sex `bson:"docSex" json:"docSex"` + DocNationality string `bson:"docNationality" json:"docNationality"` + DocIssuingCountry string `bson:"docIssuingCountry" json:"docIssuingCountry"` + BirthPlace string `bson:"birthPlace" json:"birthPlace"` + Authority string `bson:"authority" json:"authority"` + Address string `bson:"address" json:"address"` + DocTemporaryAddress string `bson:"docTemporaryAddress" json:"docTemporaryAddress"` + MothersMaidenName string `bson:"mothersMaidenName" json:"mothersMaidenName"` + DocBirthName string `bson:"docBirthName" json:"docBirthName"` + DriverLicenseCategory string `bson:"driverLicenseCategory" json:"driverLicenseCategory"` + ManuallyDataChanged *bool `bson:"manuallyDataChanged" json:"manuallyDataChanged"` + FullName string `bson:"fullName" json:"fullName"` + SelectedCountry string `bson:"selectedCountry" json:"selectedCountry"` + OrgFirstName string `bson:"orgFirstName" json:"orgFirstName"` + OrgLastName string `bson:"orgLastName" json:"orgLastName"` + OrgNationality string `bson:"orgNationality" json:"orgNationality"` + OrgBirthPlace string `bson:"orgBirthPlace" json:"orgBirthPlace"` + OrgAuthority string `bson:"orgAuthority" json:"orgAuthority"` + OrgAddress string `bson:"orgAddress" json:"orgAddress"` + OrgTemporaryAddress string `bson:"orgTemporaryAddress" json:"orgTemporaryAddress"` + OrgMothersMaidenName string `bson:"orgMothersMaidenName" json:"orgMothersMaidenName"` + OrgBirthName string `bson:"orgBirthName" json:"orgBirthName"` + AgeEstimate *AgeEstimate `bson:"ageEstimate" json:"ageEstimate"` + ClientIPProxyRiskLevel string `bson:"clientIpProxyRiskLevel" json:"clientIpProxyRiskLevel"` + DuplicateFaces []string `bson:"duplicateFaces" json:"duplicateFaces"` + DuplicateDocFaces []string `bson:"duplicateDocFaces" json:"duplicateDocFaces"` + AdditionalData interface{} `bson:"additionalData" json:"additionalData"` +} + +type AMLCheck struct { + Status ServiceStatus `bson:"status"` + Data []AMLData `bson:"data"` + ServiceName string `bson:"serviceName"` + ServiceGroupType string `bson:"serviceGroupType"` + UID string `bson:"uid"` + ErrorMessage string `bson:"errorMessage"` +} + +type AMLData struct { + Name string `bson:"name"` + Surname string `bson:"surname"` + Nationality string `bson:"nationality"` + DOB string `bson:"dob"` + Suspicion string `bson:"suspicion"` + Reason string `bson:"reason"` + ListNumber string `bson:"listNumber"` + ListName string `bson:"listName"` + Score *float64 `bson:"score"` + LastUpdate *string `bson:"lastUpdate"` + IsPerson *bool `bson:"isPerson"` + IsActive *bool `bson:"isActive"` + CheckDate string `bson:"checkDate"` +} + +type LID struct { + Status *ServiceStatus `json:"status"` + Data []LIDData `json:"data"` + ServiceName string `json:"serviceName"` + ServiceGroupType string `json:"serviceGroupType"` + UID string `json:"uid"` + ErrorMessage string `json:"errorMessage"` +} + +type LIDData struct { + DocumentNumber string `json:"documentNumber"` + DocumentType *DocumentType `json:"documentType"` + Valid *bool `json:"valid"` + ExpiryDate string `json:"expiryDate"` + CheckDate string `json:"checkDate"` +} + +type ServiceStatus struct { + ServiceSuspected *bool `json:"serviceSuspected" bson:"serviceSuspected"` + CheckSuccessful *bool `json:"checkSuccessful" bson:"checkSuccessful"` + ServiceFound *bool `json:"serviceFound" bson:"serviceFound"` + ServiceUsed *bool `json:"serviceUsed" bson:"serviceUsed"` + OverallStatus string `json:"overallStatus" bson:"overallStatus"` +} + +type VerificationOutcome struct { + Final *bool `bson:"final"` + ClientID string `bson:"clientId"` + IdenfyRef string `bson:"idenfyRef"` + Outcome Outcome `bson:"outcome"` +} + +type Outcome string + +const ( + OutcomeApproved Outcome = "APPROVED" + OutcomeRejected Outcome = "REJECTED" +) diff --git a/internal/repository/mongo.go b/internal/repository/mongo.go new file mode 100644 index 0000000..1206271 --- /dev/null +++ b/internal/repository/mongo.go @@ -0,0 +1,35 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/threefoldtech/tf-kyc-verifier/internal/models" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type TokenRepository interface { + SaveToken(ctx context.Context, token *models.Token) error + GetToken(ctx context.Context, clientID string) (*models.Token, error) + DeleteToken(ctx context.Context, clientID string, scanRef string) error +} + +type VerificationRepository interface { + SaveVerification(ctx context.Context, verification *models.Verification) error + GetVerification(ctx context.Context, clientID string) (*models.Verification, error) +} + +func NewMongoClient(ctx context.Context, mongoURI string) (*mongo.Client, error) { + client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI)) + if err != nil { + return nil, fmt.Errorf("connecting to MongoDB: %w", err) + } + + err = client.Ping(ctx, nil) + if err != nil { + return nil, fmt.Errorf("pinging MongoDB: %w", err) + } + + return client, nil +} diff --git a/internal/repository/token_repository.go b/internal/repository/token_repository.go new file mode 100644 index 0000000..44499b7 --- /dev/null +++ b/internal/repository/token_repository.go @@ -0,0 +1,82 @@ +package repository + +import ( + "context" + "time" + + "log/slog" + + "github.com/threefoldtech/tf-kyc-verifier/internal/models" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type MongoTokenRepository struct { + collection *mongo.Collection + logger *slog.Logger +} + +func NewMongoTokenRepository(ctx context.Context, db *mongo.Database, logger *slog.Logger) TokenRepository { + repo := &MongoTokenRepository{ + collection: db.Collection("tokens"), + logger: logger, + } + repo.createTTLIndex(ctx) + repo.createCollectionIndexes(ctx) + return repo +} + +func (r *MongoTokenRepository) createTTLIndex(ctx context.Context) { + _, err := r.collection.Indexes().CreateOne( + ctx, + mongo.IndexModel{ + Keys: bson.D{{Key: "expiresAt", Value: 1}}, + Options: options.Index().SetExpireAfterSeconds(0), + }, + ) + if err != nil { + r.logger.Error("Error creating TTL index", "error", err) + } +} + +func (r *MongoTokenRepository) createCollectionIndexes(ctx context.Context) { + keys := []bson.D{ + {{Key: "clientId", Value: 1}}, + {{Key: "scanRef", Value: 1}}, + } + for _, key := range keys { + _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: key, + Options: options.Index().SetUnique(true), + }) + if err != nil { + r.logger.Error("Error creating index", "key", key, "error", err) + } + } +} + +func (r *MongoTokenRepository) SaveToken(ctx context.Context, token *models.Token) error { + token.CreatedAt = time.Now() + token.ExpiresAt = token.CreatedAt.Add(time.Duration(token.ExpiryTime) * time.Second) + _, err := r.collection.InsertOne(ctx, token) + return err +} + +func (r *MongoTokenRepository) GetToken(ctx context.Context, clientID string) (*models.Token, error) { + var token models.Token + err := r.collection.FindOne(ctx, bson.M{"clientId": clientID}).Decode(&token) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + + return &token, nil +} + +func (r *MongoTokenRepository) DeleteToken(ctx context.Context, clientID string, scanRef string) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"clientId": clientID, "scanRef": scanRef}) + return err +} diff --git a/internal/repository/verification_repository.go b/internal/repository/verification_repository.go new file mode 100644 index 0000000..3b1d203 --- /dev/null +++ b/internal/repository/verification_repository.go @@ -0,0 +1,58 @@ +package repository + +import ( + "context" + "log/slog" + "time" + + "github.com/threefoldtech/tf-kyc-verifier/internal/models" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type MongoVerificationRepository struct { + collection *mongo.Collection + logger *slog.Logger +} + +func NewMongoVerificationRepository(ctx context.Context, db *mongo.Database, logger *slog.Logger) VerificationRepository { + // create index for clientId + repo := &MongoVerificationRepository{ + collection: db.Collection("verifications"), + logger: logger, + } + repo.createCollectionIndexes(ctx) + return repo +} + +func (r *MongoVerificationRepository) createCollectionIndexes(ctx context.Context) { + key := bson.D{{Key: "clientId", Value: 1}} + _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: key, + Options: options.Index().SetUnique(false), + }) + if err != nil { + r.logger.Error("Error creating index", "key", key, "error", err) + } +} + +func (r *MongoVerificationRepository) SaveVerification(ctx context.Context, verification *models.Verification) error { + verification.CreatedAt = time.Now() + _, err := r.collection.InsertOne(ctx, verification) + return err +} + +func (r *MongoVerificationRepository) GetVerification(ctx context.Context, clientID string) (*models.Verification, error) { + var verification models.Verification + // return the latest verification + opts := options.FindOne().SetSort(bson.D{{Key: "createdAt", Value: -1}}) + err := r.collection.FindOne(ctx, bson.M{"clientId": clientID}, opts).Decode(&verification) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &verification, nil +} diff --git a/internal/responses/responses.go b/internal/responses/responses.go new file mode 100644 index 0000000..e8ed6db --- /dev/null +++ b/internal/responses/responses.go @@ -0,0 +1,207 @@ +package responses + +import ( + "github.com/gofiber/fiber/v2" + "github.com/threefoldtech/tf-kyc-verifier/internal/config" + "github.com/threefoldtech/tf-kyc-verifier/internal/models" +) + +type APIResponse struct { + Result any `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +func Success(data any) *APIResponse { + return &APIResponse{ + Result: data, + } +} + +func Error(err string) *APIResponse { + return &APIResponse{ + Error: err, + } +} + +func RespondWithError(c *fiber.Ctx, status int, err error) error { + return c.Status(status).JSON(Error(err.Error())) +} + +func RespondWithData(c *fiber.Ctx, status int, data any) error { + return c.Status(status).JSON(Success(data)) +} + +type HealthStatus string + +const ( + HealthStatusHealthy HealthStatus = "Healthy" + HealthStatusDegraded HealthStatus = "Degraded" +) + +type HealthResponse struct { + Status HealthStatus `json:"status"` + Timestamp string `json:"timestamp"` + Errors []string `json:"errors"` +} + +type TokenResponse struct { + Message string `json:"message"` + AuthToken string `json:"authToken"` + ScanRef string `json:"scanRef"` + ClientID string `json:"clientId"` + ExpiryTime int `json:"expiryTime"` + SessionLength int `json:"sessionLength"` + DigitString string `json:"digitString"` + TokenType string `json:"tokenType"` +} + +type Outcome string + +const ( + OutcomeVerified Outcome = "VERIFIED" + OutcomeRejected Outcome = "REJECTED" +) + +type VerificationStatusResponse struct { + Final bool `json:"final"` + IdenfyRef string `json:"idenfyRef"` + ClientID string `json:"clientId"` + Status Outcome `json:"status"` +} + +type VerificationDataResponse struct { + DocFirstName string `json:"docFirstName"` + DocLastName string `json:"docLastName"` + DocNumber string `json:"docNumber"` + DocPersonalCode string `json:"docPersonalCode"` + DocExpiry string `json:"docExpiry"` + DocDob string `json:"docDob"` + DocDateOfIssue string `json:"docDateOfIssue"` + DocType string `json:"docType"` + DocSex string `json:"docSex"` + DocNationality string `json:"docNationality"` + DocIssuingCountry string `json:"docIssuingCountry"` + DocTemporaryAddress string `json:"docTemporaryAddress"` + DocBirthName string `json:"docBirthName"` + BirthPlace string `json:"birthPlace"` + Authority string `json:"authority"` + Address string `json:"address"` + MotherMaidenName string `json:"mothersMaidenName"` + DriverLicenseCategory string `json:"driverLicenseCategory"` + ManuallyDataChanged *bool `json:"manuallyDataChanged"` + FullName string `json:"fullName"` + OrgFirstName string `json:"orgFirstName"` + OrgLastName string `json:"orgLastName"` + OrgNationality string `json:"orgNationality"` + OrgBirthPlace string `json:"orgBirthPlace"` + OrgAuthority string `json:"orgAuthority"` + OrgAddress string `json:"orgAddress"` + OrgTemporaryAddress string `json:"orgTemporaryAddress"` + OrgMothersMaidenName string `json:"orgMothersMaidenName"` + OrgBirthName string `json:"orgBirthName"` + SelectedCountry string `json:"selectedCountry"` + AgeEstimate string `json:"ageEstimate"` + ClientIpProxyRiskLevel string `json:"clientIpProxyRiskLevel"` + DuplicateFaces []string `json:"duplicateFaces"` + DuplicateDocFaces []string `json:"duplicateDocFaces"` + AddressVerification interface{} `json:"addressVerification"` + AdditionalData interface{} `json:"additionalData"` + IdenfyRef string `json:"idenfyRef"` + ClientID string `json:"clientId"` +} + +func NewTokenResponseWithStatus(token *models.Token, isNewToken bool) *TokenResponse { + message := "Existing valid token retrieved." + if isNewToken { + message = "New token created." + } + return &TokenResponse{ + AuthToken: token.AuthToken, + ScanRef: token.ScanRef, + ClientID: token.ClientID, + ExpiryTime: token.ExpiryTime, + SessionLength: token.SessionLength, + DigitString: token.DigitString, + TokenType: token.TokenType, + Message: message, + } +} + +func NewVerificationStatusResponse(verificationOutcome *models.VerificationOutcome) *VerificationStatusResponse { + outcome := OutcomeVerified + if verificationOutcome.Outcome == models.OutcomeRejected { + outcome = OutcomeRejected + } + return &VerificationStatusResponse{ + Final: *verificationOutcome.Final, + IdenfyRef: verificationOutcome.IdenfyRef, + ClientID: verificationOutcome.ClientID, + Status: outcome, + } +} + +func NewVerificationDataResponse(verification *models.Verification) *VerificationDataResponse { + var docType string + if verification.Data.DocType != nil { + docType = string(*verification.Data.DocType) + } + var docSex string + if verification.Data.DocSex != nil { + docSex = string(*verification.Data.DocSex) + } + var manuallyDataChanged *bool + if verification.Data.ManuallyDataChanged != nil { + manuallyDataChanged = verification.Data.ManuallyDataChanged + } + var ageEstimate string + if verification.Data.AgeEstimate != nil { + ageEstimate = string(*verification.Data.AgeEstimate) + } + return &VerificationDataResponse{ + DocFirstName: verification.Data.DocFirstName, + DocLastName: verification.Data.DocLastName, + DocNumber: verification.Data.DocNumber, + DocPersonalCode: verification.Data.DocPersonalCode, + DocExpiry: verification.Data.DocExpiry, + DocDob: verification.Data.DocDOB, + DocDateOfIssue: verification.Data.DocDateOfIssue, + DocType: docType, + DocSex: docSex, + DocNationality: verification.Data.DocNationality, + DocIssuingCountry: verification.Data.DocIssuingCountry, + DocTemporaryAddress: verification.Data.DocTemporaryAddress, + DocBirthName: verification.Data.DocBirthName, + BirthPlace: verification.Data.BirthPlace, + Authority: verification.Data.Authority, + MotherMaidenName: verification.Data.MothersMaidenName, + DriverLicenseCategory: verification.Data.DriverLicenseCategory, + ManuallyDataChanged: manuallyDataChanged, + FullName: verification.Data.FullName, + OrgFirstName: verification.Data.OrgFirstName, + OrgLastName: verification.Data.OrgLastName, + OrgNationality: verification.Data.OrgNationality, + OrgBirthPlace: verification.Data.OrgBirthPlace, + OrgAuthority: verification.Data.OrgAuthority, + OrgAddress: verification.Data.OrgAddress, + OrgTemporaryAddress: verification.Data.OrgTemporaryAddress, + OrgMothersMaidenName: verification.Data.OrgMothersMaidenName, + OrgBirthName: verification.Data.OrgBirthName, + SelectedCountry: verification.Data.SelectedCountry, + AgeEstimate: ageEstimate, + ClientIpProxyRiskLevel: verification.Data.ClientIPProxyRiskLevel, + DuplicateFaces: verification.Data.DuplicateFaces, + DuplicateDocFaces: verification.Data.DuplicateDocFaces, + AddressVerification: verification.AddressVerification, + AdditionalData: verification.Data.AdditionalData, + IdenfyRef: verification.IdenfyRef, + ClientID: verification.ClientID, + } +} + +// appConfigsResponse +type AppConfigsResponse = config.Config + +// appVersionResponse +type AppVersionResponse struct { + Version string `json:"version"` +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..1b79c7e --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,299 @@ +/* +Package server contains the HTTP server for the application. +This layer is responsible for initializing the server and its dependencies. in more details: +- setting up the middleware +- setting up the database +- setting up the repositories +- setting up the services +- setting up the routes +*/ +package server + +import ( + "context" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/helmet" + "github.com/gofiber/fiber/v2/middleware/limiter" + "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/gofiber/storage/mongodb" + "github.com/gofiber/swagger" + _ "github.com/threefoldtech/tf-kyc-verifier/api/docs" + "github.com/threefoldtech/tf-kyc-verifier/internal/clients/idenfy" + "github.com/threefoldtech/tf-kyc-verifier/internal/clients/substrate" + "github.com/threefoldtech/tf-kyc-verifier/internal/config" + "github.com/threefoldtech/tf-kyc-verifier/internal/handlers" + "github.com/threefoldtech/tf-kyc-verifier/internal/middleware" + "github.com/threefoldtech/tf-kyc-verifier/internal/repository" + "github.com/threefoldtech/tf-kyc-verifier/internal/services" + "go.mongodb.org/mongo-driver/mongo" +) + +const ( + SERVER_STARTUP_TIMEOUT = 10 * time.Second + REQUEST_READ_TIMEOUT = 15 * time.Second + RESPONSE_WRITE_TIMEOUT = 15 * time.Second + CONNECTION_IDLE_TIMEOUT = 20 * time.Second + REQUETS_BODY_LIMIT = 512 * 1024 // 512KB + LOOPBACK = "127.0.0.1" +) + +// Server represents the HTTP server and its dependencies +type Server struct { + app *fiber.App + config *config.Config + logger *slog.Logger +} + +// New creates a new server instance with the given configuration and options +func New(config *config.Config, srvLogger *slog.Logger) (*Server, error) { + // Create base context for initialization + ctx, cancel := context.WithTimeout(context.Background(), SERVER_STARTUP_TIMEOUT) + defer cancel() + + // Initialize server with base configuration + server := &Server{ + config: config, + logger: srvLogger, + } + + // Initialize Fiber app with base configuration + server.app = fiber.New(fiber.Config{ + ReadTimeout: REQUEST_READ_TIMEOUT, + WriteTimeout: RESPONSE_WRITE_TIMEOUT, + IdleTimeout: CONNECTION_IDLE_TIMEOUT, + BodyLimit: REQUETS_BODY_LIMIT, + }) + + // Initialize core components + if err := server.initializeCore(ctx); err != nil { + return nil, fmt.Errorf("initializing core components: %w", err) + } + + return server, nil +} + +// initializeCore sets up the core components of the server +func (s *Server) initializeCore(ctx context.Context) error { + // Setup middleware + if err := s.setupMiddleware(); err != nil { + return fmt.Errorf("setting up middleware: %w", err) + } + + // Setup database + dbClient, db, err := s.setupDatabase(ctx) + if err != nil { + return fmt.Errorf("setting up database: %w", err) + } + + // Setup repositories + repos, err := s.setupRepositories(ctx, db) + if err != nil { + return fmt.Errorf("setting up repositories: %w", err) + } + + // Setup services + service, err := s.setupServices(repos) + if err != nil { + return fmt.Errorf("setting up services: %w", err) + } + + // Setup routes + if err := s.setupRoutes(service, dbClient); err != nil { + return fmt.Errorf("setting up routes: %w", err) + } + + return nil +} + +func (s *Server) setupMiddleware() error { + s.logger.Debug("Setting up middleware") + + // Setup rate limiter stores + ipLimiterStore := mongodb.New(mongodb.Config{ + ConnectionURI: s.config.MongoDB.URI, + Database: s.config.MongoDB.DatabaseName, + Collection: "ip_limit", + Reset: false, + }) + + idLimiterStore := mongodb.New(mongodb.Config{ + ConnectionURI: s.config.MongoDB.URI, + Database: s.config.MongoDB.DatabaseName, + Collection: "id_limit", + Reset: false, + }) + + // Configure rate limiters + ipLimiterConfig := limiter.Config{ + Max: int(s.config.IPLimiter.MaxTokenRequests), + Expiration: time.Duration(s.config.IPLimiter.TokenExpiration) * time.Minute, + Storage: ipLimiterStore, + KeyGenerator: func(c *fiber.Ctx) string { + return extractIPFromRequest(c) + }, + Next: func(c *fiber.Ctx) bool { + return extractIPFromRequest(c) == LOOPBACK + }, + SkipFailedRequests: true, + } + + idLimiterConfig := limiter.Config{ + Max: int(s.config.IDLimiter.MaxTokenRequests), + Expiration: time.Duration(s.config.IDLimiter.TokenExpiration) * time.Minute, + Storage: idLimiterStore, + KeyGenerator: func(c *fiber.Ctx) string { + return c.Get("X-Client-ID") + }, + SkipFailedRequests: true, + } + + // Apply middleware + s.app.Use(middleware.NewLoggingMiddleware(s.logger)) + s.app.Use(cors.New()) + s.app.Use(recover.New(recover.Config{ + EnableStackTrace: true, + })) + s.app.Use(helmet.New()) + + if s.config.IPLimiter.MaxTokenRequests > 0 { + s.app.Use("/api/v1/token", limiter.New(ipLimiterConfig)) + } + if s.config.IDLimiter.MaxTokenRequests > 0 { + s.app.Use("/api/v1/token", limiter.New(idLimiterConfig)) + } + + return nil +} + +func (s *Server) setupDatabase(ctx context.Context) (*mongo.Client, *mongo.Database, error) { + s.logger.Debug("Connecting to database") + + client, err := repository.NewMongoClient(ctx, s.config.MongoDB.URI) + if err != nil { + return nil, nil, fmt.Errorf("setting up database: %w", err) + } + + return client, client.Database(s.config.MongoDB.DatabaseName), nil +} + +type repositories struct { + token repository.TokenRepository + verification repository.VerificationRepository +} + +func (s *Server) setupRepositories(ctx context.Context, db *mongo.Database) (*repositories, error) { + s.logger.Debug("Setting up repositories") + + return &repositories{ + token: repository.NewMongoTokenRepository(ctx, db, s.logger), + verification: repository.NewMongoVerificationRepository(ctx, db, s.logger), + }, nil +} + +func (s *Server) setupServices(repos *repositories) (*services.KYCService, error) { + s.logger.Debug("Setting up services") + + idenfyClient := idenfy.New(&s.config.Idenfy, s.logger) + + substrateClient, err := substrate.New(&s.config.TFChain, s.logger) + if err != nil { + return nil, fmt.Errorf("initializing substrate client: %w", err) + } + kycService, err := services.NewKYCService( + repos.verification, + repos.token, + idenfyClient, + substrateClient, + s.config, + s.logger, + ) + if err != nil { + return nil, err + } + return kycService, nil +} + +func (s *Server) setupRoutes(kycService *services.KYCService, mongoCl *mongo.Client) error { + s.logger.Debug("Setting up routes") + + handler := handlers.NewHandler(kycService, s.config, s.logger) + + // API routes + v1 := s.app.Group("/api/v1") + v1.Post("/token", middleware.AuthMiddleware(s.config.Challenge), handler.GetOrCreateVerificationToken()) + v1.Get("/data", middleware.AuthMiddleware(s.config.Challenge), handler.GetVerificationData()) + v1.Get("/status", handler.GetVerificationStatus()) + v1.Get("/health", handler.HealthCheck(mongoCl)) + v1.Get("/configs", handler.GetServiceConfigs()) + v1.Get("/version", handler.GetServiceVersion()) + + // Webhook routes + webhooks := s.app.Group("/webhooks/idenfy") + webhooks.Post("/verification-update", handler.ProcessVerificationResult()) + webhooks.Post("/id-expiration", handler.ProcessDocExpirationNotification()) + + // Documentation + s.app.Get("/docs/*", swagger.HandlerDefault) + + return nil +} + +func extractIPFromRequest(c *fiber.Ctx) string { + // Check for X-Forwarded-For header + if ip := c.Get("X-Forwarded-For"); ip != "" { + ips := strings.Split(ip, ",") + for _, ip := range ips { + // return the first non-private ip in the list + if net.ParseIP(strings.TrimSpace(ip)) != nil && !net.ParseIP(strings.TrimSpace(ip)).IsPrivate() { + return strings.TrimSpace(ip) + } + } + } + // Check for X-Real-IP header if not a private IP + if ip := c.Get("X-Real-IP"); ip != "" { + if net.ParseIP(strings.TrimSpace(ip)) != nil && !net.ParseIP(strings.TrimSpace(ip)).IsPrivate() { + return strings.TrimSpace(ip) + } + } + // Fall back to RemoteIP() if no proxy headers are present + ip := c.IP() + if parsedIP := net.ParseIP(ip); parsedIP != nil { + if !parsedIP.IsPrivate() { + return ip + } + } + // If we still have a private IP, return a default value that will be skipped by the limiter + return LOOPBACK +} + +func (s *Server) Run() error { + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + <-sigChan + // Graceful shutdown + s.logger.Info("Shutting down server...") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := s.app.ShutdownWithContext(ctx); err != nil { + s.logger.Error("Server forced to shutdown:", slog.String("error", err.Error())) + } + }() + + // Start server + if err := s.app.Listen(":" + s.config.Server.Port); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("starting server: %w", err) + } + return nil +} diff --git a/internal/services/services.go b/internal/services/services.go new file mode 100644 index 0000000..24c07ca --- /dev/null +++ b/internal/services/services.go @@ -0,0 +1,254 @@ +/* +Package services contains the services for the application. +This layer is responsible for handling the business logic. +*/ +package services + +import ( + "context" + "fmt" + "log/slog" + "slices" + "strconv" + "strings" + "time" + + "github.com/threefoldtech/tf-kyc-verifier/internal/clients/idenfy" + "github.com/threefoldtech/tf-kyc-verifier/internal/clients/substrate" + "github.com/threefoldtech/tf-kyc-verifier/internal/config" + "github.com/threefoldtech/tf-kyc-verifier/internal/errors" + "github.com/threefoldtech/tf-kyc-verifier/internal/models" + "github.com/threefoldtech/tf-kyc-verifier/internal/repository" +) + +const TFT_CONVERSION_FACTOR = 10000000 + +type KYCService struct { + verificationRepo repository.VerificationRepository + tokenRepo repository.TokenRepository + idenfy idenfy.IdenfyClient + substrate substrate.SubstrateClient + config *config.Verification + logger *slog.Logger + IdenfySuffix string +} + +func NewKYCService(verificationRepo repository.VerificationRepository, tokenRepo repository.TokenRepository, idenfy idenfy.IdenfyClient, substrateClient substrate.SubstrateClient, config *config.Config, logger *slog.Logger) (*KYCService, error) { + idenfySuffix, err := GetIdenfySuffix(substrateClient, config) + if err != nil { + return nil, fmt.Errorf("getting idenfy suffix: %w", err) + } + return &KYCService{verificationRepo: verificationRepo, tokenRepo: tokenRepo, idenfy: idenfy, substrate: substrateClient, config: &config.Verification, logger: logger, IdenfySuffix: idenfySuffix}, nil +} + +func GetIdenfySuffix(substrateClient substrate.SubstrateClient, config *config.Config) (string, error) { + idenfySuffix, err := GetChainNetworkName(substrateClient) + if err != nil { + return "", fmt.Errorf("getting chain network name: %w", err) + } + if config.Idenfy.Namespace != "" { + idenfySuffix = config.Idenfy.Namespace + ":" + idenfySuffix + } + return idenfySuffix, nil +} + +func GetChainNetworkName(substrateClient substrate.SubstrateClient) (string, error) { + chainName, err := substrateClient.GetChainName() + if err != nil { + return "", err + } + chainNameParts := strings.Split(chainName, " ") + chainNetworkName := strings.ToLower(chainNameParts[len(chainNameParts)-1]) + return chainNetworkName, nil +} + +// ----------------------------- +// Token related methods +// ----------------------------- +func (s *KYCService) GetOrCreateVerificationToken(ctx context.Context, clientID string) (*models.Token, bool, error) { + isVerified, err := s.IsUserVerified(ctx, clientID) + if err != nil { + s.logger.Error("Error checking if user is verified", "clientID", clientID, "error", err) + return nil, false, errors.NewInternalError("getting verification status from database", err) // db error + } + if isVerified { + return nil, false, errors.NewConflictError("user already verified", nil) // TODO: implement a custom error that can be converted in the handler to a 4xx such 409 status code + } + token, err_ := s.tokenRepo.GetToken(ctx, clientID) + if err_ != nil { + s.logger.Error("Error getting token from database", "clientID", clientID, "error", err_) + return nil, false, errors.NewInternalError("getting token from database", err_) // db error + } + // check if token is found and not expired + if token != nil { + duration := time.Since(token.CreatedAt) + if duration < time.Duration(token.ExpiryTime)*time.Second { + remainingTime := time.Duration(token.ExpiryTime)*time.Second - duration + token.ExpiryTime = int(remainingTime.Seconds()) + return token, false, nil + } + } + + // check if user account balance satisfies the minimum required balance, return an error if not + hasRequiredBalance, err_ := s.AccountHasRequiredBalance(ctx, clientID) + if err_ != nil { + s.logger.Error("Error checking if user account has required balance", "clientID", clientID, "error", err_) + return nil, false, errors.NewExternalError("checking if user account has required balance", err_) + } + if !hasRequiredBalance { + requiredBalance := s.config.MinBalanceToVerifyAccount / TFT_CONVERSION_FACTOR + return nil, false, errors.NewNotSufficientBalanceError(fmt.Sprintf("account does not have the minimum required balance to verify (%d) TFT", requiredBalance), nil) + } + // prefix clientID with tfchain network prefix + uniqueClientID := clientID + ":" + s.IdenfySuffix + newToken, err_ := s.idenfy.CreateVerificationSession(ctx, uniqueClientID) + if err_ != nil { + s.logger.Error("Error creating iDenfy verification session", "clientID", clientID, "uniqueClientID", uniqueClientID, "error", err_) + return nil, false, errors.NewExternalError("creating iDenfy verification session", err_) + } + // save the token with the original clientID + newToken.ClientID = clientID + err_ = s.tokenRepo.SaveToken(ctx, &newToken) + if err_ != nil { + s.logger.Error("Error saving verification token to database", "clientID", clientID, "error", err_) + } + + return &newToken, true, nil +} + +func (s *KYCService) DeleteToken(ctx context.Context, clientID string, scanRef string) error { + + err := s.tokenRepo.DeleteToken(ctx, clientID, scanRef) + if err != nil { + s.logger.Error("Error deleting verification token from database", "clientID", clientID, "scanRef", scanRef, "error", err) + return errors.NewInternalError("deleting verification token from database", err) + } + return nil +} + +func (s *KYCService) AccountHasRequiredBalance(ctx context.Context, address string) (bool, error) { + if s.config.MinBalanceToVerifyAccount == 0 { + s.logger.Warn("Minimum balance to verify account is 0 which is not recommended", "address", address) + return true, nil + } + balance, err := s.substrate.GetAccountBalance(address) + if err != nil { + s.logger.Error("Error getting account balance", "address", address, "error", err) + return false, errors.NewExternalError("getting account balance", err) + } + return balance >= s.config.MinBalanceToVerifyAccount, nil +} + +// ----------------------------- +// Verifications related methods +// ----------------------------- +func (s *KYCService) GetVerificationData(ctx context.Context, clientID string) (*models.Verification, error) { + verification, err := s.verificationRepo.GetVerification(ctx, clientID) + if err != nil { + s.logger.Error("Error getting verification from database", "clientID", clientID, "error", err) + return nil, errors.NewInternalError("getting verification from database", err) + } + return verification, nil +} + +func (s *KYCService) GetVerificationStatus(ctx context.Context, clientID string) (*models.VerificationOutcome, error) { + // check first if the clientID is in alwaysVerifiedAddresses + if s.config.AlwaysVerifiedIDs != nil && slices.Contains(s.config.AlwaysVerifiedIDs, clientID) { + final := true + s.logger.Info("ClientID is in always verified addresses. skipping verification", "clientID", clientID) + return &models.VerificationOutcome{ + Final: &final, + ClientID: clientID, + IdenfyRef: "", + Outcome: models.OutcomeApproved, + }, nil + } + verification, err := s.verificationRepo.GetVerification(ctx, clientID) + if err != nil { + s.logger.Error("Error getting verification from database", "clientID", clientID, "error", err) + return nil, errors.NewInternalError("getting verification from database", err) + } + var outcome models.Outcome + if verification != nil { + if verification.Status.Overall != nil && *verification.Status.Overall == models.OverallApproved || (s.config.SuspiciousVerificationOutcome == "APPROVED" && *verification.Status.Overall == models.OverallSuspected) { + outcome = models.OutcomeApproved + } else { + outcome = models.OutcomeRejected + } + } else { + return nil, nil + } + return &models.VerificationOutcome{ + Final: verification.Final, + ClientID: clientID, + IdenfyRef: verification.IdenfyRef, + Outcome: outcome, + }, nil +} + +func (s *KYCService) GetVerificationStatusByTwinID(ctx context.Context, twinID string) (*models.VerificationOutcome, error) { + // get the address from the twinID + twinIDUint64, err := strconv.ParseUint(twinID, 10, 32) + if err != nil { + s.logger.Error("Error parsing twinID", "twinID", twinID, "error", err) + return nil, errors.NewInternalError("parsing twinID", err) + } + address, err := s.substrate.GetAddressByTwinID(uint32(twinIDUint64)) + if err != nil { + s.logger.Error("Error getting address from twinID", "twinID", twinID, "error", err) + return nil, errors.NewExternalError("looking up twinID address from TFChain", err) + } + return s.GetVerificationStatus(ctx, address) +} + +func (s *KYCService) ProcessVerificationResult(ctx context.Context, body []byte, sigHeader string, result models.Verification) error { + err := s.idenfy.VerifyCallbackSignature(ctx, body, sigHeader) + if err != nil { + s.logger.Error("Error verifying callback signature", "sigHeader", sigHeader, "error", err) + return errors.NewAuthorizationError("verifying callback signature", err) + } + clientIDParts := strings.Split(result.ClientID, ":") + if len(clientIDParts) < 2 { + s.logger.Error("clientID have no network suffix", "clientID", result.ClientID) + return errors.NewInternalError("invalid clientID", nil) + } + networkSuffix := clientIDParts[len(clientIDParts)-1] + if networkSuffix != s.IdenfySuffix { + s.logger.Error("clientID has different network suffix", "clientID", result.ClientID, "expectedSuffix", s.IdenfySuffix, "actualSuffix", networkSuffix) + return errors.NewInternalError("invalid clientID", nil) + } + // delete the token with the same clientID and same scanRef + result.ClientID = clientIDParts[0] + + err = s.tokenRepo.DeleteToken(ctx, result.ClientID, result.IdenfyRef) + if err != nil { + s.logger.Warn("Error deleting verification token from database", "clientID", result.ClientID, "scanRef", result.IdenfyRef, "error", err) + } + // if the verification status is EXPIRED, we don't need to save it + if result.Status.Overall != nil && *result.Status.Overall != models.Overall("EXPIRED") { + // remove idenfy suffix from clientID + err = s.verificationRepo.SaveVerification(ctx, &result) + if err != nil { + s.logger.Error("Error saving verification to database", "clientID", result.ClientID, "scanRef", result.IdenfyRef, "error", err) + return errors.NewInternalError("saving verification to database", err) + } + } + s.logger.Debug("Verification result processed successfully", "result", result) + return nil +} + +func (s *KYCService) ProcessDocExpirationNotification(ctx context.Context, clientID string) error { + return nil +} + +func (s *KYCService) IsUserVerified(ctx context.Context, clientID string) (bool, error) { + verification, err := s.verificationRepo.GetVerification(ctx, clientID) + if err != nil { + s.logger.Error("Error getting verification from database", "clientID", clientID, "error", err) + return false, errors.NewInternalError("getting verification from database", err) + } + if verification == nil { + return false, nil + } + return verification.Status.Overall != nil && (*verification.Status.Overall == models.OverallApproved || (s.config.SuspiciousVerificationOutcome == "APPROVED" && *verification.Status.Overall == models.OverallSuspected)), nil +} diff --git a/scripts/dev/auth/generate-test-auth-data.go b/scripts/dev/auth/generate-test-auth-data.go new file mode 100644 index 0000000..ee44555 --- /dev/null +++ b/scripts/dev/auth/generate-test-auth-data.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/hex" + "fmt" + "os" + "time" + + "github.com/vedhavyas/go-subkey/v2" + "github.com/vedhavyas/go-subkey/v2/ed25519" + "github.com/vedhavyas/go-subkey/v2/sr25519" +) + +// Generate test auth data for development use +func main() { + // get domain from first arg. error if not provided + if len(os.Args) < 2 { + fmt.Println("Error: Domain is required") + os.Exit(1) + } + domain := os.Args[1] + message := createSignMessage(domain) + // if no arg provided, generate random keys + krSr25519, krEd25519, err := loadKeys() + if err != nil { + panic(err) + } + msg := []byte(message) + sigSr25519, err := krSr25519.Sign(msg) + if err != nil { + panic(err) + } + sigEd25519, err := krEd25519.Sign(msg) + if err != nil { + panic(err) + } + messageString := hex.EncodeToString([]byte(message)) + fmt.Println("______________________") + fmt.Println("Auth Data") + fmt.Println("______________________") + fmt.Println("** SR25519 **") + //fmt.Println("Public key sr25519: ", hex.EncodeToString(krSr25519.Public())) + fmt.Println("SS58Address sr25519: ", krSr25519.SS58Address(42)) + fmt.Println("Challenge hex: ", hex.EncodeToString([]byte(message))) + fmt.Println("Signature sr25519: ", hex.EncodeToString(sigSr25519)) + fmt.Println("______________________") + fmt.Println("** ED25519 **") + // fmt.Println("Public key ed25519: ", hex.EncodeToString(krEd25519.Public())) + fmt.Println("SS58Address ed25519: ", krEd25519.SS58Address(42)) + fmt.Println("Challenge hex: ", hex.EncodeToString([]byte(message))) + fmt.Println("Signature ed25519: ", hex.EncodeToString(sigEd25519)) + fmt.Println("______________________") + bytes, err := hex.DecodeString(messageString) + if err != nil { + panic(err) + } + fmt.Println("challenge string (plain text): ", string(bytes)) +} + +func createSignMessage(domain string) string { + // return a message with the domain and the current timestamp in hex + message := fmt.Sprintf("%s:%d", domain, time.Now().Unix()) + fmt.Println("message: ", message) + return message +} + +func loadKeys() (subkey.KeyPair, subkey.KeyPair, error) { + if len(os.Args) < 3 { + krSr25519, err := sr25519.Scheme{}.Generate() + if err != nil { + return nil, nil, err + } + krEd25519, err := ed25519.Scheme{}.Generate() + if err != nil { + return nil, nil, err + } + return krSr25519, krEd25519, nil + } else { + krSr25519, err := sr25519.Scheme{}.FromPhrase(os.Args[2], "") + if err != nil { + return nil, nil, err + } + krEd25519, err := ed25519.Scheme{}.FromPhrase(os.Args[2], "") + if err != nil { + return nil, nil, err + } + return krSr25519, krEd25519, nil + } +} diff --git a/scripts/dev/balance/check-account-balance.go b/scripts/dev/balance/check-account-balance.go new file mode 100644 index 0000000..881f30a --- /dev/null +++ b/scripts/dev/balance/check-account-balance.go @@ -0,0 +1,35 @@ +// Use substarte client to get account free balance for development use +package main + +import ( + "fmt" + "log/slog" + + "github.com/threefoldtech/tf-kyc-verifier/internal/clients/substrate" +) + +func main() { + config := &TFChainConfig{ + WsProviderURL: "wss://tfchain.dev.grid.tf", + } + + logger := slog.Default() + substrateClient, err := substrate.New(config, logger) + if err != nil { + panic(err) + } + free_balance, err := substrateClient.GetAccountBalance("5DFkH2fcqYecVHjfgAEfxgsJyoEg5Kd93JFihfpHDaNoWagJ") + if err != nil { + panic(err) + } + fmt.Println(free_balance) +} + +type TFChainConfig struct { + WsProviderURL string +} + +// implement SubstrateConfig for config.TFChain +func (c *TFChainConfig) GetWsProviderURL() string { + return c.WsProviderURL +} diff --git a/scripts/dev/chain/chain_name.go b/scripts/dev/chain/chain_name.go new file mode 100644 index 0000000..08d6d2e --- /dev/null +++ b/scripts/dev/chain/chain_name.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "log/slog" + + "github.com/threefoldtech/tf-kyc-verifier/internal/clients/substrate" +) + +func main() { + config := &TFChainConfig{ + WsProviderURL: "wss://tfchain.dev.grid.tf", + } + + logger := slog.Default() + substrateClient, err := substrate.New(config, logger) + if err != nil { + panic(err) + } + + chainName, err := substrateClient.GetChainName() + if err != nil { + panic(err) + } + fmt.Println(chainName) + +} + +type TFChainConfig struct { + WsProviderURL string +} + +// implement SubstrateConfig for config.TFChain +func (c *TFChainConfig) GetWsProviderURL() string { + return c.WsProviderURL +} diff --git a/scripts/dev/twin/get-address-by-twin-id.go b/scripts/dev/twin/get-address-by-twin-id.go new file mode 100644 index 0000000..c48e1e0 --- /dev/null +++ b/scripts/dev/twin/get-address-by-twin-id.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "log/slog" + + "github.com/threefoldtech/tf-kyc-verifier/internal/clients/substrate" +) + +func main() { + config := &TFChainConfig{ + WsProviderURL: "wss://tfchain.dev.grid.tf", + } + + logger := slog.Default() + substrateClient, err := substrate.New(config, logger) + if err != nil { + panic(err) + } + + address, err := substrateClient.GetAddressByTwinID(41) + if err != nil { + panic(err) + } + fmt.Println(address) + +} + +type TFChainConfig struct { + WsProviderURL string +} + +// implement SubstrateConfig for config.TFChain +func (c *TFChainConfig) GetWsProviderURL() string { + return c.WsProviderURL +}