From ff0e97e36b84fab6bee53013397865a12d6fb19c Mon Sep 17 00:00:00 2001 From: Ichen Chhoeng Date: Mon, 17 Nov 2025 11:25:33 +0700 Subject: [PATCH] blah --- .env.example | 10 +- Dockerfile | 10 +- Makefile | 61 ++-- README.md | 48 +++- config/kratos/kratos.yml | 6 +- config/openfga/openfga-config.json | 19 -- k8s/base/app-cnpg.yml | 19 -- k8s/base/app-kratos.yml | 77 +----- k8s/base/kratos.yml | 80 ------ k8s/base/kustomization.yml | 10 +- k8s/base/postgres-dotnet.yml | 2 +- k8s/base/postgres-kratos.yml | 1 - .../app-envoy.yml => bootstrap/bootstrap.yml} | 24 +- k8s/bootstrap/kustomization.yml | 17 ++ k8s/bootstrap/namespaces.yml | 4 + k8s/local/kustomization.yml | 14 +- k8s/uat/app.yml | 18 ++ k8s/uat/kustomization.yml | 15 +- khmer-eID-backend.sln | 2 +- .../Controllers/AuthController.cs | 48 ---- .../Controllers/UserController.cs | 24 -- .../Integrations/Ory/KratosHandler.cs | 52 ---- .../Integrations/Ory/KratosIntegration.cs | 193 ------------- .../Integrations/Ory/OryAuthSchemeOptions.cs | 9 - .../Middlewares/KratosAuthMiddleware.cs | 27 -- khmer_eid_backend/Models/Kratos.cs | 25 -- khmer_eid_backend/Models/User.cs | 7 - khmer_eid_backend/Program.cs | 19 -- khmer_eid_backend/Resources/SignupResource.cs | 6 - khmer_eid_backend/Services/AuthService.cs | 0 .../appsettings.Development.json | 29 -- khmer_eid_backend/khmer_eid_backend.csproj | 17 -- khmer_eid_backend/khmer_eid_backend.http | 6 - .../Controllers/AuthController.cs | 62 +++++ .../Controllers/UserController.cs | 17 ++ khmereid_backend/Data/AppDbContext.cs | 10 + khmereid_backend/Dtos/ApiResponse.cs | 29 ++ khmereid_backend/Dtos/FlowDto.cs | 10 + khmereid_backend/Dtos/LoginRequest.cs | 10 + khmereid_backend/Dtos/LoginResultDto.cs | 13 + khmereid_backend/Dtos/SessionDto.cs | 11 + .../Dtos}/SignupRequest.cs | 10 +- khmereid_backend/Dtos/SignupResultDto.cs | 13 + khmereid_backend/Dtos/UserDto.cs | 10 + khmereid_backend/Dtos/VerifyOtpRequest.cs | 10 + .../Extensions/ControllerExtensions.cs | 20 ++ .../Extensions/HttpContextExtensions.cs | 10 + khmereid_backend/Integrations/IAuthnApi.cs | 13 + khmereid_backend/Integrations/KratosApi.cs | 260 ++++++++++++++++++ khmereid_backend/Integrations/MockAuthnApi.cs | 102 +++++++ .../Middlewares/KratosMiddleware.cs | 73 +++++ .../20251117033748_init.Designer.cs | 69 +++++ .../Migrations/20251117033748_init.cs | 49 ++++ .../Migrations/AppDbContextModelSnapshot.cs | 66 +++++ khmereid_backend/Models/User.cs | 20 ++ khmereid_backend/Program.cs | 68 +++++ .../Properties/launchSettings.json | 0 khmereid_backend/Services/AuthService.cs | 90 ++++++ khmereid_backend/Services/UserService.cs | 31 +++ khmereid_backend/appsettings.Development.json | 21 ++ .../appsettings.json | 5 +- khmereid_backend/khmereid_backend.csproj | 26 ++ khmereid_backend/test.http | 5 + skaffold.yaml | 18 +- 64 files changed, 1313 insertions(+), 737 deletions(-) delete mode 100644 config/openfga/openfga-config.json delete mode 100644 k8s/base/app-cnpg.yml delete mode 100644 k8s/base/kratos.yml rename k8s/{base/app-envoy.yml => bootstrap/bootstrap.yml} (51%) create mode 100644 k8s/bootstrap/kustomization.yml create mode 100644 k8s/bootstrap/namespaces.yml create mode 100644 k8s/uat/app.yml delete mode 100644 khmer_eid_backend/Controllers/AuthController.cs delete mode 100644 khmer_eid_backend/Controllers/UserController.cs delete mode 100644 khmer_eid_backend/Integrations/Ory/KratosHandler.cs delete mode 100644 khmer_eid_backend/Integrations/Ory/KratosIntegration.cs delete mode 100644 khmer_eid_backend/Integrations/Ory/OryAuthSchemeOptions.cs delete mode 100644 khmer_eid_backend/Middlewares/KratosAuthMiddleware.cs delete mode 100644 khmer_eid_backend/Models/Kratos.cs delete mode 100644 khmer_eid_backend/Models/User.cs delete mode 100644 khmer_eid_backend/Program.cs delete mode 100644 khmer_eid_backend/Resources/SignupResource.cs delete mode 100644 khmer_eid_backend/Services/AuthService.cs delete mode 100644 khmer_eid_backend/appsettings.Development.json delete mode 100644 khmer_eid_backend/khmer_eid_backend.csproj delete mode 100644 khmer_eid_backend/khmer_eid_backend.http create mode 100644 khmereid_backend/Controllers/AuthController.cs create mode 100644 khmereid_backend/Controllers/UserController.cs create mode 100644 khmereid_backend/Data/AppDbContext.cs create mode 100644 khmereid_backend/Dtos/ApiResponse.cs create mode 100644 khmereid_backend/Dtos/FlowDto.cs create mode 100644 khmereid_backend/Dtos/LoginRequest.cs create mode 100644 khmereid_backend/Dtos/LoginResultDto.cs create mode 100644 khmereid_backend/Dtos/SessionDto.cs rename {khmer_eid_backend/Requests => khmereid_backend/Dtos}/SignupRequest.cs (57%) create mode 100644 khmereid_backend/Dtos/SignupResultDto.cs create mode 100644 khmereid_backend/Dtos/UserDto.cs create mode 100644 khmereid_backend/Dtos/VerifyOtpRequest.cs create mode 100644 khmereid_backend/Extensions/ControllerExtensions.cs create mode 100644 khmereid_backend/Extensions/HttpContextExtensions.cs create mode 100644 khmereid_backend/Integrations/IAuthnApi.cs create mode 100644 khmereid_backend/Integrations/KratosApi.cs create mode 100644 khmereid_backend/Integrations/MockAuthnApi.cs create mode 100644 khmereid_backend/Middlewares/KratosMiddleware.cs create mode 100644 khmereid_backend/Migrations/20251117033748_init.Designer.cs create mode 100644 khmereid_backend/Migrations/20251117033748_init.cs create mode 100644 khmereid_backend/Migrations/AppDbContextModelSnapshot.cs create mode 100644 khmereid_backend/Models/User.cs create mode 100644 khmereid_backend/Program.cs rename {khmer_eid_backend => khmereid_backend}/Properties/launchSettings.json (100%) create mode 100644 khmereid_backend/Services/AuthService.cs create mode 100644 khmereid_backend/Services/UserService.cs create mode 100644 khmereid_backend/appsettings.Development.json rename {khmer_eid_backend => khmereid_backend}/appsettings.json (54%) create mode 100644 khmereid_backend/khmereid_backend.csproj create mode 100644 khmereid_backend/test.http diff --git a/.env.example b/.env.example index d0c238d..05eb1bb 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,9 @@ -DATABASE_USERNAME= \ No newline at end of file +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASS=postgres +DB_NAME=khmereid_backend_db +DB_SSL_MODE=Disable + +Kyc__EKYC_Solutions__BaseUrl= +Kyc__EKYC_Solutions__PrivateKey= \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6c2f0cd..014226d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,17 +3,17 @@ FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build WORKDIR /app # Copy the project files -COPY khmer_eid_backend/*.csproj ./ +COPY khmereid_backend/*.csproj ./ # Restore dependencies RUN dotnet restore # Copy the rest of the application code -COPY khmer_eid_backend/ ./ +COPY khmereid_backend/ ./ # Build the application # Change to Release for prod -RUN dotnet publish khmer_eid_backend.csproj -c Debug -o out +RUN dotnet publish khmereid_backend.csproj -c Debug -o out # Use the .NET runtime image for running the application FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime @@ -23,11 +23,11 @@ WORKDIR /app COPY --from=build /app/out ./ # Copy Program.cs into the runtime image so you can inspect it -COPY khmer_eid_backend/Program.cs ./ +COPY khmereid_backend/Program.cs ./ # Install vim RUN apt-get update && apt-get install -y vim && rm -rf /var/lib/apt/lists/* # Expose the port and run the application EXPOSE 5000 -ENTRYPOINT ["dotnet", "khmer_eid_backend.dll"] +ENTRYPOINT ["dotnet", "khmereid_backend.dll"] diff --git a/Makefile b/Makefile index c6cbd2a..a3ab388 100644 --- a/Makefile +++ b/Makefile @@ -1,32 +1,41 @@ -.PHONY: dev-up dev-down prod-up prod-down logs build +.PHONY: bootstrap, dev -dev-up: - docker compose -f docker-compose.dev.yml up -d --remove-orphans +bootstrap: + @echo "Creating kind cluster 'khmereid' if it doesn't exist..." + @if ! kind get clusters | grep -q '^khmereid$$'; then \ + kind create cluster --name khmereid; \ + else \ + echo "Cluster 'khmereid' already exists, skipping creation"; \ + fi -dev-down: - docker compose -f docker-compose.dev.yml down + @echo "Applying Kubernetes bootstrap manifests..." + @kubectl apply -k k8s/bootstrap + @kubectl apply -f k8s/bootstrap/bootstrap.yml -dev-restore: - docker compose -f docker-compose.dev.yml run khmer-eid-api dotnet restore + @echo "Waiting for namespace 'envoy-gateway-system' to appear..." + @while ! kubectl get namespace envoy-gateway-system >/dev/null 2>&1; do \ + printf "."; sleep 2; \ + done; \ + echo "\nNamespace 'envoy-gateway-system' is ready!" -dev-build: - docker compose -f docker-compose.dev.yml build + @echo "Waiting for all pods in all namespaces to be ready (timeout 10 minutes)..." + @kubectl wait --for=condition=Ready pod --all --all-namespaces --timeout=600s + @echo "All pods are ready!" -dev-list-packages: - docker exec -it khmer-eid-api-dev dotnet list package - -dev-exec: - docker exec -it khmer-eid-api-dev /bin/bash - -prod-up: - docker compose -f docker-compose.prod.yml up -d - -prod-down: - docker compose -f docker-compose.prod.yml down - -dev-logs: - docker compose -f docker-compose.dev.yml logs -f khmer-eid-api - -prod-logs: - docker compose -f docker-compose.prod.yml logs -f khmer-eid-api +dev-migrate: + @skaffold run --cleanup=false + @sleep 5 + @cd khmereid_backend && dotnet ef database update + @echo "Waiting for all pods in all namespaces to be ready (timeout 10 minutes)..." + @kubectl wait --for=condition=Ready pod --all --all-namespaces --timeout=600s +dev: + @echo "Running Skaffold to build and deploy once..." + @skaffold run --cleanup=false + @echo "Starting local development environment..." + # Port-forward Kratos + @kubectl port-forward svc/kratos-app-public 4433:80 & + # Wait a moment for port-forwards to initialize + @sleep 5 + # Run dotnet in watch mode + @cd khmereid_backend && dotnet watch run --urls http://localhost:5200 diff --git a/README.md b/README.md index 95c4ef1..f7609af 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,42 @@ -### In order to add a new package, run the following commands -1. dotnet add package -2. dotnet resotre -3. docker compose build -4. docker compose up +## Quick Start (Local Development) +### Prerequisites +Make sure you have: -### config kratos -create schema kratos +- [.NET 9 SDK](https://dotnet.microsoft.com/en-us/download) +- [Docker](https://www.docker.com/) +- kind ```brew install kind``` +- skaffold ```brew install skaffold``` +- PostgreSQL -docker exec -it kratos kratos migrate sql \ - "postgres://dev:dev@postgres:5432/backend_db?sslmode=disable&search_path=kratos" \ - -y +--- +### Clone and set up -### config hanko -create schema hanko +1. Clone the repository and navigate into it: +```bash +git clone https://github.com/khmer-eid/khmereid-backend.git +cd khmereid-backend +``` -docker compose run --rm hanko migrate up +2. Bootstrap the environment: +```bash +make bootstrap +``` -docker compose up -d hanko \ No newline at end of file +3. Set up environment variables: +```bash +cp .env.example .env +# update db config part your .env +``` + +4. Run database migrations: +```bash +make dev-migrate +``` + +5. Start the development server: +```bash +make dev +``` \ No newline at end of file diff --git a/config/kratos/kratos.yml b/config/kratos/kratos.yml index 11235dc..2552c9b 100644 --- a/config/kratos/kratos.yml +++ b/config/kratos/kratos.yml @@ -1,5 +1,5 @@ version: v1.3.0 -dsn: "postgres://dev:dev@postgres:5432/backend_db?sslmode=disable&search_path=kratos" +dsn: ${DSN} log: leak_sensitive_values: true @@ -31,14 +31,14 @@ identity: default_schema_id: default schemas: - id: default - url: file:///etc/config/identity.schema.json + url: file:///etc/config/custom/identity.schema.json courier: channels: - id: sms type: http request_config: method: POST - url: https://webhook.site/b9e137a6-f184-47b5-ac43-50c95a95cd14 + url: https://webhook.site/d67a9ea6-e152-4d7f-8432-7ab92b2ace1f body: base64://ZnVuY3Rpb24oY3R4KSB7CiAgcmVjaXBpZW50OiBjdHgucmVjaXBpZW50LAogIHRlbXBsYXRlX3R5cGU6IGN0eC50ZW1wbGF0ZV90eXBlLAogIHRvOiBpZiAidGVtcGxhdGVfZGF0YSIgaW4gY3R4ICYmICJ0byIgaW4gY3R4LnRlbXBsYXRlX2RhdGEgdGhlbiBjdHgudGVtcGxhdGVfZGF0YS50byBlbHNlIG51bGwsCiAgcmVjb3ZlcnlfY29kZTogaWYgInRlbXBsYXRlX2RhdGEiIGluIGN0eCAmJiAicmVjb3ZlcnlfY29kZSIgaW4gY3R4LnRlbXBsYXRlX2RhdGEgdGhlbiBjdHgudGVtcGxhdGVfZGF0YS5yZWNvdmVyeV9jb2RlIGVsc2UgbnVsbCwKICByZWNvdmVyeV91cmw6IGlmICJ0ZW1wbGF0ZV9kYXRhIiBpbiBjdHggJiYgInJlY292ZXJ5X3VybCIgaW4gY3R4LnRlbXBsYXRlX2RhdGEgdGhlbiBjdHgudGVtcGxhdGVfZGF0YS5yZWNvdmVyeV91cmwgZWxzZSBudWxsLAogIHZlcmlmaWNhdGlvbl91cmw6IGlmICJ0ZW1wbGF0ZV9kYXRhIiBpbiBjdHggJiYgInZlcmlmaWNhdGlvbl91cmwiIGluIGN0eC50ZW1wbGF0ZV9kYXRhIHRoZW4gY3R4LnRlbXBsYXRlX2RhdGEudmVyaWZpY2F0aW9uX3VybCBlbHNlIG51bGwsCiAgdmVyaWZpY2F0aW9uX2NvZGU6IGlmICJ0ZW1wbGF0ZV9kYXRhIiBpbiBjdHggJiYgInZlcmlmaWNhdGlvbl9jb2RlIiBpbiBjdHgudGVtcGxhdGVfZGF0YSB0aGVuIGN0eC50ZW1wbGF0ZV9kYXRhLnZlcmlmaWNhdGlvbl9jb2RlIGVsc2UgbnVsbCwKICBzdWJqZWN0OiBjdHguc3ViamVjdCwKICBib2R5OiBjdHguYm9keQp9Cg== headers: content-type: application/json diff --git a/config/openfga/openfga-config.json b/config/openfga/openfga-config.json deleted file mode 100644 index 9b9757a..0000000 --- a/config/openfga/openfga-config.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "log": { - "level": "info" - }, - "playground": { - "enabled": true, - "port": 3000 - }, - "datastore": { - "engine": "postgres", - "uri": "postgres://dev:dev@postgres:5432/backend_db?sslmode=disable&search_path=openfga" - }, - "grpc": { - "addr": ":8081" - }, - "http": { - "addr": ":8080" - } -} diff --git a/k8s/base/app-cnpg.yml b/k8s/base/app-cnpg.yml deleted file mode 100644 index a0588d7..0000000 --- a/k8s/base/app-cnpg.yml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: argoproj.io/v1alpha1 -kind: Application -metadata: - name: cnpg-app - namespace: argocd -spec: - syncPolicy: - automated: - enabled: true - syncOptions: - - ServerSideApply=true - project: default - source: - chart: cloudnative-pg - repoURL: 'https://cloudnative-pg.github.io/charts' - targetRevision: 0.26.1 - destination: - namespace: default - server: 'https://kubernetes.default.svc' \ No newline at end of file diff --git a/k8s/base/app-kratos.yml b/k8s/base/app-kratos.yml index 7053fb4..1e581cf 100644 --- a/k8s/base/app-kratos.yml +++ b/k8s/base/app-kratos.yml @@ -1,3 +1,6 @@ +# ============================== +# Kratos Application +# ============================== apiVersion: argoproj.io/v1alpha1 kind: Application metadata: @@ -15,6 +18,10 @@ spec: targetRevision: 0.58.0 helm: valuesObject: + statefulSet: + extraArgs: + - --config + - /etc/config/custom/kratos.yaml deployment: extraVolumes: - name: kratos-custom-config @@ -30,77 +37,17 @@ spec: secretKeyRef: name: postgres-kratos-app key: uri + extraArgs: + - --config + - /etc/config/custom/kratos.yaml kratos: - # config db + log: + leak_sensitive_values: true automigration: enabled: true - config: - dsn: - valueFrom: - secretKeyRef: - name: postgres-kratos-app - key: uri - selfservice: - methods: - code: - enabled: true - passwordless_enabled: true - lifespan: 2m - password: - enabled: true - default_browser_return_url: "http://localhost:4433" - flows: - login: - ui_url: "http://localhost:5200/auth/login" - registration: - after: - password: - hooks: - - hook: session - code: - hooks: - - hook: session - ui_url: "http://localhost:5200/auth/register" - verification: - ui_url: "http://localhost:5200/auth/verify" - enabled: true - identity: - default_schema_id: default - schemas: - - id: default - url: file:///etc/config/custom/identity.schema.json - courier: - channels: - - id: sms - type: http - request_config: - method: POST - url: https://webhook.site/b9e137a6-f184-47b5-ac43-50c95a95cd14 - body: base64://ZnVuY3Rpb24oY3R4KSB7CiAgcmVjaXBpZW50OiBjdHgucmVjaXBpZW50LAogIHRlbXBsYXRlX3R5cGU6IGN0eC50ZW1wbGF0ZV90eXBlLAogIHRvOiBpZiAidGVtcGxhdGVfZGF0YSIgaW4gY3R4ICYmICJ0byIgaW4gY3R4LnRlbXBsYXRlX2RhdGEgdGhlbiBjdHgudGVtcGxhdGVfZGF0YS50byBlbHNlIG51bGwsCiAgcmVjb3ZlcnlfY29kZTogaWYgInRlbXBsYXRlX2RhdGEiIGluIGN0eCAmJiAicmVjb3ZlcnlfY29kZSIgaW4gY3R4LnRlbXBsYXRlX2RhdGEgdGhlbiBjdHgudGVtcGxhdGVfZGF0YS5yZWNvdmVyeV9jb2RlIGVsc2UgbnVsbCwKICByZWNvdmVyeV91cmw6IGlmICJ0ZW1wbGF0ZV9kYXRhIiBpbiBjdHggJiYgInJlY292ZXJ5X3VybCIgaW4gY3R4LnRlbXBsYXRlX2RhdGEgdGhlbiBjdHgudGVtcGxhdGVfZGF0YS5yZWNvdmVyeV91cmwgZWxzZSBudWxsLAogIHZlcmlmaWNhdGlvbl91cmw6IGlmICJ0ZW1wbGF0ZV9kYXRhIiBpbiBjdHggJiYgInZlcmlmaWNhdGlvbl91cmwiIGluIGN0eC50ZW1wbGF0ZV9kYXRhIHRoZW4gY3R4LnRlbXBsYXRlX2RhdGEudmVyaWZpY2F0aW9uX3VybCBlbHNlIG51bGwsCiAgdmVyaWZpY2F0aW9uX2NvZGU6IGlmICJ0ZW1wbGF0ZV9kYXRhIiBpbiBjdHggJiYgInZlcmlmaWNhdGlvbl9jb2RlIiBpbiBjdHgudGVtcGxhdGVfZGF0YSB0aGVuIGN0eC50ZW1wbGF0ZV9kYXRhLnZlcmlmaWNhdGlvbl9jb2RlIGVsc2UgbnVsbCwKICBzdWJqZWN0OiBjdHguc3ViamVjdCwKICBib2R5OiBjdHguYm9keQp9Cg== - headers: - content-type: application/json - templates: - verification_code: - valid: - sms: - body: - plaintext: "base64://WW91ciB2ZXJpZmljYXRpb24gY29kZSBpczoge3sgLlZlcmlmaWNhdGlvbkNvZGUgfX0=" - email: - body: - plaintext: "base64://WW91ciB2ZXJpZmljYXRpb24gY29kZSBpczoge3sgLlZlcmlmaWNhdGlvbkNvZGUgfX0=" - login_code: - valid: - sms: - body: - plaintext: "base64://WW91ciBsb2dpbiBjb2RlIGlzOiB7eyAuTG9naW5Db2RlIH19" - email: - body: - plaintext: "base64://WW91ciB2ZXJpZmljYXRpb24gY29kZSBpczoge3sgLlZlcmlmaWNhdGlvbkNvZGUgfX0=" - image: repository: shadowlegend/ory-kratos tag: master-arm64 - destination: namespace: default server: 'https://kubernetes.default.svc' \ No newline at end of file diff --git a/k8s/base/kratos.yml b/k8s/base/kratos.yml deleted file mode 100644 index 7edf9e7..0000000 --- a/k8s/base/kratos.yml +++ /dev/null @@ -1,80 +0,0 @@ -# ============================== -# Ory Kratos Deployment + Service -# ============================== ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: kratos -spec: - selector: - matchLabels: - app: kratos - template: - metadata: - labels: - app: kratos - spec: - volumes: - - name: kratos-config - configMap: - name: kratos-config - initContainers: - - name: kratos-migrate - image: shadowlegend/ory-kratos@sha256:1e2a07fa1406c90eb2a16df5da509d163db394262c1a91baf73cc32009dcccd6 - env: - - name: DSN - valueFrom: - secretKeyRef: - name: postgres-kratos-app - key: uri - command: - - sh - - -c - - kratos migrate sql -e --yes - containers: - - name: kratos - image: shadowlegend/ory-kratos@sha256:1e2a07fa1406c90eb2a16df5da509d163db394262c1a91baf73cc32009dcccd6 - env: - - name: DSN - valueFrom: - secretKeyRef: - name: postgres-kratos-app - key: uri - - name: KRATOS_LOG_LEVEL - value: "info" - command: - - kratos - - serve - - --watch-courier - - --config - - /etc/config/kratos.yml - resources: - limits: - memory: "128Mi" - cpu: "500m" - requests: - memory: 64Mi - cpu: 20m - ports: - - containerPort: 4433 - - containerPort: 4434 - volumeMounts: - - name: kratos-config - mountPath: /etc/config - ---- -apiVersion: v1 -kind: Service -metadata: - name: kratos -spec: - selector: - app: kratos - ports: - - name: public - port: 4433 - targetPort: 4433 - - name: admin - port: 4434 - targetPort: 4434 \ No newline at end of file diff --git a/k8s/base/kustomization.yml b/k8s/base/kustomization.yml index 6d0a837..92960fc 100644 --- a/k8s/base/kustomization.yml +++ b/k8s/base/kustomization.yml @@ -3,20 +3,16 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - app-kratos.yml - - app-cnpg.yml - - app-envoy.yml - envoy.yml - # - kratos.yml + # - deployment.yml + - app-kratos.yml - postgres-dotnet.yml - postgres-kratos.yml - - deployment.yml - # - app-khmereid.yml configMapGenerator: - name: kratos-config files: - - kratos.yml=../../config/kratos/kratos.yml + - kratos.yaml=../../config/kratos/kratos.yml - identity.schema.json=../../config/kratos/identity.schema.json generatorOptions: disableNameSuffixHash: true diff --git a/k8s/base/postgres-dotnet.yml b/k8s/base/postgres-dotnet.yml index df7f381..faae188 100644 --- a/k8s/base/postgres-dotnet.yml +++ b/k8s/base/postgres-dotnet.yml @@ -2,7 +2,7 @@ apiVersion: postgresql.cnpg.io/v1 kind: Cluster metadata: name: postgres-dotnet - namespace: argocd + namespace: default annotations: argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true spec: diff --git a/k8s/base/postgres-kratos.yml b/k8s/base/postgres-kratos.yml index 6c73e33..9676ad7 100644 --- a/k8s/base/postgres-kratos.yml +++ b/k8s/base/postgres-kratos.yml @@ -3,7 +3,6 @@ kind: Cluster metadata: name: postgres-kratos namespace: default - # namespace: argocd annotations: argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true spec: diff --git a/k8s/base/app-envoy.yml b/k8s/bootstrap/bootstrap.yml similarity index 51% rename from k8s/base/app-envoy.yml rename to k8s/bootstrap/bootstrap.yml index 685441b..be73693 100644 --- a/k8s/base/app-envoy.yml +++ b/k8s/bootstrap/bootstrap.yml @@ -1,5 +1,26 @@ apiVersion: argoproj.io/v1alpha1 kind: Application +metadata: + name: cnpg-app + namespace: argocd +spec: + syncPolicy: + automated: + enabled: true + syncOptions: + - ServerSideApply=true + project: default + source: + chart: cloudnative-pg + repoURL: 'https://cloudnative-pg.github.io/charts' + targetRevision: 0.26.1 + destination: + namespace: default + server: 'https://kubernetes.default.svc' + +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application metadata: name: envoy-app namespace: argocd @@ -22,6 +43,5 @@ spec: agent: enabled: true destination: - # namespace: default namespace: envoy-gateway-system - server: 'https://kubernetes.default.svc' \ No newline at end of file + server: 'https://kubernetes.default.svc' diff --git a/k8s/bootstrap/kustomization.yml b/k8s/bootstrap/kustomization.yml new file mode 100644 index 0000000..a22ea9b --- /dev/null +++ b/k8s/bootstrap/kustomization.yml @@ -0,0 +1,17 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: argocd + +resources: +- namespaces.yml +- https://raw.githubusercontent.com/argoproj/argo-cd/v3.1.9/manifests/install.yaml + +patches: +- patch: |- + apiVersion: v1 + kind: ConfigMap + metadata: + name: argocd-cm + data: + kustomize.buildOptions: --enable-helm --load-restrictor LoadRestrictionsNone \ No newline at end of file diff --git a/k8s/bootstrap/namespaces.yml b/k8s/bootstrap/namespaces.yml new file mode 100644 index 0000000..96e84ab --- /dev/null +++ b/k8s/bootstrap/namespaces.yml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: argocd \ No newline at end of file diff --git a/k8s/local/kustomization.yml b/k8s/local/kustomization.yml index 1e5b6d3..4e6ffbf 100644 --- a/k8s/local/kustomization.yml +++ b/k8s/local/kustomization.yml @@ -2,16 +2,4 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - ../base - - nginx-temp.yml - -patches: - - target: - kind: Deployment - name: argocd-repo-server - namespace: argocd - patch: |- - - op: add - path: /spec/template/spec/containers/0/env/- - value: - name: ARGOCD_APP_SOURCE_PATH_RESTRICTION - value: repo \ No newline at end of file + - nginx-temp.yml \ No newline at end of file diff --git a/k8s/uat/app.yml b/k8s/uat/app.yml new file mode 100644 index 0000000..178c4b0 --- /dev/null +++ b/k8s/uat/app.yml @@ -0,0 +1,18 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: khmereid-app + namespace: argocd +spec: + syncPolicy: + automated: + prune: true + enabled: true + project: default + source: + repoURL: 'https://gitea.internal.ekycsolutions.com/xadminx/khmereid-backend-temp.git' + path: k8s/uat + targetRevision: main + destination: + namespace: default + server: 'https://kubernetes.default.svc' diff --git a/k8s/uat/kustomization.yml b/k8s/uat/kustomization.yml index 82e7d71..a4a58a6 100644 --- a/k8s/uat/kustomization.yml +++ b/k8s/uat/kustomization.yml @@ -1,4 +1,17 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - ../base \ No newline at end of file + - ../base + +# secretGenerator: +# - name: crime-management-system-deployment-repo +# namespace: argocd +# envs: +# - .env.cms-deploy-repo-cred +# literals: +# - type=git +# - project=default +# - url=https://github.com/EKYCSolutions/crime-management-system.git +# options: +# labels: +# argocd.argoproj.io/secret-type: repository \ No newline at end of file diff --git a/khmer-eID-backend.sln b/khmer-eID-backend.sln index ab5daad..9bcf998 100644 --- a/khmer-eID-backend.sln +++ b/khmer-eID-backend.sln @@ -2,7 +2,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "khmer_eid_backend", "khmer_eid_backend\khmer_eid_backend.csproj", "{6D4F6FF4-86EE-BD7D-7691-D7FD0BC6AFAB}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "khmereid_backend", "khmereid_backend\khmereid_backend.csproj", "{6D4F6FF4-86EE-BD7D-7691-D7FD0BC6AFAB}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/khmer_eid_backend/Controllers/AuthController.cs b/khmer_eid_backend/Controllers/AuthController.cs deleted file mode 100644 index 80c6b35..0000000 --- a/khmer_eid_backend/Controllers/AuthController.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using khmer_eid_backend.Integrations.Ory; -using khmer_eid_backend.Requests; -using Microsoft.AspNetCore.Authorization; - -namespace khmer_eid_backend.Controllers -{ - [ApiController] - [Route("auth")] - public class AuthController(KratosIntegration _kratos) : ControllerBase - { - [HttpPost("request-signup-otp")] - public async Task RequestSignupOtp([FromForm] SignupRequest request) - { - var data = await _kratos.CreateOtpRegistrationFlowAsync(phone: request.Phone); - return Ok(new { Message = "OTP sent if the phone number is valid.", data }); - } - - [HttpPost("verify-signup-otp")] - public async Task VerifySignupOtp([FromForm] string phone,[FromForm] string otp, [FromForm] string flowId) - { - var data = await _kratos.CompleteOtpRegistrationFlowAsync(flowId, phone, otp); - return Ok(new { Message = "OTP verified successfully.", data }); - } - - [HttpPost("request-login-otp")] - public async Task RequestLoginOtp([FromForm] string phone) - { - var data = await _kratos.CreateOtpLoginFlowAsync(phone: phone); - return Ok(new { Message = "OTP sent if the phone number is valid.", data }); - } - - [HttpPost("verify-login-otp")] - public async Task VerifyLoginOtp([FromForm] string phone, [FromForm] string otp, [FromForm] string flowId) - { - var data = await _kratos.CompleteOtpLoginFlowAsync(flowId, phone, otp); - return Ok(new { Message = "OTP verified successfully.", data }); - } - - [HttpPost("logout")] - [Authorize(AuthenticationSchemes = "Kratos")] - public async Task Logout() - { - var data = await _kratos.Logout(Request.Headers.Authorization.ToString().Replace("Bearer ","")); - return Ok(new { Message = "Logged out successfully."}); - } - } -} diff --git a/khmer_eid_backend/Controllers/UserController.cs b/khmer_eid_backend/Controllers/UserController.cs deleted file mode 100644 index 2095bc2..0000000 --- a/khmer_eid_backend/Controllers/UserController.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using khmer_eid_backend.Integrations.Ory; -using Microsoft.AspNetCore.Authorization; -using System.Security.Claims; - -namespace khmer_eid_backend.Controllers; - -public class UserController(KratosIntegration _kratos) : ControllerBase -{ - [HttpGet("me")] - [Authorize(AuthenticationSchemes = "Kratos")] - // public Task Me() - // { - // var id = User.FindFirstValue(ClaimTypes.NameIdentifier); - // var phone = User.FindFirstValue("phone"); - // // return Ok(new { id, phone }); - // return Task.FromResult(Ok(new { id, phone })); - // } - public async Task Me() - { - var data = await _kratos.GetMe(Request.Headers.Authorization.ToString().Replace("Bearer ","")); - return Ok(data); - } -} \ No newline at end of file diff --git a/khmer_eid_backend/Integrations/Ory/KratosHandler.cs b/khmer_eid_backend/Integrations/Ory/KratosHandler.cs deleted file mode 100644 index f5ee4f9..0000000 --- a/khmer_eid_backend/Integrations/Ory/KratosHandler.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Ory.Kratos.Client.Api; -using System.Security.Claims; -using Ory.Kratos.Client.Client; -using System.Text.Encodings.Web; -using Microsoft.Extensions.Options; -using Microsoft.AspNetCore.Authentication; - -namespace khmer_eid_backend.Integrations.Ory; - -public class KratosHandler : AuthenticationHandler -{ - private readonly FrontendApi _frontendApi; - - public KratosHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder, - IConfiguration config) - : base(options, logger, encoder) - { - var cfg = new Configuration { BasePath = config["Ory:Kratos:PublicUrl"]! }; - _frontendApi = new FrontendApi(cfg); - } - - protected override async Task HandleAuthenticateAsync() - { - var authHeader = Request.Headers["Authorization"].ToString(); - if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ")) - return AuthenticateResult.NoResult(); - - var token = authHeader.Substring("Bearer ".Length).Trim(); - - try - { - var session = await _frontendApi.ToSessionAsync(xSessionToken: token); - - var identity = new ClaimsIdentity(new[] - { - new Claim(ClaimTypes.NameIdentifier, session.Identity.Id), - new Claim("phone", session.Identity.Traits.ToString() ?? "") - }, Scheme.Name); - - var principal = new ClaimsPrincipal(identity); - return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to authenticate Kratos session"); - return AuthenticateResult.Fail("Invalid Kratos session token"); - } - } -} diff --git a/khmer_eid_backend/Integrations/Ory/KratosIntegration.cs b/khmer_eid_backend/Integrations/Ory/KratosIntegration.cs deleted file mode 100644 index 27b07ff..0000000 --- a/khmer_eid_backend/Integrations/Ory/KratosIntegration.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System.Text.Json; -using Ory.Kratos.Client.Api; -using Ory.Kratos.Client.Model; -using Ory.Kratos.Client.Client; - -namespace khmer_eid_backend.Integrations.Ory; - -public class KratosIntegration -{ - private readonly FrontendApi _frontendApi; - private readonly IdentityApi _identityApi; - private readonly ILogger _logger; - - public KratosIntegration(IConfiguration config, ILogger logger) - { - var publicCfg = new Configuration { BasePath = config["Ory:Kratos:PublicUrl"]! }; - var adminCfg = new Configuration { BasePath = config["Ory:Kratos:AdminUrl"]! }; - - _frontendApi = new FrontendApi(publicCfg); - _identityApi = new IdentityApi(adminCfg); - _logger = logger; - } - - public async Task CreateOtpRegistrationFlowAsync(string phone) - { - var flow = await _frontendApi.CreateNativeRegistrationFlowAsync(returnSessionTokenExchangeCode: true); - _logger.LogInformation("Started registration flow: {FlowId}", flow.Id); - - phone = phone.Trim(); - var method = new KratosUpdateRegistrationFlowWithCodeMethod( - traits: new { phone = phone }, - method: "code" - ); - - try - { - var body = new KratosUpdateRegistrationFlowBody(method); - _logger.LogInformation("Updating OTP registration flow for {Phone}, flow {FlowId}", phone, flow.Id); - var updatedFlow = _frontendApi.UpdateRegistrationFlow(flow.Id, body); - - return flow; - } - catch (ApiException ex) - { - var res = JsonSerializer.Deserialize(ex.ErrorContent.ToString()!); - return ex.ErrorCode switch - { - 400 => res.GetProperty("state").ToString() == "sent_email" ? - new - { - FlowId = res.GetProperty("id").GetString(), - State = res.GetProperty("state").GetString(), - ExpiredAt = res.GetProperty("expires_at").GetDateTime() - } : throw new Exception("Unhandled Kratos API exception: " + ex.Message), - _ => throw new Exception("Unhandled Kratos API exception: " + ex.Message), - }; - } - } - - public async Task CompleteOtpRegistrationFlowAsync(string flowId, string phone,string otp) - { - phone = phone.Trim(); - var method = new KratosUpdateRegistrationFlowWithCodeMethod( - code: otp, - traits: new { phone = phone}, - method: "code" - ); - var body = new KratosUpdateRegistrationFlowBody(method); - - try - { - var result = await _frontendApi.UpdateRegistrationFlowAsync(flowId, body); - - _logger.LogInformation("Completed OTP registration flow for flow {FlowId}", flowId); - return new - { - accessToken = result.SessionToken, - expiredAt = result.Session?.ExpiresAt - }; - } - catch (ApiException ex) - { - var res = JsonSerializer.Deserialize(ex.ErrorContent.ToString()!); - return ex.ErrorCode switch - { - 400 => res.GetProperty("ui").GetProperty("messages").EnumerateArray().First().GetProperty("text").GetString()!, - 404 => throw new Exception("Registration flow not found."), - _ => throw new Exception("Unhandled Kratos API exception: " + ex.Message), - }; - } - } - - public async Task CreateOtpLoginFlowAsync(string phone) - { - var flow = await _frontendApi.CreateNativeLoginFlowAsync(returnSessionTokenExchangeCode: true); - _logger.LogInformation("Started login flow: {FlowId}", flow.Id); - - phone = phone.Trim(); - var method = new KratosUpdateLoginFlowWithCodeMethod( - method: "code", - csrfToken: "dfdfdfde", - identifier: phone - ); - - try - { - var body = new KratosUpdateLoginFlowBody(method); - _logger.LogInformation("Updating OTP registration flow for {Phone}, flow {FlowId}", phone, flow.Id); - var updatedFlow = await _frontendApi.UpdateLoginFlowAsync(flow.Id, body); - - return flow; - } - catch (ApiException ex) - { - var res = JsonSerializer.Deserialize(ex.ErrorContent.ToString()!); - return ex.ErrorCode switch - { - 400 => res.GetProperty("state").ToString() == "sent_email" ? - new - { - FlowId = res.GetProperty("id").GetString(), - State = res.GetProperty("state").GetString(), - ExpiredAt = res.GetProperty("expires_at").GetDateTime() - } : throw new Exception("Unhandled Kratos API exception: " + ex.Message), - _ => throw new Exception("Unhandled Kratos API exception: " + ex.Message), - }; - } - } - - public async Task CompleteOtpLoginFlowAsync(string flowId, string phone, string otp) - { - phone = "+" + phone.Trim(); - var method = new KratosUpdateLoginFlowWithCodeMethod( - code: otp, - method: "code", - csrfToken: "dfdfdfde", - identifier: phone - ); - var body = new KratosUpdateLoginFlowBody(method); - - try - { - var result = await _frontendApi.UpdateLoginFlowAsync(flowId, body); - - _logger.LogInformation("Completed OTP login flow for flow {FlowId}", flowId); - return new - { - accessToken = result.SessionToken, - expiredAt = result.Session?.ExpiresAt - }; - } - catch (ApiException ex) - { - var res = JsonSerializer.Deserialize(ex.ErrorContent.ToString()!); - return ex.ErrorCode switch - { - 400 => res, - 404 => throw new Exception("Login flow not found."), - _ => throw new Exception("Unhandled Kratos API exception: " + ex.Message), - }; - } - } - - public async Task Logout(string sessionToken) - { - var body = new KratosPerformNativeLogoutBody(sessionToken: sessionToken); - await _frontendApi.PerformNativeLogoutAsync(body); - _logger.LogInformation("Logged out session with token {SessionToken}", sessionToken); - return new { Message = "Logged out successfully." }; - } - - public async Task GetMe(string sessionToken) - { - var session = await _frontendApi.ToSessionAsync(xSessionToken: sessionToken); - _logger.LogInformation("Fetched session for identity {IdentityId}", session.Identity.Id); - return session; - } - - public async Task PasswordRegistrationFlowAsync(string flowId, string phone) - { - var method = new KratosUpdateRegistrationFlowWithPasswordMethod( - password: "add3ae4d8ae8", - traits: new { phone = phone, identifier = phone }, - method: "password" - ); - var body = new KratosUpdateRegistrationFlowBody(method); - var result = await _frontendApi.UpdateRegistrationFlowAsync(flowId, body); - - Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); - _logger.LogInformation("Completed registration flow for {Phone}", phone); - return result; - } -} diff --git a/khmer_eid_backend/Integrations/Ory/OryAuthSchemeOptions.cs b/khmer_eid_backend/Integrations/Ory/OryAuthSchemeOptions.cs deleted file mode 100644 index e722ac1..0000000 --- a/khmer_eid_backend/Integrations/Ory/OryAuthSchemeOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -//todo obsolete -using Microsoft.AspNetCore.Authentication; - -namespace khmer_eid_backend.Integrations.Ory; - -public sealed class OryAuthSchemeOptions : AuthenticationSchemeOptions -{ - public string? BasePath { get; set; } -} \ No newline at end of file diff --git a/khmer_eid_backend/Middlewares/KratosAuthMiddleware.cs b/khmer_eid_backend/Middlewares/KratosAuthMiddleware.cs deleted file mode 100644 index 68189b4..0000000 --- a/khmer_eid_backend/Middlewares/KratosAuthMiddleware.cs +++ /dev/null @@ -1,27 +0,0 @@ -// // validates token & attaches user info - -// public class KratosAuthMiddleware -// { -// private readonly RequestDelegate _next; - -// public KratosAuthMiddleware(RequestDelegate next) => _next = next; - -// public async Task InvokeAsync(HttpContext context, KratosIntegration kratos) -// { -// var cookie = context.Request.Headers["Cookie"].ToString(); -// var bearer = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", ""); - -// var session = await kratos.ValidateSessionAsync(cookie, bearer); - -// if (session == null) -// { -// context.Response.StatusCode = StatusCodes.Status401Unauthorized; -// await context.Response.WriteAsync("Unauthorized"); -// return; -// } - -// // attach identity to HttpContext -// context.Items["user"] = session.Identity; -// await _next(context); -// } -// } diff --git a/khmer_eid_backend/Models/Kratos.cs b/khmer_eid_backend/Models/Kratos.cs deleted file mode 100644 index 944d7cb..0000000 --- a/khmer_eid_backend/Models/Kratos.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Data models that map Kratos JSON responses -using System.ComponentModel.DataAnnotations; - -namespace khmer_eid_backend.Models -{ - // Represents user traits stored in Kratos - public class KratosTraits - { - public string Phone { get; set; } = ""; - } - - // Represents an identity in Kratos - public class KratosIdentity - { - public string Id { get; set; } = ""; - public KratosTraits Traits { get; set; } = new(); - } - - // Represents a session returned by Kratos - public class KratosSession - { - public string Id { get; set; } = ""; - public KratosIdentity Identity { get; set; } = new(); - } -} \ No newline at end of file diff --git a/khmer_eid_backend/Models/User.cs b/khmer_eid_backend/Models/User.cs deleted file mode 100644 index 730806f..0000000 --- a/khmer_eid_backend/Models/User.cs +++ /dev/null @@ -1,7 +0,0 @@ -public class UserProfile -{ - public int Id { get; set; } - public string KratosId { get; set; } = ""; // link to Kratos user - public string FullName { get; set; } = ""; - public string NationalId { get; set; } = ""; -} diff --git a/khmer_eid_backend/Program.cs b/khmer_eid_backend/Program.cs deleted file mode 100644 index dbe319c..0000000 --- a/khmer_eid_backend/Program.cs +++ /dev/null @@ -1,19 +0,0 @@ -using khmer_eid_backend.Integrations.Ory; -using Microsoft.AspNetCore.Authentication; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddSingleton(); - -builder.Services.AddAuthentication("Kratos") - .AddScheme("Kratos", _ => { }); - -builder.Services.AddAuthorization(); - -builder.Services.AddControllers(); - -var app = builder.Build(); -app.UseAuthentication(); -app.UseAuthorization(); -app.MapControllers(); -app.Run(); \ No newline at end of file diff --git a/khmer_eid_backend/Resources/SignupResource.cs b/khmer_eid_backend/Resources/SignupResource.cs deleted file mode 100644 index ff0b10a..0000000 --- a/khmer_eid_backend/Resources/SignupResource.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace khmer_eid_backend.Resources; - -public class SignupResource -{ - public string Phone { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/khmer_eid_backend/Services/AuthService.cs b/khmer_eid_backend/Services/AuthService.cs deleted file mode 100644 index e69de29..0000000 diff --git a/khmer_eid_backend/appsettings.Development.json b/khmer_eid_backend/appsettings.Development.json deleted file mode 100644 index 2885aba..0000000 --- a/khmer_eid_backend/appsettings.Development.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Debug", - "Microsoft.AspNetCore": "Warning" - } - }, - "AppSettings": { - "ApplicationName": "Khmer eID Backend - Development" - }, - "Ory": { - "Kratos": { - "PublicUrl": "http://localhost:4433", - "AdminUrl": "http://localhost:4434" - } - }, - "Consul": { - "Address": "http://consul:8500" - }, - "Grpc": { - "GDIOnline": { - "Address": "https://gdi.dev.ekycsolutions.com" - } - }, - "Kyc": { - "BaseUrl": "https://uat.ekycsolutions.com", - "EKYC_SOLUTION_PRIVATE_KEY": "d4e5840f352d448a49f296e2344cffd406535969e5b7af84b8cb867dc6ab8258" - } -} diff --git a/khmer_eid_backend/khmer_eid_backend.csproj b/khmer_eid_backend/khmer_eid_backend.csproj deleted file mode 100644 index e63d416..0000000 --- a/khmer_eid_backend/khmer_eid_backend.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - net9.0 - enable - enable - - - - - - - - - - - \ No newline at end of file diff --git a/khmer_eid_backend/khmer_eid_backend.http b/khmer_eid_backend/khmer_eid_backend.http deleted file mode 100644 index af9f7fa..0000000 --- a/khmer_eid_backend/khmer_eid_backend.http +++ /dev/null @@ -1,6 +0,0 @@ -@khmer_eid_backend_HostAddress = http://localhost:5103 - -GET {{khmer_eid_backend_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/khmereid_backend/Controllers/AuthController.cs b/khmereid_backend/Controllers/AuthController.cs new file mode 100644 index 0000000..e4ff2c0 --- /dev/null +++ b/khmereid_backend/Controllers/AuthController.cs @@ -0,0 +1,62 @@ +using khmereid_backend.Dtos; +using Microsoft.AspNetCore.Mvc; +using khmereid_backend.Services; +using khmereid_backend.Extensions; +using Microsoft.AspNetCore.Authorization; + +namespace khmereid_backend.Controllers; + +[ApiController] +[Route("auth")] +public class AuthController(AuthService _authService) : ControllerBase +{ + [AllowAnonymous] + [HttpPost("request-signup-otp")] + public async Task RequestSignupOtp([FromForm] SignupRequest request) + { + var response = await _authService.StartRegistrationAsync(request.Phone); + + return this.ToActionResult(response); + } + + [AllowAnonymous] + [HttpPost("verify-signup-otp")] + public async Task VerifySignupOtp([FromForm] VerifyOtpRequest request) + { + var response = await _authService.CompleteRegistrationAsync(request.FlowId, request.Phone, request.Otp); + + return this.ToActionResult(response); + } + + [AllowAnonymous] + [HttpPost("request-login-otp")] + public async Task RequestLoginOtp([FromForm] LoginRequest request) + { + var response = await _authService.StartLoginAsync(request.Phone); + return this.ToActionResult(response); + } + + [AllowAnonymous] + [HttpPost("verify-login-otp")] + public async Task VerifyLoginOtp([FromForm] VerifyOtpRequest request) + { + var response = await _authService.CompleteLoginAsync(request.FlowId, request.Phone, request.Otp); + + return this.ToActionResult(response); + } + + [HttpPost("logout")] + public async Task Logout() + { + var token = Request.Headers.Authorization.ToString().Replace("Bearer ", ""); + var response = await _authService.LogoutAsync(token); + return this.ToActionResult(response); + } + + [AllowAnonymous] + [HttpGet("options")] + public IActionResult GetAuthOptions() + { + return Ok(ApiResponse.Ok(null, "Not implemented.")); + } +} \ No newline at end of file diff --git a/khmereid_backend/Controllers/UserController.cs b/khmereid_backend/Controllers/UserController.cs new file mode 100644 index 0000000..3cf3ee8 --- /dev/null +++ b/khmereid_backend/Controllers/UserController.cs @@ -0,0 +1,17 @@ +using khmereid_backend.Dtos; +using Microsoft.AspNetCore.Mvc; +using khmereid_backend.Services; +using khmereid_backend.Extensions; + +namespace khmereid_backend.Controllers; + +public class UserController(AuthService _authService) : ControllerBase +{ + [HttpGet("me")] + public IActionResult Me() + { + var user = HttpContext.GetUser(); + + return this.ToActionResult(ApiResponse.Ok(user)); + } +} \ No newline at end of file diff --git a/khmereid_backend/Data/AppDbContext.cs b/khmereid_backend/Data/AppDbContext.cs new file mode 100644 index 0000000..8112f02 --- /dev/null +++ b/khmereid_backend/Data/AppDbContext.cs @@ -0,0 +1,10 @@ +using khmereid_backend.Models; +using Microsoft.EntityFrameworkCore; + +namespace khmereid_backend.Data +{ + public class AppDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Users { get; set; } + } +} \ No newline at end of file diff --git a/khmereid_backend/Dtos/ApiResponse.cs b/khmereid_backend/Dtos/ApiResponse.cs new file mode 100644 index 0000000..cf1a588 --- /dev/null +++ b/khmereid_backend/Dtos/ApiResponse.cs @@ -0,0 +1,29 @@ +namespace khmereid_backend.Dtos; +public class ApiResponse +{ + public bool Success { get; set; } = true; + public string? Message { get; set; } + public string? Code { get; set; } + public T? Data { get; set; } + public string? Error { get; set; } + + // public static ApiResponse Ok(T? data = default, string? message = null) + // => new() { Success = true, Data = data, Message = message}; + + // public static ApiResponse Fail(string error, string? message = null, string? code = null) + // => new() { Success = false, Error = error, Message = message, Code = code }; + + public static ApiResponse Ok(string message) + => new() { Success = true, Message = message }; + public static ApiResponse Ok(T? data = default, string? message = null) + => new() { Success = true, Data = data, Message = message }; + + public static ApiResponse Fail(string error) + => new() { Success = false, Error = error }; + + public static ApiResponse Fail(string error, string code) + => new() { Success = false, Error = error, Code = code }; + + public static ApiResponse Fail(string error, string? message, string? code = null) + => new() { Success = false, Error = error, Message = message, Code = code }; +} \ No newline at end of file diff --git a/khmereid_backend/Dtos/FlowDto.cs b/khmereid_backend/Dtos/FlowDto.cs new file mode 100644 index 0000000..39eddf0 --- /dev/null +++ b/khmereid_backend/Dtos/FlowDto.cs @@ -0,0 +1,10 @@ +namespace khmereid_backend.Dtos; +public class FlowDto +{ + public string FlowId { get; set; } = null!; + public string State { get; set; } = null!; + public DateTimeOffset? ExpiresAt { get; set; } + public string? Error { get; set; } + public string? Code { get; set; } + public string? Message { get; set; } +} \ No newline at end of file diff --git a/khmereid_backend/Dtos/LoginRequest.cs b/khmereid_backend/Dtos/LoginRequest.cs new file mode 100644 index 0000000..cebe65d --- /dev/null +++ b/khmereid_backend/Dtos/LoginRequest.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace khmereid_backend.Dtos; + +public class LoginRequest +{ + [Required(ErrorMessage = "Phone number is required")] + [Phone(ErrorMessage = "Invalid phone number format")] + public string Phone { get; set; } = string.Empty; +} diff --git a/khmereid_backend/Dtos/LoginResultDto.cs b/khmereid_backend/Dtos/LoginResultDto.cs new file mode 100644 index 0000000..e5ca157 --- /dev/null +++ b/khmereid_backend/Dtos/LoginResultDto.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +namespace khmereid_backend.Dtos; +public class LoginResultDto +{ + // [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public Guid? IdentityId { get; set; } + public string? AccessToken { get; set; } + public DateTime? ExpiredAt { get; set; } + public string? Error { get; set; } + public string? Code { get; set; } + public string? Message { get; set; } + public string? UseFlowId { get; set; } +} \ No newline at end of file diff --git a/khmereid_backend/Dtos/SessionDto.cs b/khmereid_backend/Dtos/SessionDto.cs new file mode 100644 index 0000000..cfa358d --- /dev/null +++ b/khmereid_backend/Dtos/SessionDto.cs @@ -0,0 +1,11 @@ +namespace khmereid_backend.Dtos; + +public class SessionDto +{ + public string Id { get; set; } = null!; + public string? Phone { get; set; } + public string? Error { get; set; } + // public string? Code { get; set; } + public string? Message { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/khmer_eid_backend/Requests/SignupRequest.cs b/khmereid_backend/Dtos/SignupRequest.cs similarity index 57% rename from khmer_eid_backend/Requests/SignupRequest.cs rename to khmereid_backend/Dtos/SignupRequest.cs index 7700d61..328e813 100644 --- a/khmer_eid_backend/Requests/SignupRequest.cs +++ b/khmereid_backend/Dtos/SignupRequest.cs @@ -1,10 +1,10 @@ -namespace khmer_eid_backend.Requests; - using System.ComponentModel.DataAnnotations; +namespace khmereid_backend.Dtos; + public class SignupRequest { - [Required] + [Required(ErrorMessage = "Phone number is required")] [RegularExpression(@"^\+855\d{8,9}$", ErrorMessage = "Phone number must be in the format +855XXXXXXXX or +855XXXXXXXXX")] - public string Phone { get; set; } = default!; -} \ No newline at end of file + public string Phone { get; set; } = string.Empty; +} diff --git a/khmereid_backend/Dtos/SignupResultDto.cs b/khmereid_backend/Dtos/SignupResultDto.cs new file mode 100644 index 0000000..43a3d9a --- /dev/null +++ b/khmereid_backend/Dtos/SignupResultDto.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +namespace khmereid_backend.Dtos; +public class SignupResultDto +{ + // [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public Guid? IdentityId { get; set; } + public string? AccessToken { get; set; } + public DateTime? ExpiredAt { get; set; } + public string? Error { get; set; } + public string? Code { get; set; } + public string? Message { get; set; } + public string? UseFlowId { get; set; } +} \ No newline at end of file diff --git a/khmereid_backend/Dtos/UserDto.cs b/khmereid_backend/Dtos/UserDto.cs new file mode 100644 index 0000000..d97f967 --- /dev/null +++ b/khmereid_backend/Dtos/UserDto.cs @@ -0,0 +1,10 @@ +namespace khmereid_backend.Dtos +{ + public class UserDto + { + public Guid Id { get; set; } + public Guid IdentityId { get; set; } = Guid.Empty; + public string Phone { get; set; } = default!; + public string NationalId { get; set; } = null!; + } +} diff --git a/khmereid_backend/Dtos/VerifyOtpRequest.cs b/khmereid_backend/Dtos/VerifyOtpRequest.cs new file mode 100644 index 0000000..6b24241 --- /dev/null +++ b/khmereid_backend/Dtos/VerifyOtpRequest.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace khmereid_backend.Dtos; + +public class VerifyOtpRequest +{ + [Required] public string Phone { get; set; } = string.Empty; + [Required] public string Otp { get; set; } = string.Empty; + [Required] public string FlowId { get; set; } = string.Empty; +} diff --git a/khmereid_backend/Extensions/ControllerExtensions.cs b/khmereid_backend/Extensions/ControllerExtensions.cs new file mode 100644 index 0000000..d433a8d --- /dev/null +++ b/khmereid_backend/Extensions/ControllerExtensions.cs @@ -0,0 +1,20 @@ +using khmereid_backend.Dtos; +using Microsoft.AspNetCore.Mvc; + +namespace khmereid_backend.Extensions; +public static class ControllerExtensions +{ + public static IActionResult ToActionResult(this ControllerBase controller, ApiResponse response) + { + if (response == null) + return controller.StatusCode(500, new { success = false, error = "Internal error: response is null" }); + + return response.Success switch + { + true => controller.Ok(response), + false when response.Code?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true => + controller.NotFound(response), + false => controller.BadRequest(response) + }; + } +} diff --git a/khmereid_backend/Extensions/HttpContextExtensions.cs b/khmereid_backend/Extensions/HttpContextExtensions.cs new file mode 100644 index 0000000..7eb6b27 --- /dev/null +++ b/khmereid_backend/Extensions/HttpContextExtensions.cs @@ -0,0 +1,10 @@ +using khmereid_backend.Dtos; + +namespace khmereid_backend.Extensions +{ + public static class HttpContextExtensions + { + public static UserDto? GetUser(this HttpContext context) + => context.Items["UserDto"] as UserDto; + } +} diff --git a/khmereid_backend/Integrations/IAuthnApi.cs b/khmereid_backend/Integrations/IAuthnApi.cs new file mode 100644 index 0000000..42ff8a3 --- /dev/null +++ b/khmereid_backend/Integrations/IAuthnApi.cs @@ -0,0 +1,13 @@ +using khmereid_backend.Dtos; + +namespace khmereid_backend.Integrations; + +public interface IAuthnApi +{ + public Task Logout(string sessionToken); + public Task GetMeAsync(string sessionToken); + public Task CreateOtpLoginFlowAsync(string phone); + public Task CreateOtpRegistrationFlowAsync(string phone); + public Task CompleteOtpLoginFlowAsync(string flowId, string phone, string otp); + public Task CompleteOtpRegistrationFlowAsync(string flowId, string phone, string otp); +} \ No newline at end of file diff --git a/khmereid_backend/Integrations/KratosApi.cs b/khmereid_backend/Integrations/KratosApi.cs new file mode 100644 index 0000000..64c23d7 --- /dev/null +++ b/khmereid_backend/Integrations/KratosApi.cs @@ -0,0 +1,260 @@ +using System.Text.Json; +using Ory.Kratos.Client.Api; +using khmereid_backend.Dtos; +using Ory.Kratos.Client.Model; +using Ory.Kratos.Client.Client; + +namespace khmereid_backend.Integrations; + +public class KratosApi : IAuthnApi +{ + private readonly FrontendApi _frontendApi; + private readonly IdentityApi _identityApi; + private readonly ILogger _logger; + + public KratosApi(IConfiguration config, ILogger logger) + { + var publicCfg = new Configuration { BasePath = config["Ory:Kratos:PublicUrl"]! }; + var adminCfg = new Configuration { BasePath = config["Ory:Kratos:AdminUrl"]! }; + + _frontendApi = new FrontendApi(publicCfg); + _identityApi = new IdentityApi(adminCfg); + _logger = logger; + } + + // --------------------------- Registration Flows --------------------------- + public async Task CreateOtpRegistrationFlowAsync(string phone) + { + var flow = await _frontendApi.CreateNativeRegistrationFlowAsync(returnSessionTokenExchangeCode: true); + _logger.LogInformation("Started registration flow: {FlowId}", flow.Id); + + phone = phone.Trim(); + var method = new KratosUpdateRegistrationFlowWithCodeMethod(method: "code", traits: new { phone }); + var body = new KratosUpdateRegistrationFlowBody(method); + + try + { + var updatedFlow = await _frontendApi.UpdateRegistrationFlowAsync(flow.Id, body); + return new FlowDto{}; + } + catch (ApiException ex) + { + return HandleFlowException(ex); + } + } + + public async Task CompleteOtpRegistrationFlowAsync(string otp, string phone, string flowId) + { + phone = phone.Trim(); + var method = new KratosUpdateRegistrationFlowWithCodeMethod(code: otp, method: "code", traits: new { phone }); + var body = new KratosUpdateRegistrationFlowBody(method); + + try + { + var result = await _frontendApi.UpdateRegistrationFlowAsync(flowId, body); + _logger.LogInformation("Completed OTP registration flow for flow {FlowId}", flowId); + + return new SignupResultDto + { + IdentityId = Guid.Parse(result.Identity.Id), + AccessToken = result.SessionToken, + ExpiredAt = result.Session?.ExpiresAt + }; + } + catch (ApiException ex) + { + return HandleRegistrationException(ex); + } + } + + // --------------------------- Login Flows --------------------------- + public async Task CreateOtpLoginFlowAsync(string phone) + { + var flow = await _frontendApi.CreateNativeLoginFlowAsync(returnSessionTokenExchangeCode: true); + _logger.LogInformation("Started login flow: {FlowId}", flow.Id); + + phone = phone.Trim(); + var method = new KratosUpdateLoginFlowWithCodeMethod(method: "code", csrfToken: "dfdfdfde", identifier: phone); + var body = new KratosUpdateLoginFlowBody(method); + + try + { + var updatedFlow = await _frontendApi.UpdateLoginFlowAsync(flow.Id, body); + return new FlowDto{}; + } + catch (ApiException ex) + { + return HandleFlowException(ex); + } + } + + public async Task CompleteOtpLoginFlowAsync(string flowId, string phone, string otp) + { + phone = "+" + phone.Trim(); + var method = new KratosUpdateLoginFlowWithCodeMethod(code: otp, method: "code", csrfToken: "dfdfdfde", identifier: phone); + var body = new KratosUpdateLoginFlowBody(method); + + try + { + var result = await _frontendApi.UpdateLoginFlowAsync(flowId, body); + _logger.LogInformation("Completed OTP login flow for flow {FlowId}", flowId); + + return new LoginResultDto + { + AccessToken = result.SessionToken, + ExpiredAt = result.Session?.ExpiresAt + }; + } + catch (ApiException ex) + { + return HandleLoginException(ex); + } + } + + // --------------------------- Session & Logout --------------------------- + public async Task GetMeAsync(string sessionToken) + { + try + { + var session = await _frontendApi.ToSessionAsync(xSessionToken: sessionToken); + return new SessionDto + { + Id = session.Identity.Id, + ExpiresAt = session.ExpiresAt + }; + } + catch (ApiException ex) when (ex.ErrorCode == 401 || ex.ErrorCode == 403) + { + throw new UnauthorizedAccessException("Invalid or expired Kratos session token", ex); + } + catch (ApiException ex) + { + throw new InvalidOperationException($"Failed to fetch session from Kratos: {ex.Message}", ex); + } + } + + public async Task Logout(string sessionToken) + { + var body = new KratosPerformNativeLogoutBody(sessionToken: sessionToken); + await _frontendApi.PerformNativeLogoutAsync(body); + _logger.LogInformation("Logged out session with token {SessionToken}", sessionToken); + return new { Message = "Logged out successfully." }; + } + + // --------------------------- Password Flow --------------------------- + public async Task PasswordRegistrationFlowAsync(string flowId, string phone) + { + var method = new KratosUpdateRegistrationFlowWithPasswordMethod( + password: "add3ae4d8ae8", + traits: new { phone, identifier = phone }, + method: "password" + ); + + var body = new KratosUpdateRegistrationFlowBody(method); + var result = await _frontendApi.UpdateRegistrationFlowAsync(flowId, body); + + _logger.LogInformation("Completed password registration flow for {Phone}", phone); + return result; + } + + // --------------------------- Private Helpers --------------------------- + private static FlowDto HandleFlowException(ApiException ex) + { + var res = JsonSerializer.Deserialize(ex.ErrorContent?.ToString() ?? "{}"); + + if (ex.ErrorCode == 400 && res.TryGetProperty("state", out var stateProp) && stateProp.GetString() == "sent_email") + { + return new FlowDto + { + FlowId = res.GetProperty("id").GetString() ?? "", + State = "sent_otp", + ExpiresAt = res.TryGetProperty("expires_at", out var expiresAtProp) ? expiresAtProp.GetDateTime() : (DateTimeOffset?)null + }; + } + + if (ex.ErrorCode == 400) + return new FlowDto + { + Error = "BadRequest", + Code = ex.ErrorCode.ToString(), + Message = GetKratosErrorMessage(res) + }; + + throw new InvalidOperationException($"Kratos API returned {ex.ErrorCode}: {ex.Message}", ex); + } + + private SignupResultDto HandleRegistrationException(ApiException ex) + { + var res = JsonSerializer.Deserialize(ex.ErrorContent?.ToString() ?? "{}"); + + if (ex.ErrorCode == 410 && res.TryGetProperty("use_flow_id", out var useFlowProp)) + { + _logger.LogWarning("Registration flow expired. Use new flow: {FlowId}", useFlowProp.GetString()); + return new SignupResultDto + { + Error = "FlowExpired", + Code = ex.ErrorCode.ToString(), + Message = "Registration flow expired. Please restart registration.", + UseFlowId = useFlowProp.GetString() + }; + } + + if (ex.ErrorCode == 400) + return new SignupResultDto + { + Code = ex.ErrorCode.ToString(), + Error = "BadRequest", + Message = GetKratosErrorMessage(res) + }; + + if (ex.ErrorCode == 404) + return new SignupResultDto + { + Code = ex.ErrorCode.ToString(), + Error = "NotFound", + Message = "Registration flow not found." + }; + + return new SignupResultDto + { + Code = ex.ErrorCode.ToString(), + Error = "UnhandledException", + Message = $"Unhandled Kratos API exception: {ex.Message}" + }; + } + + private static LoginResultDto HandleLoginException(ApiException ex) + { + var res = JsonSerializer.Deserialize(ex.ErrorContent?.ToString() ?? "{}"); + + if (ex.ErrorCode == 404) + return new LoginResultDto + { + Error = "NotFound", + Code = ex.ErrorCode.ToString(), + Message = "Login flow not found." + }; + + return new LoginResultDto + { + Error = "BadRequest", + Code = ex.ErrorCode.ToString(), + Message = GetKratosErrorMessage(res) + }; + } + + private static string GetKratosErrorMessage(JsonElement res) + { + if (res.TryGetProperty("error", out var errProp) && errProp.TryGetProperty("reason", out var reasonProp)) + return reasonProp.GetString() ?? "Bad request"; + + if (res.TryGetProperty("ui", out var uiProp) && + uiProp.TryGetProperty("messages", out var messagesProp) && + messagesProp.ValueKind == JsonValueKind.Array && + messagesProp.GetArrayLength() > 0 && + messagesProp[0].TryGetProperty("text", out var textProp)) + return textProp.GetString() ?? "Bad request"; + + return "Bad request"; + } +} diff --git a/khmereid_backend/Integrations/MockAuthnApi.cs b/khmereid_backend/Integrations/MockAuthnApi.cs new file mode 100644 index 0000000..6fd5500 --- /dev/null +++ b/khmereid_backend/Integrations/MockAuthnApi.cs @@ -0,0 +1,102 @@ +using khmereid_backend.Dtos; +using Microsoft.Extensions.Logging; + +namespace khmereid_backend.Integrations; + +public class MockAuthnApi : IAuthnApi +{ + private readonly ILogger _logger; + + private static readonly Guid StaticIdentityId = Guid.Parse("c9ac4dab-cb86-4e5b-8a59-8ecf6a5479a2"); + private const string StaticOtp = "000000"; + private const string StaticToken = "ory_st_gCk1btBYn0RUCdLV31N60jZlzLgcAoS8"; + + public MockAuthnApi(ILogger logger) + { + _logger = logger; + } + + // --------------------------- Registration Flows --------------------------- + public Task CreateOtpRegistrationFlowAsync(string phone) + { + _logger.LogInformation("[Mock] Created registration flow for {Phone}, OTP={Otp}", phone, StaticOtp); + + return Task.FromResult(new FlowDto + { + FlowId = "mock_flow_id", + State = "sent_otp", + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) + }); + } + + public Task CompleteOtpRegistrationFlowAsync(string otp, string phone, string flowId) + { + if (otp != StaticOtp) + { + return Task.FromResult(new SignupResultDto + { + Error = "BadRequest", + Message = "Invalid OTP." + }); + } + + _logger.LogInformation("[Mock] Completed registration for {Phone}, IdentityId={IdentityId}", phone, StaticIdentityId); + + return Task.FromResult(new SignupResultDto + { + IdentityId = StaticIdentityId, + AccessToken = StaticToken, + ExpiredAt = DateTimeOffset.UtcNow.AddHours(1).UtcDateTime + }); + } + + // --------------------------- Login Flows --------------------------- + public Task CreateOtpLoginFlowAsync(string phone) + { + _logger.LogInformation("[Mock] Created login flow for {Phone}, OTP={Otp}", phone, StaticOtp); + + return Task.FromResult(new FlowDto + { + FlowId = "mock_login_flow_id", + State = "sent_otp", + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) + }); + } + + public Task CompleteOtpLoginFlowAsync(string flowId, string phone, string otp) + { + if (otp != StaticOtp) + { + return Task.FromResult(new LoginResultDto + { + Error = "BadRequest", + Message = "Invalid OTP." + }); + } + + _logger.LogInformation("[Mock] Completed login for {Phone}, IdentityId={IdentityId}", phone, StaticIdentityId); + + return Task.FromResult(new LoginResultDto + { + IdentityId = StaticIdentityId, + AccessToken = StaticToken, + ExpiredAt = DateTimeOffset.UtcNow.AddHours(1).UtcDateTime + }); + } + + // --------------------------- Session & Logout --------------------------- + public Task GetMeAsync(string sessionToken) + { + return Task.FromResult(new SessionDto + { + Id = StaticIdentityId.ToString(), + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }); + } + + public Task Logout(string sessionToken) + { + _logger.LogInformation("[Mock] Logged out session {Token}", sessionToken); + return Task.FromResult(new { Message = "Logged out successfully." }); + } +} diff --git a/khmereid_backend/Middlewares/KratosMiddleware.cs b/khmereid_backend/Middlewares/KratosMiddleware.cs new file mode 100644 index 0000000..9a92e99 --- /dev/null +++ b/khmereid_backend/Middlewares/KratosMiddleware.cs @@ -0,0 +1,73 @@ +using khmereid_backend.Data; +using khmereid_backend.Dtos; +using System.Security.Claims; +using System.Text.Encodings.Web; +using khmereid_backend.Services; +using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Authentication; +using System.Text.Json; +using Ory.Kratos.Client.Client; + +namespace khmereid_backend.Integrations; + +public class KratosMiddleware : AuthenticationHandler +{ + private readonly AppDbContext _db; + private readonly AuthService _authService; + + public KratosMiddleware( + AppDbContext db, + UrlEncoder encoder, + ILoggerFactory logger, + AuthService authService, + IOptionsMonitor options + ) + : base(options, logger, encoder) + { + _db = db; + _authService = authService; + } + + protected override async Task HandleAuthenticateAsync() + { + var authHeader = Request.Headers["Authorization"].ToString(); + if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ")) + return AuthenticateResult.NoResult(); + + var token = authHeader.Substring("Bearer ".Length).Trim(); + + try + { + var session = await _authService.GetMeAsync(token); + Console.WriteLine("---------------"); + Console.WriteLine(JsonSerializer.Serialize(session, new JsonSerializerOptions { WriteIndented = true })); + + var identityId = Guid.Parse(session.Data!.Id); + var localUser = _db.Users.FirstOrDefault(u => u.IdentityId == identityId); + + if (localUser == null) + return AuthenticateResult.Fail("User not found in local database"); + + var userDto = new UserDto + { + Id = localUser.Id, + Phone = localUser.Phone, + IdentityId = localUser.IdentityId + }; + Context.Items["UserDto"] = userDto; + + var identity = new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, userDto.Id.ToString())], + Scheme.Name + ); + var principal = new ClaimsPrincipal(identity); + + return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to authenticate Kratos session"); + return AuthenticateResult.Fail("Invalid Kratos session token"); + } + } +} diff --git a/khmereid_backend/Migrations/20251117033748_init.Designer.cs b/khmereid_backend/Migrations/20251117033748_init.Designer.cs new file mode 100644 index 0000000..d9b6252 --- /dev/null +++ b/khmereid_backend/Migrations/20251117033748_init.Designer.cs @@ -0,0 +1,69 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using khmereid_backend.Data; + +#nullable disable + +namespace khmereid_backend.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251117033748_init")] + partial class init + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("khmereid_backend.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IdentityId") + .HasColumnType("uuid") + .HasColumnName("identity_id"); + + b.Property("NationalId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("national_id"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("phone"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("NationalId") + .IsUnique() + .HasDatabaseName("ix_users_national_id"); + + b.HasIndex("Phone") + .IsUnique() + .HasDatabaseName("ix_users_phone"); + + b.ToTable("users", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/khmereid_backend/Migrations/20251117033748_init.cs b/khmereid_backend/Migrations/20251117033748_init.cs new file mode 100644 index 0000000..55bb2dd --- /dev/null +++ b/khmereid_backend/Migrations/20251117033748_init.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace khmereid_backend.Migrations +{ + /// + public partial class init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + identity_id = table.Column(type: "uuid", nullable: false), + phone = table.Column(type: "text", nullable: false), + national_id = table.Column(type: "text", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_users", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_users_national_id", + table: "users", + column: "national_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_users_phone", + table: "users", + column: "phone", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "users"); + } + } +} diff --git a/khmereid_backend/Migrations/AppDbContextModelSnapshot.cs b/khmereid_backend/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..dcab9c5 --- /dev/null +++ b/khmereid_backend/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,66 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using khmereid_backend.Data; + +#nullable disable + +namespace khmereid_backend.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("khmereid_backend.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IdentityId") + .HasColumnType("uuid") + .HasColumnName("identity_id"); + + b.Property("NationalId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("national_id"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("phone"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("NationalId") + .IsUnique() + .HasDatabaseName("ix_users_national_id"); + + b.HasIndex("Phone") + .IsUnique() + .HasDatabaseName("ix_users_phone"); + + b.ToTable("users", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/khmereid_backend/Models/User.cs b/khmereid_backend/Models/User.cs new file mode 100644 index 0000000..339c79a --- /dev/null +++ b/khmereid_backend/Models/User.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; + +namespace khmereid_backend.Models; + +[Index(nameof(Phone), IsUnique = true)] +[Index(nameof(NationalId), IsUnique = true)] +public class User +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public Guid IdentityId { get; set; } = default!; // Identity id from the provider + + [Required] + public string Phone { get; set; } = default!; + public string NationalId { get; set; } = ""; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/khmereid_backend/Program.cs b/khmereid_backend/Program.cs new file mode 100644 index 0000000..5c79cd1 --- /dev/null +++ b/khmereid_backend/Program.cs @@ -0,0 +1,68 @@ +using DotNetEnv; +using khmereid_backend.Data; +using khmereid_backend.Services; +using Microsoft.EntityFrameworkCore; +using khmereid_backend.Integrations; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authentication; + +var builder = WebApplication.CreateBuilder(args); +if (builder.Environment.IsDevelopment() && File.Exists("../.env")) +{ + Env.Load("../.env"); +} + +builder.Configuration + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: false) + .AddEnvironmentVariables(); + +var dbHost = builder.Configuration["DB_HOST"]; +var dbPort = builder.Configuration["DB_PORT"]; +var dbUser = builder.Configuration["DB_USER"]; +var dbPass = builder.Configuration["DB_PASS"]; +var dbName = builder.Configuration["DB_NAME"]; +var dbSsl = builder.Configuration["DB_SSL_MODE"] ?? "Disable"; + +var connectionString = $"Host={dbHost};Port={dbPort};Username={dbUser};Password={dbPass};Database={dbName};Ssl Mode={dbSsl}"; + +builder.Services.AddDbContext(options => +{ + options.UseNpgsql(connectionString).UseSnakeCaseNamingConvention(); +}); + +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = "Kratos"; + options.DefaultChallengeScheme = "Kratos"; + options.DefaultScheme = "Kratos"; +}).AddScheme("Kratos", _ => { }); + +builder.Services.AddAuthorizationBuilder() + .SetFallbackPolicy(new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build()); + +builder.Services.AddControllers().AddJsonOptions(options => +{ + options.JsonSerializerOptions.DefaultIgnoreCondition = + System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingDefault; +}); + +var app = builder.Build(); + +await using var scope = app.Services.CreateAsyncScope(); +var db = scope.ServiceProvider.GetRequiredService(); +var canConnect = await db.Database.CanConnectAsync(); +app.Logger.LogInformation("Can connect to database: {CanConnect}", canConnect); + +app.MapGet("/health", () => Results.Ok("I'm good.")).AllowAnonymous().WithOpenApi(); + +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); diff --git a/khmer_eid_backend/Properties/launchSettings.json b/khmereid_backend/Properties/launchSettings.json similarity index 100% rename from khmer_eid_backend/Properties/launchSettings.json rename to khmereid_backend/Properties/launchSettings.json diff --git a/khmereid_backend/Services/AuthService.cs b/khmereid_backend/Services/AuthService.cs new file mode 100644 index 0000000..d29b200 --- /dev/null +++ b/khmereid_backend/Services/AuthService.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using khmereid_backend.Dtos; +using khmereid_backend.Integrations; + +namespace khmereid_backend.Services; + +public class AuthService( + IAuthnApi authnApi, + ILogger logger, + UserService userService) +{ + private readonly UserService _userService = userService; + private readonly ILogger _logger = logger; + private readonly IAuthnApi _authnApi = authnApi; + + public async Task> StartRegistrationAsync(string phone) + { + if (await _userService.IsUserExistsAsync(phone)) + { + _logger.LogWarning("User {Phone} already exists", phone); + return ApiResponse.Fail("User already exists."); + } + + _logger.LogInformation("Starting OTP registration for phone {Phone}", phone); + var result = await _authnApi.CreateOtpRegistrationFlowAsync(phone); + + return ApiResponse.Ok(result, "OTP sent if the phone number is valid."); + } + + public async Task> CompleteRegistrationAsync(string flowId, string phone, string otp) + { + var data = await _authnApi.CompleteOtpRegistrationFlowAsync(otp, phone, flowId); + + if (data.Error != null) + return ApiResponse.Fail(data.Message ?? "Registration failed.", null, data.Code); + + var user = await _userService.CreateUserAsync(phone, (Guid)data.IdentityId!); + return ApiResponse.Ok(data, "Registration completed successfully."); + } + + public async Task> StartLoginAsync(string phone) + { + _logger.LogInformation("Starting OTP login for phone {Phone}", phone); + var data = await _authnApi.CreateOtpLoginFlowAsync(phone); + + if (data.Error != null) + return ApiResponse.Fail(data.Message ?? "Request failed.", null, data.Code); + return ApiResponse.Ok(data, "Request completed successfully."); + } + + public async Task> CompleteLoginAsync(string flowId, string phone, string otp) + { + _logger.LogInformation("Completing OTP login for phone {Phone}, Flow: {FlowId}", phone, flowId); + + var data = await _authnApi.CompleteOtpLoginFlowAsync(flowId, phone, otp); + + if (data.Error != null) + return ApiResponse.Fail(data.Message ?? "Login failed.", null, data.Code); + return ApiResponse.Ok(data, "Login completed successfully."); + } + + public async Task> LogoutAsync(string sessionToken) + { + _logger.LogInformation("Logging out session token {Token}", sessionToken); + await _authnApi.Logout(sessionToken); + return ApiResponse.Ok("Logout completed successfully."); + } + + public async Task> GetMeAsync(string sessionToken) + { + try + { + _logger.LogInformation("Fetching current session for token {Token}", sessionToken); + var response = await _authnApi.GetMeAsync(sessionToken); + + if (response.Error != null) + return ApiResponse.Fail(response.Message ?? "Failed."); + return ApiResponse.Ok(response, "Session returned."); + } + catch (UnauthorizedAccessException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch session from Kratos"); + throw; + } + } +} \ No newline at end of file diff --git a/khmereid_backend/Services/UserService.cs b/khmereid_backend/Services/UserService.cs new file mode 100644 index 0000000..e070654 --- /dev/null +++ b/khmereid_backend/Services/UserService.cs @@ -0,0 +1,31 @@ +using khmereid_backend.Data; +using khmereid_backend.Models; +using Microsoft.EntityFrameworkCore; + +namespace khmereid_backend.Services +{ + public class UserService(AppDbContext context) + { + private readonly AppDbContext _context = context; + + public async Task IsUserExistsAsync(string phone) + { + phone = phone.Trim(); + return await _context.Users.AnyAsync(u => u.Phone == phone); + } + + public async Task CreateUserAsync(string phone, Guid identityId) + { + var user = new User + { + Phone = phone.Trim(), + IdentityId = identityId + }; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + return user; + } + } +} \ No newline at end of file diff --git a/khmereid_backend/appsettings.Development.json b/khmereid_backend/appsettings.Development.json new file mode 100644 index 0000000..5b7c09a --- /dev/null +++ b/khmereid_backend/appsettings.Development.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Ory": { + "Kratos": { + "PublicUrl": "http://localhost:4433", + "AdminUrl": "http://localhost:4434" + } + }, + "Kyc": { + "EKYC_Solutions": { + "BaseUrl": "", + "PrivateKey": "" + } + } +} diff --git a/khmer_eid_backend/appsettings.json b/khmereid_backend/appsettings.json similarity index 54% rename from khmer_eid_backend/appsettings.json rename to khmereid_backend/appsettings.json index faff88a..4d56694 100644 --- a/khmer_eid_backend/appsettings.json +++ b/khmereid_backend/appsettings.json @@ -5,8 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*", - "AppSettings": { - "ApplicationName": "Khmer eID Backend" - } + "AllowedHosts": "*" } diff --git a/khmereid_backend/khmereid_backend.csproj b/khmereid_backend/khmereid_backend.csproj new file mode 100644 index 0000000..001cee6 --- /dev/null +++ b/khmereid_backend/khmereid_backend.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + khmereid_backend + enable + enable + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + \ No newline at end of file diff --git a/khmereid_backend/test.http b/khmereid_backend/test.http new file mode 100644 index 0000000..58d12cc --- /dev/null +++ b/khmereid_backend/test.http @@ -0,0 +1,5 @@ +@baseUrl = http://localhost:5200 + +GET {{baseUrl}}/health +Accept: application/json +### diff --git a/skaffold.yaml b/skaffold.yaml index c6f69f9..b9fb48b 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -14,14 +14,16 @@ manifests: - k8s/local/ buildArgs: - --load-restrictor=LoadRestrictionsNone +deploy: + statusCheck: true portForward: - # - resourceType: service - # resourceName: envoy-default-my-gateway-1c7c06f0 - # namespace: envoy-gateway-system - # port: 80 - # localPort: 8888 - resourceType: service - resourceName: kratos + resourceName: kratos-app-public namespace: default - port: 4433 - localPort: 4433 \ No newline at end of file + port: 80 + localPort: 4433 + - resourceType: service + resourceName: postgres-dotnet-rw + namespace: default + port: 5432 + localPort: 5600 \ No newline at end of file