This commit is contained in:
Ichen Chhoeng 2025-11-17 11:25:33 +07:00
parent cc3a2c0e52
commit ff0e97e36b
64 changed files with 1313 additions and 737 deletions

View File

@ -1 +1,9 @@
DATABASE_USERNAME=
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=

View File

@ -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"]

View File

@ -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

View File

@ -1,22 +1,42 @@
### In order to add a new package, run the following commands
1. dotnet add package <PackageName>
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
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
```

View File

@ -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

View File

@ -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"
}
}

View File

@ -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'

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -3,7 +3,6 @@ kind: Cluster
metadata:
name: postgres-kratos
namespace: default
# namespace: argocd
annotations:
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
spec:

View File

@ -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'
server: 'https://kubernetes.default.svc'

View File

@ -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

View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: argocd

View File

@ -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
- nginx-temp.yml

18
k8s/uat/app.yml Normal file
View File

@ -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'

View File

@ -1,4 +1,17 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../base
- ../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

View File

@ -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

View File

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Logout()
{
var data = await _kratos.Logout(Request.Headers.Authorization.ToString().Replace("Bearer ",""));
return Ok(new { Message = "Logged out successfully."});
}
}
}

View File

@ -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<IActionResult> Me()
// {
// var id = User.FindFirstValue(ClaimTypes.NameIdentifier);
// var phone = User.FindFirstValue("phone");
// // return Ok(new { id, phone });
// return Task.FromResult<IActionResult>(Ok(new { id, phone }));
// }
public async Task<IActionResult> Me()
{
var data = await _kratos.GetMe(Request.Headers.Authorization.ToString().Replace("Bearer ",""));
return Ok(data);
}
}

View File

@ -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<AuthenticationSchemeOptions>
{
private readonly FrontendApi _frontendApi;
public KratosHandler(
IOptionsMonitor<AuthenticationSchemeOptions> 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<AuthenticateResult> 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");
}
}
}

View File

@ -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<KratosIntegration> _logger;
public KratosIntegration(IConfiguration config, ILogger<KratosIntegration> 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<dynamic> 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<JsonElement>(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<dynamic> 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<JsonElement>(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<dynamic> 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<JsonElement>(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<dynamic> 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<JsonElement>(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<dynamic> 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<dynamic> 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<KratosSuccessfulNativeRegistration> 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;
}
}

View File

@ -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; }
}

View File

@ -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);
// }
// }

View File

@ -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();
}
}

View File

@ -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; } = "";
}

View File

@ -1,19 +0,0 @@
using khmer_eid_backend.Integrations.Ory;
using Microsoft.AspNetCore.Authentication;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<KratosIntegration>();
builder.Services.AddAuthentication("Kratos")
.AddScheme<AuthenticationSchemeOptions, KratosHandler>("Kratos", _ => { });
builder.Services.AddAuthorization();
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@ -1,6 +0,0 @@
namespace khmer_eid_backend.Resources;
public class SignupResource
{
public string Phone { get; set; } = string.Empty;
}

View File

@ -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"
}
}

View File

@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Consul" Version="1.7.14.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.9" />
<PackageReference Include="Ory.Kratos.Client" Version="1.3.8" />
<Protobuf Include="Protos\gdi_online\user.proto" GrpcServices="Client" />
</ItemGroup>
</Project>

View File

@ -1,6 +0,0 @@
@khmer_eid_backend_HostAddress = http://localhost:5103
GET {{khmer_eid_backend_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -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<IActionResult> RequestSignupOtp([FromForm] SignupRequest request)
{
var response = await _authService.StartRegistrationAsync(request.Phone);
return this.ToActionResult(response);
}
[AllowAnonymous]
[HttpPost("verify-signup-otp")]
public async Task<IActionResult> 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<IActionResult> RequestLoginOtp([FromForm] LoginRequest request)
{
var response = await _authService.StartLoginAsync(request.Phone);
return this.ToActionResult(response);
}
[AllowAnonymous]
[HttpPost("verify-login-otp")]
public async Task<IActionResult> VerifyLoginOtp([FromForm] VerifyOtpRequest request)
{
var response = await _authService.CompleteLoginAsync(request.FlowId, request.Phone, request.Otp);
return this.ToActionResult(response);
}
[HttpPost("logout")]
public async Task<IActionResult> 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<string>.Ok(null, "Not implemented."));
}
}

View File

@ -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<UserDto>.Ok(user));
}
}

View File

@ -0,0 +1,10 @@
using khmereid_backend.Models;
using Microsoft.EntityFrameworkCore;
namespace khmereid_backend.Data
{
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<User> Users { get; set; }
}
}

View File

@ -0,0 +1,29 @@
namespace khmereid_backend.Dtos;
public class ApiResponse<T>
{
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<T> Ok(T? data = default, string? message = null)
// => new() { Success = true, Data = data, Message = message};
// public static ApiResponse<T> Fail(string error, string? message = null, string? code = null)
// => new() { Success = false, Error = error, Message = message, Code = code };
public static ApiResponse<T> Ok(string message)
=> new() { Success = true, Message = message };
public static ApiResponse<T> Ok(T? data = default, string? message = null)
=> new() { Success = true, Data = data, Message = message };
public static ApiResponse<T> Fail(string error)
=> new() { Success = false, Error = error };
public static ApiResponse<T> Fail(string error, string code)
=> new() { Success = false, Error = error, Code = code };
public static ApiResponse<T> Fail(string error, string? message, string? code = null)
=> new() { Success = false, Error = error, Message = message, Code = code };
}

View File

@ -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; }
}

View File

@ -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;
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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!;
}
public string Phone { get; set; } = string.Empty;
}

View File

@ -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; }
}

View File

@ -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!;
}
}

View File

@ -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;
}

View File

@ -0,0 +1,20 @@
using khmereid_backend.Dtos;
using Microsoft.AspNetCore.Mvc;
namespace khmereid_backend.Extensions;
public static class ControllerExtensions
{
public static IActionResult ToActionResult<T>(this ControllerBase controller, ApiResponse<T> 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)
};
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,13 @@
using khmereid_backend.Dtos;
namespace khmereid_backend.Integrations;
public interface IAuthnApi
{
public Task<dynamic> Logout(string sessionToken);
public Task<SessionDto> GetMeAsync(string sessionToken);
public Task<FlowDto> CreateOtpLoginFlowAsync(string phone);
public Task<FlowDto> CreateOtpRegistrationFlowAsync(string phone);
public Task<LoginResultDto> CompleteOtpLoginFlowAsync(string flowId, string phone, string otp);
public Task<SignupResultDto> CompleteOtpRegistrationFlowAsync(string flowId, string phone, string otp);
}

View File

@ -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<KratosApi> _logger;
public KratosApi(IConfiguration config, ILogger<KratosApi> 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<FlowDto> 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<SignupResultDto> 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<FlowDto> 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<LoginResultDto> 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<SessionDto> 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<dynamic> 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<dynamic> 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<JsonElement>(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<JsonElement>(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<JsonElement>(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";
}
}

View File

@ -0,0 +1,102 @@
using khmereid_backend.Dtos;
using Microsoft.Extensions.Logging;
namespace khmereid_backend.Integrations;
public class MockAuthnApi : IAuthnApi
{
private readonly ILogger<MockAuthnApi> _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<MockAuthnApi> logger)
{
_logger = logger;
}
// --------------------------- Registration Flows ---------------------------
public Task<FlowDto> 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<SignupResultDto> 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<FlowDto> 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<LoginResultDto> 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<SessionDto> GetMeAsync(string sessionToken)
{
return Task.FromResult(new SessionDto
{
Id = StaticIdentityId.ToString(),
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
});
}
public Task<dynamic> Logout(string sessionToken)
{
_logger.LogInformation("[Mock] Logged out session {Token}", sessionToken);
return Task.FromResult<dynamic>(new { Message = "Logged out successfully." });
}
}

View File

@ -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<AuthenticationSchemeOptions>
{
private readonly AppDbContext _db;
private readonly AuthService _authService;
public KratosMiddleware(
AppDbContext db,
UrlEncoder encoder,
ILoggerFactory logger,
AuthService authService,
IOptionsMonitor<AuthenticationSchemeOptions> options
)
: base(options, logger, encoder)
{
_db = db;
_authService = authService;
}
protected override async Task<AuthenticateResult> 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");
}
}
}

View File

@ -0,0 +1,69 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("IdentityId")
.HasColumnType("uuid")
.HasColumnName("identity_id");
b.Property<string>("NationalId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("national_id");
b.Property<string>("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
}
}
}

View File

@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace khmereid_backend.Migrations
{
/// <inheritdoc />
public partial class init : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "users",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
identity_id = table.Column<Guid>(type: "uuid", nullable: false),
phone = table.Column<string>(type: "text", nullable: false),
national_id = table.Column<string>(type: "text", nullable: false),
created_at = table.Column<DateTime>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "users");
}
}
}

View File

@ -0,0 +1,66 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("IdentityId")
.HasColumnType("uuid")
.HasColumnName("identity_id");
b.Property<string>("NationalId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("national_id");
b.Property<string>("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
}
}
}

View File

@ -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;
}

View File

@ -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<AppDbContext>(options =>
{
options.UseNpgsql(connectionString).UseSnakeCaseNamingConvention();
});
builder.Services.AddSingleton<IAuthnApi, KratosApi>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "Kratos";
options.DefaultChallengeScheme = "Kratos";
options.DefaultScheme = "Kratos";
}).AddScheme<AuthenticationSchemeOptions, KratosMiddleware>("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<AppDbContext>();
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();

View File

@ -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<AuthService> logger,
UserService userService)
{
private readonly UserService _userService = userService;
private readonly ILogger<AuthService> _logger = logger;
private readonly IAuthnApi _authnApi = authnApi;
public async Task<ApiResponse<object>> StartRegistrationAsync(string phone)
{
if (await _userService.IsUserExistsAsync(phone))
{
_logger.LogWarning("User {Phone} already exists", phone);
return ApiResponse<object>.Fail("User already exists.");
}
_logger.LogInformation("Starting OTP registration for phone {Phone}", phone);
var result = await _authnApi.CreateOtpRegistrationFlowAsync(phone);
return ApiResponse<object>.Ok(result, "OTP sent if the phone number is valid.");
}
public async Task<ApiResponse<SignupResultDto>> CompleteRegistrationAsync(string flowId, string phone, string otp)
{
var data = await _authnApi.CompleteOtpRegistrationFlowAsync(otp, phone, flowId);
if (data.Error != null)
return ApiResponse<SignupResultDto>.Fail(data.Message ?? "Registration failed.", null, data.Code);
var user = await _userService.CreateUserAsync(phone, (Guid)data.IdentityId!);
return ApiResponse<SignupResultDto>.Ok(data, "Registration completed successfully.");
}
public async Task<ApiResponse<FlowDto>> StartLoginAsync(string phone)
{
_logger.LogInformation("Starting OTP login for phone {Phone}", phone);
var data = await _authnApi.CreateOtpLoginFlowAsync(phone);
if (data.Error != null)
return ApiResponse<FlowDto>.Fail(data.Message ?? "Request failed.", null, data.Code);
return ApiResponse<FlowDto>.Ok(data, "Request completed successfully.");
}
public async Task<ApiResponse<LoginResultDto>> 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<LoginResultDto>.Fail(data.Message ?? "Login failed.", null, data.Code);
return ApiResponse<LoginResultDto>.Ok(data, "Login completed successfully.");
}
public async Task<ApiResponse<string>> LogoutAsync(string sessionToken)
{
_logger.LogInformation("Logging out session token {Token}", sessionToken);
await _authnApi.Logout(sessionToken);
return ApiResponse<string>.Ok("Logout completed successfully.");
}
public async Task<ApiResponse<SessionDto>> 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<SessionDto>.Fail(response.Message ?? "Failed.");
return ApiResponse<SessionDto>.Ok(response, "Session returned.");
}
catch (UnauthorizedAccessException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch session from Kratos");
throw;
}
}
}

View File

@ -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<bool> IsUserExistsAsync(string phone)
{
phone = phone.Trim();
return await _context.Users.AnyAsync(u => u.Phone == phone);
}
public async Task<User> CreateUserAsync(string phone, Guid identityId)
{
var user = new User
{
Phone = phone.Trim(),
IdentityId = identityId
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
return user;
}
}
}

View File

@ -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": ""
}
}
}

View File

@ -5,8 +5,5 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"AppSettings": {
"ApplicationName": "Khmer eID Backend"
}
"AllowedHosts": "*"
}

View File

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>khmereid_backend</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Consul" Version="1.7.14.9" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Ory.Kratos.Client" Version="1.3.8" />
<Protobuf Include="Protos\gdi_online\user.proto" GrpcServices="Client" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,5 @@
@baseUrl = http://localhost:5200
GET {{baseUrl}}/health
Accept: application/json
###

View File

@ -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
port: 80
localPort: 4433
- resourceType: service
resourceName: postgres-dotnet-rw
namespace: default
port: 5432
localPort: 5600