forked from baron/baron-sso
Resolve merge conflicts with main
This commit is contained in:
59
.env.sample
59
.env.sample
@@ -6,6 +6,10 @@
|
||||
APP_ENV=stage # 애플리케이션 실행 환경 (dev, stage, production)
|
||||
TZ=Asia/Seoul
|
||||
|
||||
|
||||
# IDP_PROVIDER는 우선순위 순으로 콤마 구분 (예: Kratos/Hydra 우선, Descope 백업)
|
||||
IDP_PROVIDER=ory
|
||||
|
||||
# --- Infrastructure Ports ---
|
||||
DB_PORT=5432
|
||||
CLICKHOUSE_PORT_HTTP=8123
|
||||
@@ -25,11 +29,15 @@ DB_NAME=baron_sso
|
||||
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
|
||||
JWT_SECRET=super-secret-key-must-be-32-bytes!
|
||||
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
|
||||
|
||||
# Audit System Configuration
|
||||
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
|
||||
AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기
|
||||
|
||||
# Descope Project ID (Required for Auth)
|
||||
DESCOPE_PROJECT_ID=P2t...your_descope_project_id
|
||||
DESCOPE_MANAGEMENT_KEY=your_descope_management_key_here
|
||||
DESCOPE_TEST_ACCOUNT=dyddus1210@gmail.com # 테스트 자동화용 계정(loginId). 없으면 생성 후 비밀번호 변경 시나리오 실행
|
||||
DESCOPE_TEST_ACCOUNT=tester@baroncs.co.kr
|
||||
|
||||
# --- Naver Cloud Services ---
|
||||
@@ -49,19 +57,19 @@ ADMIN_EMAIL=admin@baron.co.kr
|
||||
ADMIN_PASSWORD=adminPasswordIsNotSimple
|
||||
|
||||
# --- URLs for Proxy/Handoff ---
|
||||
USERFRONT_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용)
|
||||
BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소
|
||||
|
||||
# IDP_PROVIDER는 우선순위 순으로 콤마 구분 (예: Kratos/Hydra 우선, Descope 백업)
|
||||
IDP_PROVIDER=ory,descope
|
||||
# Project Public Base URL (Served by UserFront Nginx)
|
||||
USERFRONT_URL=https://sso.hmac.kr
|
||||
|
||||
# Services proxied via Nginx
|
||||
BACKEND_URL=${USERFRONT_URL}/api
|
||||
OATHKEEPER_PUBLIC_URL=${USERFRONT_URL}
|
||||
|
||||
# ory-stack 변수들
|
||||
ORY_POSTGRES_TAG=17-trixie
|
||||
ORY_POSTGRES_USER=ory
|
||||
ORY_POSTGRES_PASSWORD=EuBV5ywvXFehkggHQrnYo5727MseEi6i9
|
||||
ORY_POSTGRES_DB=ory
|
||||
ORY_POSTGRES_PORT=5433
|
||||
# ORY_POSTGRES_PORT=5433 # Internal only
|
||||
|
||||
KRATOS_DB=ory_kratos
|
||||
HYDRA_DB=ory_hydra
|
||||
@@ -69,33 +77,50 @@ KETO_DB=ory_keto
|
||||
|
||||
# Ory Kratos Configuration
|
||||
KRATOS_VERSION=v25.4.0-distroless
|
||||
KRATOS_PUBLIC_PORT=4433
|
||||
KRATOS_ADMINFRONT_PORT=4434
|
||||
# KRATOS_PUBLIC_PORT=4433 # Internal only
|
||||
# KRATOS_ADMINFRONT_PORT=4434 # Internal only
|
||||
|
||||
KRATOS_UI_NODE_VERSION=v25.4.0
|
||||
KRATOS_UI_PORT=4455
|
||||
# KRATOS_UI_PORT=4455 # Internal only
|
||||
|
||||
# Ory Hydra Configuration
|
||||
HYDRA_VERSION=v25.4.0-distroless
|
||||
HYDRA_PUBLIC_PORT=4441
|
||||
HYDRA_ADMINFRONT_PORT=4445
|
||||
# HYDRA_PUBLIC_PORT=4441 # Internal only
|
||||
# HYDRA_ADMINFRONT_PORT=4445 # Internal only
|
||||
|
||||
# Ory Keto Configuration
|
||||
KETO_VERSION=v25.4.0-distroless
|
||||
KETO_READ_PORT=4466
|
||||
KETO_WRITE_PORT=4467
|
||||
# KETO_READ_PORT=4466 # Internal only
|
||||
# KETO_WRITE_PORT=4467 # Internal only
|
||||
|
||||
# Kratos Selfservice UI upstreams (override for deployments)
|
||||
ORY_SDK_URL=http://kratos:4433
|
||||
KRATOS_PUBLIC_URL=http://kratos:4433
|
||||
KRATOS_ADMIN_URL=http://kratos:4434
|
||||
# 브라우저가 접근할 Kratos Public/UI 외부 URL (리버스 프록시/도메인 환경 고려)
|
||||
KRATOS_BROWSER_URL=http://localhost:4433
|
||||
|
||||
# 브라우저가 접근할 Kratos Public/UI 외부 URL
|
||||
# Oathkeeper가 /auth 경로를 Kratos Public API로 라우팅합니다.
|
||||
KRATOS_BROWSER_URL=${OATHKEEPER_PUBLIC_URL}/auth
|
||||
# Kratos UI는 별도 서브도메인이 없으면 UserFront가 렌더링하거나 /kratos-ui 등으로 라우팅 필요
|
||||
# 현재는 예시로 로컬 포트 유지 (프로덕션에선 UserFront에 통합됨)
|
||||
KRATOS_UI_URL=http://localhost:4455
|
||||
|
||||
HYDRA_ADMIN_URL=http://hydra:4445
|
||||
HYDRA_PUBLIC_URL=http://hydra:4444
|
||||
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
|
||||
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
|
||||
|
||||
# Oathkeeper JWKS (내부 통신용)
|
||||
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
|
||||
|
||||
# Oathkeeper 실행 사용자/프로브 설정
|
||||
OATHKEEPER_VERSION=v25.4.0
|
||||
OATHKEEPER_UID=1001
|
||||
OATHKEEPER_GID=1001
|
||||
OATHKEEPER_HEALTH_URL=http://oathkeeper:4456/health/ready
|
||||
OATHKEEPER_HEALTH_INTERVAL_SECONDS=10
|
||||
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=2
|
||||
OATHKEEPER_HEALTH_ENABLED=true
|
||||
|
||||
# Kratos Selfservice UI required secrets (local only)
|
||||
COOKIE_SECRET=localcookie123
|
||||
CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
|
||||
|
||||
107
Makefile
107
Makefile
@@ -1,4 +1,4 @@
|
||||
# Makefile for Ory Stack
|
||||
# Baron SSO용 Docker Compose 헬퍼
|
||||
|
||||
# 환경 변수 로드
|
||||
ifneq (,$(wildcard ./.env))
|
||||
@@ -6,62 +6,81 @@ ifneq (,$(wildcard ./.env))
|
||||
export
|
||||
endif
|
||||
|
||||
# --- 기본 실행 (All Apps) ---
|
||||
# DB 상태 체크 후 모든 App 서비스 실행
|
||||
up: check-db
|
||||
@echo "Starting ALL Ory services (Profile: app)..."
|
||||
docker compose --profile app up -d
|
||||
# Compose 파일 경로
|
||||
COMPOSE_INFRA := compose.infra.yaml
|
||||
COMPOSE_ORY := compose.ory.yaml
|
||||
COMPOSE_APP := docker-compose.yaml
|
||||
|
||||
# --- 개별 서비스 실행 ---
|
||||
# Kratos만 실행
|
||||
up-kratos: check-db
|
||||
@echo "Starting Ory Kratos..."
|
||||
docker compose --profile kratos up -d
|
||||
# --- 기본 실행 ---
|
||||
# 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음)
|
||||
up-all:
|
||||
@echo "Starting ALL stacks (infra + ory + app)..."
|
||||
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d
|
||||
|
||||
# Hydra만 실행
|
||||
up-hydra: check-db
|
||||
@echo "Starting Ory Hydra..."
|
||||
docker compose --profile hydra up -d
|
||||
|
||||
# Keto만 실행
|
||||
up-keto: check-db
|
||||
@echo "Starting Ory Keto..."
|
||||
docker compose --profile keto up -d
|
||||
|
||||
# --- 인프라 (DB) 실행 ---
|
||||
# PostgreSQL 실행
|
||||
# --- 개별 스택 실행 ---
|
||||
up-infra:
|
||||
@echo "Starting Infrastructure (PostgreSQL)..."
|
||||
docker compose --profile infra up -d
|
||||
@echo "Starting Infra stack (postgres/clickhouse/redis)..."
|
||||
docker compose -f $(COMPOSE_INFRA) up -d
|
||||
|
||||
up-ory:
|
||||
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
|
||||
docker compose -f $(COMPOSE_ORY) up -d
|
||||
|
||||
up-app:
|
||||
@echo "Starting App stack (backend/userfront/adminfront/devfront)..."
|
||||
docker compose -f $(COMPOSE_APP) up -d
|
||||
|
||||
up-backend:
|
||||
@echo "Starting Backend only..."
|
||||
docker compose -f $(COMPOSE_APP) up -d backend
|
||||
|
||||
up-dev: up-infra up-ory
|
||||
@echo "Dev stack is up (infra + ory)."
|
||||
|
||||
up-front-dev: up-infra up-ory up-backend
|
||||
@echo "Dev stack is up (infra + ory + backend)."
|
||||
|
||||
# --- 종료 (Down) ---
|
||||
# 모든 서비스 및 인프라 종료
|
||||
down:
|
||||
@echo "Stopping ALL services (Infra + App)..."
|
||||
docker compose --profile infra --profile app down
|
||||
down-all:
|
||||
@echo "Stopping ALL stacks (infra + ory + app)..."
|
||||
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down
|
||||
|
||||
# App 서비스만 종료 (DB는 유지)
|
||||
down-app:
|
||||
@echo "Stopping App services..."
|
||||
docker compose --profile app down
|
||||
@echo "Stopping App stack..."
|
||||
docker compose -f $(COMPOSE_APP) down
|
||||
|
||||
down-backend:
|
||||
@echo "Stopping Backend only..."
|
||||
docker compose -f $(COMPOSE_APP) stop backend
|
||||
|
||||
# 인프라만 종료 (주의: App 서비스 에러 가능성 있음)
|
||||
down-infra:
|
||||
@echo "Stopping Infrastructure..."
|
||||
docker compose --profile infra down
|
||||
@echo "Stopping Infra stack..."
|
||||
docker compose -f $(COMPOSE_INFRA) down
|
||||
|
||||
down-ory:
|
||||
@echo "Stopping Ory stack..."
|
||||
docker compose -f $(COMPOSE_ORY) down
|
||||
|
||||
# --- 유틸리티 ---
|
||||
# DB 상태 확인 로직
|
||||
check-db:
|
||||
@echo "Checking database status..."
|
||||
@if [ "$$(docker inspect -f '{{.State.Health.Status}}' ory-postgres 2>/dev/null)" != "healthy" ]; then \
|
||||
echo "Error: Database is not running or not healthy."; \
|
||||
# 인프라 상태 확인
|
||||
check-infra:
|
||||
@echo "Checking infra status..."
|
||||
@if [ "$$(docker inspect -f '{{.State.Health.Status}}' baron_postgres 2>/dev/null)" != "healthy" ]; then \
|
||||
echo "Error: PostgreSQL is not running or not healthy."; \
|
||||
echo "Please run 'make up-infra' first."; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo "Database is healthy."; \
|
||||
echo "PostgreSQL is healthy."; \
|
||||
fi
|
||||
|
||||
# 로그 확인
|
||||
logs:
|
||||
docker compose -f compose.ory.yaml logs -f
|
||||
ps:
|
||||
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) ps
|
||||
|
||||
logs-infra:
|
||||
docker compose -f $(COMPOSE_INFRA) logs -f
|
||||
|
||||
logs-ory:
|
||||
docker compose -f $(COMPOSE_ORY) logs -f
|
||||
|
||||
logs-app:
|
||||
docker compose -f $(COMPOSE_APP) logs -f
|
||||
|
||||
136
README.md
136
README.md
@@ -1,17 +1,48 @@
|
||||
# Baron SSO
|
||||
|
||||
**Baron SSO**는 화이트 라벨링된 사용자 인증 허브이자 통합 런처입니다.
|
||||
**Descope**를 활용하여 안전한 비밀번호 없는 인증(Enchanted Link)을 제공하며, Flutter로 구현된 커스텀 UI를 통해 매끄러운 사용자 경험을 보장합니다. Backend는 Go (Fiber)와 ClickHouse를 사용하여 대용량 감사 로그(Audit Log)를 관리합니다.
|
||||
**Baron 통합로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
|
||||
|
||||
* Ory Stack으로 모든 구성요소를 self-hosting 합니다.
|
||||
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
|
||||
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
|
||||
* UserFront: Flutter로 구현된 커스텀 UI를 통해 매끄러운 사용자 경험을 보장합니다.
|
||||
* 로그인 : 비밀번호, SMS, QR 스캔 등의 수단으로 로그인 가능
|
||||
* 향후 모바일 앱 지원으로 인증 Push 승인 등 MFA 확장 (예정)
|
||||
* 정보변경, 앱 연동 이력 확인 등 개인화 기능
|
||||
* 사용 가능한 앱 리스트 (예정)
|
||||
* AdminFront: 사용자 관리 등 Admin 기능
|
||||
* DevFront: RP 관리 등 개발자 기능
|
||||
|
||||
## 🏗 아키텍처 (Architecture)
|
||||
|
||||
### 0. Ory Stack
|
||||
- Ory Kratos: 사용자 인증/계정 관리(Identity).
|
||||
- Kratos Selfservice UI: Kratos 셀프서비스 플로우 UI.
|
||||
- Ory Hydra: OAuth2/OIDC 발급 및 토큰 관리.
|
||||
- Ory Keto: 권한/정책 기반 접근 제어.
|
||||
- Oathkeeper: 인증/인가 프록시 및 라우팅 게이트웨이.
|
||||
|
||||
```mermaid
|
||||
flowchart
|
||||
subgraph Edge
|
||||
OK["Oathkeeper<br/>(Only Public Entry)"]
|
||||
end
|
||||
|
||||
subgraph App
|
||||
BE["Backend<br/>(Only Upstream)"]
|
||||
end
|
||||
|
||||
subgraph OryStack
|
||||
KR[Kratos]
|
||||
HY[Hydra]
|
||||
KE[Keto]
|
||||
KR --- HY --- KE
|
||||
end
|
||||
BE -->|Command| OryStack
|
||||
OK -->|Query| KR
|
||||
OK -->|Query| HY
|
||||
OK -->|Query| KE
|
||||
```
|
||||
|
||||
### 1. Backend (Go Fiber)
|
||||
- **Language**: Go 1.25+
|
||||
- **Framework**: Fiber v2.25+
|
||||
@@ -23,7 +54,7 @@
|
||||
- `POST /api/v1/audit`: 감사 로그 수집 API
|
||||
- userfront가 바라보는 backend
|
||||
|
||||
### 2. userfront(Flutter Web/App)
|
||||
### 2. UserFront(Flutter Web/App)
|
||||
- **Framework**: Flutter 3.32+
|
||||
- **Key Packages**: `flutter_riverpod`, `go_router`
|
||||
- **Features**:
|
||||
@@ -36,7 +67,7 @@
|
||||
- 앱 별 사용량(호출량) 등 통계
|
||||
- 핵심 Audit 대상
|
||||
|
||||
### 4. devfront(Web) - 향후 분리 예정
|
||||
### 4. devfront(Web)
|
||||
- **Framework**: Vite, React 19+, Shadcn/ui 등
|
||||
- **Features**:
|
||||
- RP 등록 및 관리
|
||||
@@ -54,19 +85,61 @@
|
||||
### 전체 연결 구조도
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
AF[adminfront] -->|"OIDC authorize/token (PKCE)"| HY["Hydra (OIDC 엔진)"]
|
||||
DF[devfront] -->|"OIDC authorize/token (PKCE)"| HY
|
||||
UF["userfront (Login/Consent UI)"] <-->|Hydra login/consent redirect| HY
|
||||
UF -->|Kratos Browser Flow| KR["Kratos (SoT: identities/traits)"]
|
||||
KR -->|subject=identity.id| HY
|
||||
HY -->|ID/Access Token| RP[Relying Party Apps]
|
||||
MG["Magic Link Wrapper (Fiber)"] -->|1회용 토큰→Kratos CreateSession| KR
|
||||
MG -->|"Hydra Login Accept (옵션)"| HY
|
||||
DS["Descope/Social IDP"] -->|Kratos Social/OIDC| KR
|
||||
flowchart TD
|
||||
subgraph Clients ["External Clients"]
|
||||
AF[adminfront]
|
||||
DF[devfront]
|
||||
UF["userfront"]
|
||||
DS["일반SW"]
|
||||
end
|
||||
|
||||
subgraph AppService ["Control Plane"]
|
||||
BE["Backend (Command/Audit Controller)"]
|
||||
end
|
||||
|
||||
subgraph OryBundle ["Ory Deployment Stack"]
|
||||
direction TB
|
||||
OK["Oathkeeper (Public Proxy/OIDC)"]
|
||||
|
||||
subgraph OryEngines ["Ory Services"]
|
||||
direction LR
|
||||
HY["Hydra"]
|
||||
KR["Kratos"]
|
||||
KE["Keto"]
|
||||
end
|
||||
|
||||
ICH[(Internal Clickhouse)]
|
||||
|
||||
%% Internal Flow within Bundle
|
||||
OK -->|Routing/Queries| OryEngines
|
||||
OK -.->|Access/Usage Log| ICH
|
||||
end
|
||||
|
||||
subgraph AuditDB ["Audit Storage"]
|
||||
ECH[(External Clickhouse)]
|
||||
end
|
||||
|
||||
%% Key Command Path
|
||||
AF & DF & UF & DS ==>|Actions / Commands| BE
|
||||
|
||||
%% Backend Responsibilities
|
||||
BE -->|Admin/State Control| OryEngines
|
||||
BE -.->|Mandatory Audit Log| ECH
|
||||
|
||||
%% Connection Note (Hidden flow mentioned in logic)
|
||||
%% OK is technically the entry for OIDC, but removed as per request
|
||||
|
||||
%% Styles
|
||||
style OryBundle fill:#f8f9fa,stroke:#333,stroke-width:2px
|
||||
style BE fill:#bbf,stroke:#333,stroke-width:2px
|
||||
style ECH fill:#fdd,stroke:#333
|
||||
style ICH fill:#dfd,stroke:#333
|
||||
style OK fill:#f9f,stroke:#333
|
||||
style OryEngines fill:#fff,stroke:#999,stroke-dasharray: 5 5
|
||||
```
|
||||
|
||||
Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. Magic Link는 Fiber 래퍼가 Kratos 세션을 만들고 필요 시 Hydra Login Accept를 수행합니다. Descope 등 보조 IDP는 Kratos Social/OIDC provider로 연결합니다.
|
||||
|
||||
Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비지니스로직은 Backend를 통해서, 기본 인증 로직은 Ory Stack을 통해 진행됩니다.
|
||||
|
||||
---
|
||||
|
||||
@@ -103,6 +176,7 @@ Ory Stack과 애플리케이션 간 통신을 위한 도커 네트워크를 생
|
||||
docker network create -d bridge ory-net
|
||||
docker network create hydranet
|
||||
docker network create kratosnet
|
||||
docker network create public_net #서비스용
|
||||
```
|
||||
|
||||
#### 2. 인프라 및 Ory Stack 실행
|
||||
@@ -134,36 +208,36 @@ docker compose -f compose.ory.yaml --profile mcp up -d hydra-mcp-server kratos-m
|
||||
```
|
||||
|
||||
- MCP 서버는 stdio 기반이라 외부 포트를 열지 않습니다.
|
||||
- MCP 클라이언트에서 `npx`로 실행하는 설정 예시입니다.
|
||||
- `hydra-mcp`는 첫 실행 시 캐시 디렉터리에 의존성을 자동 설치합니다(수동 `npm install` 불필요).
|
||||
- 최초 실행시거나 빌드된 이미지가 없으면 `docker compose -f mcp/compose.mcp.ory.yaml build' 후에 사용 가능합니다
|
||||
|
||||
```toml
|
||||
[mcp_servers.kratos-mcp]
|
||||
command = "npx"
|
||||
args = ["-y", "mcp-ory-kratos"]
|
||||
command = "docker"
|
||||
args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "kratos-mcp-server"]
|
||||
|
||||
[mcp_servers.kratos-mcp.env]
|
||||
KRATOS_ADMIN_URL = "http://localhost:4434"
|
||||
KRATOS_ADMIN_URL = "http://kratos:4434"
|
||||
|
||||
[mcp_servers.hydra-mcp]
|
||||
command = "npx"
|
||||
args = ["-y", "/home/lectom/repos/baron-sso/mcp/hydra-mcp"]
|
||||
command = "docker"
|
||||
args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "hydra-mcp-server"]
|
||||
|
||||
[mcp_servers.hydra-mcp.env]
|
||||
HYDRA_PUBLIC_URL = "http://localhost:4441"
|
||||
HYDRA_ADMIN_URL = "http://localhost:4445"
|
||||
HYDRA_PUBLIC_URL = "http://hydra:4444"
|
||||
HYDRA_ADMIN_URL = "http://hydra:4445"
|
||||
|
||||
[mcp_servers.keto-mcp]
|
||||
command = "npx"
|
||||
args = ["-y", "/home/lectom/repos/baron-sso/mcp/keto-mcp"]
|
||||
command = "docker"
|
||||
args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "keto-mcp-server"]
|
||||
|
||||
[mcp_servers.keto-mcp.env]
|
||||
KETO_READ_URL = "http://localhost:4466"
|
||||
KETO_WRITE_URL = "http://localhost:4467"
|
||||
KETO_READ_URL = "http://keto:4466"
|
||||
KETO_WRITE_URL = "http://keto:4467"
|
||||
```
|
||||
|
||||
### 로컬 개발 (Manual)
|
||||
Docker 없이 코드를 수정하며 개발하려면:
|
||||
Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능.
|
||||
백그라운드로 infra 및 ory stack이 구동중이라는 가정
|
||||
|
||||
**Backend:**
|
||||
```bash
|
||||
@@ -219,6 +293,6 @@ baron_sso/
|
||||
## 📝 상태 및 로드맵 (Status & Roadmap)
|
||||
- [x] **Phase 1**: 초기 설정 및 아키텍처 설계 (완료)
|
||||
- [x] **Phase 2**: Backend Audit API 구현 (일부 완료)
|
||||
- [x] **Phase 3**: userfront 로그인 UI 인증 로직 (완료)
|
||||
- [ ] **Phase 3**: userfront 로그인 UI 인증 로직 (예정)
|
||||
- [ ] **Phase 4**: adminfront 기능 추가 (예정)
|
||||
- [ ] **Phase 5**: 대시보드 및 통합 런처 구현 (예정)
|
||||
|
||||
@@ -1,476 +1,562 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
ListChecks,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
ListChecks,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import type { AuditLog } from "../../lib/adminApi";
|
||||
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||
|
||||
const defaultAuditFilters = [
|
||||
"method:POST path:/api/v1/*",
|
||||
"status:failure",
|
||||
"latency_ms:>1000",
|
||||
"method:POST path:/api/v1/*",
|
||||
"status:failure",
|
||||
"latency_ms:>1000",
|
||||
];
|
||||
|
||||
type AuditDetails = {
|
||||
request_id?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
status?: number;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
tenant_id?: string;
|
||||
actor_id?: string;
|
||||
action?: string;
|
||||
target?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
request_id?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
status?: number;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
tenant_id?: string;
|
||||
actor_id?: string;
|
||||
action?: string;
|
||||
target?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
};
|
||||
|
||||
function parseDetails(details?: string): AuditDetails {
|
||||
if (!details) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(details);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as AuditDetails;
|
||||
if (!details) {
|
||||
return {};
|
||||
}
|
||||
} catch {}
|
||||
return {};
|
||||
try {
|
||||
const parsed = JSON.parse(details);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as AuditDetails;
|
||||
}
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
function formatCellValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatIsoDateTime(value: string) {
|
||||
if (!value) {
|
||||
return { date: "-", time: "-" };
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return { date: value, time: "-" };
|
||||
}
|
||||
const date = parsed.toISOString().slice(0, 10);
|
||||
const time = parsed.toLocaleTimeString("ko-KR", { hour12: false });
|
||||
return { date, time };
|
||||
if (!value) {
|
||||
return { date: "-", time: "-" };
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return { date: value, time: "-" };
|
||||
}
|
||||
const date = parsed.toISOString().slice(0, 10);
|
||||
const time = parsed.toLocaleTimeString("ko-KR", { hour12: false });
|
||||
return { date, time };
|
||||
}
|
||||
|
||||
function AuditLogsPage() {
|
||||
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
||||
const [filterDraft, setFilterDraft] = React.useState("");
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
||||
const [filterDraft, setFilterDraft] = React.useState("");
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["audit-logs"],
|
||||
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
|
||||
const logs =
|
||||
data?.pages?.flatMap(
|
||||
(page) =>
|
||||
page?.items?.filter((item): item is AuditLog =>
|
||||
Boolean(item),
|
||||
) ?? [],
|
||||
) ?? [];
|
||||
|
||||
const handleAddFilter = () => {
|
||||
const trimmed = filterDraft.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
setFilters((prev) =>
|
||||
prev.includes(trimmed) ? prev : [...prev, trimmed],
|
||||
);
|
||||
setFilterDraft("");
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center">Loading audit logs...</div>;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["audit-logs"],
|
||||
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
|
||||
const logs =
|
||||
data?.pages?.flatMap(
|
||||
(page) =>
|
||||
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
||||
) ?? [];
|
||||
|
||||
const handleAddFilter = () => {
|
||||
const trimmed = filterDraft.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
if (error) {
|
||||
const errMsg =
|
||||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(error as Error).message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
Error loading logs: {errMsg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed]));
|
||||
setFilterDraft("");
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center">Loading audit logs...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errMsg =
|
||||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(error as Error).message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
Error loading logs: {errMsg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>Audit</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">Logs</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">감사 로그</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후
|
||||
세션 연동 시 자동 채워집니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button>
|
||||
<ListChecks size={16} />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Audit registry</CardTitle>
|
||||
<CardDescription>로드된 로그 {logs.length}건</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">Command only</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={filterDraft}
|
||||
onChange={(event) => setFilterDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
handleAddFilter();
|
||||
}
|
||||
}}
|
||||
placeholder="필터 추가 (예: status:failure)"
|
||||
className="w-full bg-transparent text-sm text-foreground outline-none"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleAddFilter}>
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
{filters.length === 0 ? (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
필터 없음
|
||||
</span>
|
||||
) : (
|
||||
filters.map((filter) => (
|
||||
<span
|
||||
key={filter}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
{filter}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters((prev) =>
|
||||
prev.filter((item) => item !== filter),
|
||||
)
|
||||
}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
|
||||
aria-label={`${filter} 필터 제거`}
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>Audit</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">Logs</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">감사 로그</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Command 요청 기반 ClickHouse 로그를 조회합니다.
|
||||
사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[140px]">TIME</TableHead>
|
||||
<TableHead className="w-[160px]">ACTOR (ID)</TableHead>
|
||||
<TableHead>REQUEST</TableHead>
|
||||
<TableHead>PATH</TableHead>
|
||||
<TableHead className="w-[120px]">STATUS</TableHead>
|
||||
<TableHead>Action / Target</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>로딩 중...</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isLoading && logs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
아직 수집된 감사 로그가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{logs.map((row, index) => {
|
||||
const details = parseDetails(row.details);
|
||||
const actionLabel =
|
||||
details.action ||
|
||||
(details.method && details.path
|
||||
? `${details.method} ${details.path}`
|
||||
: row.event_type);
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const isExpanded = Boolean(expandedRows[rowKey]);
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow className="bg-card/40">
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
{(() => {
|
||||
const { date, time } = formatIsoDateTime(
|
||||
row.timestamp,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{row.user_id || details.actor_id || "-"}
|
||||
</code>
|
||||
{(row.user_id || details.actor_id) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label="Copy actor id"
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
row.user_id || details.actor_id || "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="break-all">
|
||||
{formatCellValue(details.request_id)}
|
||||
</span>
|
||||
{details.request_id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label="Copy request id"
|
||||
onClick={() =>
|
||||
handleCopy(details.request_id || "")
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{formatCellValue(details.method)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{formatCellValue(details.path)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
row.status === "success" || row.status === "ok"
|
||||
? "success"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{actionLabel}
|
||||
</div>
|
||||
{details.target && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">
|
||||
Target · {details.target}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label="Copy target"
|
||||
onClick={() => handleCopy(details.target || "")}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
<RefreshCw size={16} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button>
|
||||
<ListChecks size={16} />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Audit registry</CardTitle>
|
||||
<CardDescription>
|
||||
로드된 로그 {logs.length}건
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">Command only</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={filterDraft}
|
||||
onChange={(event) =>
|
||||
setFilterDraft(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
handleAddFilter();
|
||||
}
|
||||
}}
|
||||
placeholder="필터 추가 (예: status:failure)"
|
||||
className="w-full bg-transparent text-sm text-foreground outline-none"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleAddFilter}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !isExpanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
{filters.length === 0 ? (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
필터 없음
|
||||
</span>
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
filters.map((filter) => (
|
||||
<span
|
||||
key={filter}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
{filter}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters((prev) =>
|
||||
prev.filter(
|
||||
(item) =>
|
||||
item !== filter,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
|
||||
aria-label={`${filter} 필터 제거`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<TableRow className="bg-card/20">
|
||||
<TableCell colSpan={7} className="text-xs">
|
||||
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
Request
|
||||
</div>
|
||||
<div className="break-all">
|
||||
Request ID ·{" "}
|
||||
{formatCellValue(details.request_id)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
Event ID · {formatCellValue(row.event_id)}
|
||||
</div>
|
||||
<div>
|
||||
IP · {formatCellValue(row.ip_address)}
|
||||
</div>
|
||||
<div>
|
||||
Latency ·{" "}
|
||||
{details.latency_ms !== undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
Actor
|
||||
</div>
|
||||
<div>
|
||||
Actor ID ·{" "}
|
||||
{row.user_id || details.actor_id || "-"}
|
||||
</div>
|
||||
<div>
|
||||
Tenant · {formatCellValue(details.tenant_id)}
|
||||
</div>
|
||||
<div>
|
||||
Device · {formatCellValue(row.device_id)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
Result
|
||||
</div>
|
||||
<div className="break-all">
|
||||
Error · {formatCellValue(details.error)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
Before · {formatCellValue(details.before)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
After · {formatCellValue(details.after)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="pt-4 text-center">
|
||||
{hasNextPage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage ? "Loading..." : "Load more"}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
End of audit feed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[140px]">
|
||||
TIME
|
||||
</TableHead>
|
||||
<TableHead className="w-[160px]">
|
||||
ACTOR (ID)
|
||||
</TableHead>
|
||||
<TableHead>REQUEST</TableHead>
|
||||
<TableHead>PATH</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
STATUS
|
||||
</TableHead>
|
||||
<TableHead>Action / Target</TableHead>
|
||||
<TableHead className="w-[80px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isLoading && logs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
아직 수집된 감사 로그가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{logs.map((row, index) => {
|
||||
const details = parseDetails(row.details);
|
||||
const actionLabel =
|
||||
details.action ||
|
||||
(details.method && details.path
|
||||
? `${details.method} ${details.path}`
|
||||
: row.event_type);
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const isExpanded = Boolean(
|
||||
expandedRows[rowKey],
|
||||
);
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow className="bg-card/40">
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
{(() => {
|
||||
const { date, time } =
|
||||
formatIsoDateTime(
|
||||
row.timestamp,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
{date}
|
||||
</div>
|
||||
<div>
|
||||
{time}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{row.user_id ||
|
||||
details.actor_id ||
|
||||
"-"}
|
||||
</code>
|
||||
{(row.user_id ||
|
||||
details.actor_id) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label="Copy actor id"
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
row.user_id ||
|
||||
details.actor_id ||
|
||||
"",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="break-all">
|
||||
{formatCellValue(
|
||||
details.request_id,
|
||||
)}
|
||||
</span>
|
||||
{details.request_id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label="Copy request id"
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
details.request_id ||
|
||||
"",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{formatCellValue(
|
||||
details.method,
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{formatCellValue(
|
||||
details.path,
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
row.status ===
|
||||
"success" ||
|
||||
row.status === "ok"
|
||||
? "success"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||
<div className="font-semibold text-foreground">
|
||||
{actionLabel}
|
||||
</div>
|
||||
{details.target && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">
|
||||
Target ·{" "}
|
||||
{details.target}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||
aria-label="Copy target"
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
details.target ||
|
||||
"",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExpandedRows(
|
||||
(prev) => ({
|
||||
...prev,
|
||||
[rowKey]:
|
||||
!isExpanded,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<TableRow className="bg-card/20">
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="text-xs"
|
||||
>
|
||||
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
Request
|
||||
</div>
|
||||
<div className="break-all">
|
||||
Request ID ·{" "}
|
||||
{formatCellValue(
|
||||
details.request_id,
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
Event ID ·{" "}
|
||||
{formatCellValue(
|
||||
row.event_id,
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
IP ·{" "}
|
||||
{formatCellValue(
|
||||
row.ip_address,
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
Latency ·{" "}
|
||||
{details.latency_ms !==
|
||||
undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
Actor
|
||||
</div>
|
||||
<div>
|
||||
Actor ID ·{" "}
|
||||
{row.user_id ||
|
||||
details.actor_id ||
|
||||
"-"}
|
||||
</div>
|
||||
<div>
|
||||
Tenant ·{" "}
|
||||
{formatCellValue(
|
||||
details.tenant_id,
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
Device ·{" "}
|
||||
{formatCellValue(
|
||||
row.device_id,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
Result
|
||||
</div>
|
||||
<div className="break-all">
|
||||
Error ·{" "}
|
||||
{formatCellValue(
|
||||
details.error,
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
Before ·{" "}
|
||||
{formatCellValue(
|
||||
details.before,
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
After ·{" "}
|
||||
{formatCellValue(
|
||||
details.after,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="pt-4 text-center">
|
||||
{hasNextPage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? "Loading..."
|
||||
: "Load more"}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-muted)]">
|
||||
End of audit feed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuditLogsPage;
|
||||
export default AuditLogsPage;
|
||||
132
backend/cmd/server/health_monitor.go
Normal file
132
backend/cmd/server/health_monitor.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HTTPProbe struct {
|
||||
name string
|
||||
url string
|
||||
interval time.Duration
|
||||
timeout time.Duration
|
||||
client *http.Client
|
||||
mu sync.RWMutex
|
||||
status string
|
||||
lastError string
|
||||
lastChecked time.Time
|
||||
lastSuccess time.Time
|
||||
}
|
||||
|
||||
type ProbeSnapshot struct {
|
||||
Status string
|
||||
Error string
|
||||
LastChecked time.Time
|
||||
LastSuccess time.Time
|
||||
}
|
||||
|
||||
func NewHTTPProbe(name, url string, interval, timeout time.Duration) *HTTPProbe {
|
||||
if interval <= 0 {
|
||||
interval = 10 * time.Second
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Second
|
||||
}
|
||||
|
||||
return &HTTPProbe{
|
||||
name: name,
|
||||
url: url,
|
||||
interval: interval,
|
||||
timeout: timeout,
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Start는 프로브를 백그라운드에서 주기적으로 실행합니다.
|
||||
func (p *HTTPProbe) Start() {
|
||||
go func() {
|
||||
p.checkOnce()
|
||||
ticker := time.NewTicker(p.interval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
p.checkOnce()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *HTTPProbe) Snapshot() ProbeSnapshot {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return ProbeSnapshot{
|
||||
Status: p.status,
|
||||
Error: p.lastError,
|
||||
LastChecked: p.lastChecked,
|
||||
LastSuccess: p.lastSuccess,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HTTPProbe) StatusText() string {
|
||||
s := p.Snapshot()
|
||||
if s.Status == "ok" {
|
||||
return "ok"
|
||||
}
|
||||
if s.Status == "" {
|
||||
return "unknown"
|
||||
}
|
||||
if s.Error == "" {
|
||||
return "error"
|
||||
}
|
||||
return "error: " + s.Error
|
||||
}
|
||||
|
||||
func (p *HTTPProbe) checkOnce() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||
if err != nil {
|
||||
p.update("error", fmt.Sprintf("request build failed: %v", err), false)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
p.update("error", err.Error(), false)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
p.update("error", fmt.Sprintf("status=%d", resp.StatusCode), false)
|
||||
return
|
||||
}
|
||||
|
||||
p.update("ok", "", true)
|
||||
}
|
||||
|
||||
func (p *HTTPProbe) update(status, errMsg string, success bool) {
|
||||
p.mu.Lock()
|
||||
prevStatus := p.status
|
||||
p.status = status
|
||||
p.lastError = errMsg
|
||||
p.lastChecked = time.Now()
|
||||
if success {
|
||||
p.lastSuccess = p.lastChecked
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if prevStatus == status {
|
||||
return
|
||||
}
|
||||
if status == "ok" {
|
||||
slog.Info("Service probe recovered", "name", p.name, "url", p.url)
|
||||
return
|
||||
}
|
||||
slog.Error("Service probe failed", "name", p.name, "url", p.url, "error", errMsg)
|
||||
}
|
||||
@@ -158,6 +158,28 @@ func main() {
|
||||
slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err)
|
||||
}
|
||||
|
||||
// Oathkeeper 상태를 주기적으로 확인해 다운을 감지합니다.
|
||||
var oathkeeperProbe *HTTPProbe
|
||||
if strings.ToLower(getEnv("OATHKEEPER_HEALTH_ENABLED", "true")) != "false" {
|
||||
intervalSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_INTERVAL_SECONDS", "10"))
|
||||
if err != nil || intervalSec <= 0 {
|
||||
intervalSec = 10
|
||||
}
|
||||
timeoutSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_TIMEOUT_SECONDS", "2"))
|
||||
if err != nil || timeoutSec <= 0 {
|
||||
timeoutSec = 2
|
||||
}
|
||||
oathkeeperProbe = NewHTTPProbe(
|
||||
"oathkeeper",
|
||||
getEnv("OATHKEEPER_HEALTH_URL", "http://oathkeeper:4456/health/ready"),
|
||||
time.Duration(intervalSec)*time.Second,
|
||||
time.Duration(timeoutSec)*time.Second,
|
||||
)
|
||||
oathkeeperProbe.Start()
|
||||
} else {
|
||||
slog.Info("Oathkeeper probe disabled")
|
||||
}
|
||||
|
||||
// 2. Initialize Handlers
|
||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider)
|
||||
@@ -249,10 +271,14 @@ func main() {
|
||||
app.Use(recover.New(recover.Config{
|
||||
EnableStackTrace: true,
|
||||
}))
|
||||
|
||||
allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000")
|
||||
allowCredentials := allowedOrigins != "*"
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*", // Adjust in production
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||
AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS",
|
||||
AllowOrigins: allowedOrigins,
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||
AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS",
|
||||
AllowCredentials: allowCredentials,
|
||||
}))
|
||||
|
||||
// Ensure COOKIE_SECRET is exactly 32 bytes for AES-256
|
||||
@@ -271,24 +297,30 @@ func main() {
|
||||
Key: cookieSecret,
|
||||
}))
|
||||
|
||||
app.Get("/docs", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/swagger-ui/index.html")
|
||||
})
|
||||
app.Get("/docs/", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/swagger-ui/index.html")
|
||||
})
|
||||
app.Static("/docs", "./docs/swagger-ui")
|
||||
app.Get("/redoc", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/redoc/index.html")
|
||||
})
|
||||
app.Get("/redoc/", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/redoc/index.html")
|
||||
})
|
||||
app.Static("/redoc", "./docs/redoc")
|
||||
app.Get("/openapi.yaml", func(c *fiber.Ctx) error {
|
||||
c.Type("yaml")
|
||||
return c.SendFile("./docs/openapi.yaml")
|
||||
})
|
||||
// [Security] Disable Swagger/ReDoc in Production
|
||||
if appEnv != "production" {
|
||||
app.Get("/docs", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/swagger-ui/index.html")
|
||||
})
|
||||
app.Get("/docs/", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/swagger-ui/index.html")
|
||||
})
|
||||
app.Static("/docs", "./docs/swagger-ui")
|
||||
app.Get("/redoc", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/redoc/index.html")
|
||||
})
|
||||
app.Get("/redoc/", func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./docs/redoc/index.html")
|
||||
})
|
||||
app.Static("/redoc", "./docs/redoc")
|
||||
app.Get("/openapi.yaml", func(c *fiber.Ctx) error {
|
||||
c.Type("yaml")
|
||||
return c.SendFile("./docs/openapi.yaml")
|
||||
})
|
||||
slog.Info("📚 API Docs enabled", "swagger", "/docs", "redoc", "/redoc")
|
||||
} else {
|
||||
slog.Info("🔒 API Docs disabled in production")
|
||||
}
|
||||
|
||||
// Routes
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
@@ -325,6 +357,32 @@ func main() {
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
// Check Oathkeeper
|
||||
if oathkeeperProbe != nil {
|
||||
snapshot := oathkeeperProbe.Snapshot()
|
||||
switch snapshot.Status {
|
||||
case "ok":
|
||||
checks["oathkeeper"] = "ok"
|
||||
case "":
|
||||
checks["oathkeeper"] = "unknown"
|
||||
if status != "error" {
|
||||
status = "degraded"
|
||||
}
|
||||
default:
|
||||
if snapshot.Error == "" {
|
||||
checks["oathkeeper"] = "error"
|
||||
} else {
|
||||
checks["oathkeeper"] = "error: " + snapshot.Error
|
||||
}
|
||||
status = "error"
|
||||
}
|
||||
} else {
|
||||
checks["oathkeeper"] = "disabled"
|
||||
if status != "error" {
|
||||
status = "degraded"
|
||||
}
|
||||
}
|
||||
|
||||
if status == "error" {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"status": status,
|
||||
@@ -340,12 +398,19 @@ func main() {
|
||||
|
||||
// API Group
|
||||
api := app.Group("/api/v1")
|
||||
api.Use(middleware.RequireAudit(middleware.AuditRequiredConfig{
|
||||
|
||||
workerCount, _ := strconv.Atoi(getEnv("AUDIT_WORKER_COUNT", "5"))
|
||||
queueSize, _ := strconv.Atoi(getEnv("AUDIT_QUEUE_SIZE", "2000"))
|
||||
|
||||
api.Use(middleware.AuditMiddleware(middleware.AuditConfig{
|
||||
Repo: auditRepo,
|
||||
ExcludePaths: map[string]struct{}{
|
||||
"/api/v1/audit": {},
|
||||
"/api/v1/client-log": {},
|
||||
},
|
||||
BodyDump: true,
|
||||
WorkerCount: workerCount,
|
||||
QueueSize: queueSize,
|
||||
}))
|
||||
api.Post("/audit", auditHandler.CreateLog)
|
||||
api.Get("/audit", auditHandler.ListLogs)
|
||||
@@ -355,6 +420,8 @@ func main() {
|
||||
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
|
||||
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
||||
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
|
||||
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
|
||||
auth.Post("/password/login", authHandler.PasswordLogin)
|
||||
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
||||
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
||||
@@ -388,6 +455,7 @@ func main() {
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) // API Key 인증 추가
|
||||
admin.Get("/check", adminHandler.CheckAuth)
|
||||
admin.Get("/stats", adminHandler.GetSystemStats)
|
||||
admin.Get("/tenants", tenantHandler.ListTenants)
|
||||
admin.Post("/tenants", tenantHandler.CreateTenant)
|
||||
admin.Get("/tenants/:id", tenantHandler.GetTenant)
|
||||
@@ -423,6 +491,9 @@ func main() {
|
||||
// Webhook for Descope Generic Email Gateway (Fake Email Strategy)
|
||||
auth.Post("/webhooks/descope-email", authHandler.HandleDescopeEmailRelay)
|
||||
|
||||
// Webhook for Kratos courier (HTTP delivery)
|
||||
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
|
||||
|
||||
// Client Logging Route (Standardized & Flattened)
|
||||
api.Post("/client-log", func(c *fiber.Ctx) error {
|
||||
type LogReq struct {
|
||||
|
||||
@@ -230,7 +230,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MessageResponse"
|
||||
$ref: "#/components/schemas/MagicLinkVerifyResponse"
|
||||
|
||||
/api/v1/auth/sms:
|
||||
post:
|
||||
@@ -266,7 +266,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MessageResponse"
|
||||
$ref: "#/components/schemas/SmsVerifyResponse"
|
||||
|
||||
/api/v1/auth/qr/init:
|
||||
post:
|
||||
@@ -908,18 +908,28 @@ components:
|
||||
type: boolean
|
||||
nonAlphanumeric:
|
||||
type: boolean
|
||||
minCharacterTypes:
|
||||
type: integer
|
||||
|
||||
EnchantedLinkInitRequest:
|
||||
type: object
|
||||
properties:
|
||||
loginId:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
method:
|
||||
type: string
|
||||
|
||||
EnchantedLinkInitResponse:
|
||||
type: object
|
||||
properties:
|
||||
linkId:
|
||||
type: string
|
||||
pendingRef:
|
||||
type: string
|
||||
maskedEmail:
|
||||
type: string
|
||||
expiresIn:
|
||||
type: integer
|
||||
|
||||
@@ -943,22 +953,36 @@ components:
|
||||
token:
|
||||
type: string
|
||||
|
||||
MagicLinkVerifyResponse:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
|
||||
SmsSendRequest:
|
||||
type: object
|
||||
properties:
|
||||
phone:
|
||||
type: string
|
||||
message:
|
||||
phoneNumber:
|
||||
type: string
|
||||
|
||||
SmsVerifyRequest:
|
||||
type: object
|
||||
properties:
|
||||
phone:
|
||||
phoneNumber:
|
||||
type: string
|
||||
code:
|
||||
type: string
|
||||
|
||||
SmsVerifyResponse:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
|
||||
QrInitResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -14,6 +14,7 @@ require (
|
||||
github.com/gofiber/fiber/v2 v2.52.10
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.46.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -34,6 +35,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
@@ -57,10 +59,12 @@ require (
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/paulmach/orb v0.12.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
@@ -71,4 +75,5 @@ require (
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -137,6 +137,8 @@ github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
|
||||
@@ -4,6 +4,7 @@ type EnchantedLinkInitRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow)
|
||||
Method string `json:"method,omitempty"` // "email" or "sms"
|
||||
CodeOnly bool `json:"codeOnly,omitempty"`
|
||||
}
|
||||
|
||||
type EnchantedLinkInitResponse struct {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotSupported는 IDP가 특정 인증 흐름을 지원하지 않을 때 반환합니다.
|
||||
var ErrNotSupported = errors.New("idp: not supported")
|
||||
|
||||
// BrokerUser is the standard user model used within Baron SSO business logic.
|
||||
// It defines the canonical set of fields that must be supported by any underlying IDP.
|
||||
type BrokerUser struct {
|
||||
@@ -24,6 +28,16 @@ type IDPMetadata struct {
|
||||
SupportedFields []string
|
||||
}
|
||||
|
||||
// PasswordPolicy는 비밀번호 정책 정보를 표현합니다.
|
||||
type PasswordPolicy struct {
|
||||
MinLength int
|
||||
Lowercase bool
|
||||
Uppercase bool
|
||||
Number bool
|
||||
NonAlphanumeric bool
|
||||
MinCharacterTypes int
|
||||
}
|
||||
|
||||
// Token represents a session or refresh token.
|
||||
type Token struct {
|
||||
JWT string
|
||||
@@ -38,6 +52,16 @@ type AuthInfo struct {
|
||||
Subject string
|
||||
}
|
||||
|
||||
// LinkLoginInit는 링크 로그인 초기화 결과입니다.
|
||||
type LinkLoginInit struct {
|
||||
FlowID string
|
||||
ExpiresAt time.Time
|
||||
// Mode는 링크 로그인 완료 후 세션 처리 방식입니다. (예: "cookie")
|
||||
Mode string
|
||||
// LoginID는 IDP에 실제 전달된 식별자입니다.
|
||||
LoginID string
|
||||
}
|
||||
|
||||
// IdentityProvider is the interface that all IDP adapters must implement.
|
||||
type IdentityProvider interface {
|
||||
Name() string
|
||||
@@ -48,6 +72,16 @@ type IdentityProvider interface {
|
||||
CreateUser(user *BrokerUser, password string) (string, error)
|
||||
// SignIn은 로그인 ID/비밀번호로 인증해 세션 정보를 반환합니다.
|
||||
SignIn(loginID, password string) (*AuthInfo, error)
|
||||
// UserExists는 loginID 기준으로 사용자 존재 여부를 확인합니다.
|
||||
UserExists(loginID string) (bool, error)
|
||||
// IssueSession은 비밀번호 없이 세션을 발급해야 하는 흐름에서 사용합니다.
|
||||
IssueSession(loginID string) (*AuthInfo, error)
|
||||
// InitiateLinkLogin은 링크 기반 로그인 요청을 IDP에 전달합니다.
|
||||
InitiateLinkLogin(loginID, returnTo string) (*LinkLoginInit, error)
|
||||
// VerifyLoginCode는 링크/코드 기반 로그인에서 코드를 제출해 세션을 발급합니다.
|
||||
VerifyLoginCode(loginID, flowID, code string) (*AuthInfo, error)
|
||||
// GetPasswordPolicy는 IDP가 제공하는 비밀번호 정책을 반환합니다.
|
||||
GetPasswordPolicy() (*PasswordPolicy, error)
|
||||
InitiatePasswordReset(loginID, redirectUrl string) error
|
||||
VerifyPasswordResetToken(token string) (*AuthInfo, error)
|
||||
UpdateUserPassword(loginID, newPassword string, r *http.Request) error
|
||||
|
||||
@@ -3,6 +3,8 @@ package handler
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/descope/go-sdk/descope/client"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -39,3 +41,23 @@ func NewAdminHandler() *AdminHandler {
|
||||
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
|
||||
// GetSystemStats returns runtime statistics for monitoring
|
||||
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
stats := fiber.Map{
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"cpus": runtime.NumCPU(),
|
||||
"memory": fiber.Map{
|
||||
"alloc": m.Alloc,
|
||||
"totalAlign": m.TotalAlloc,
|
||||
"sys": m.Sys,
|
||||
"numGC": m.NumGC,
|
||||
},
|
||||
"timestamp": time.Now(),
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(stats)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -72,7 +72,7 @@ func TestCompletePasswordReset_InvalidPasswordPolicy(t *testing.T) {
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got["error"] != "Password must be at least 8 characters long" {
|
||||
if got["error"] != "비밀번호는 최소 12자 이상이어야 합니다" {
|
||||
t.Fatalf("unexpected error message: %v", got["error"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,43 +124,144 @@ func (c *chainedProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
||||
}
|
||||
|
||||
func (c *chainedProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
|
||||
var errs []error
|
||||
for idx, p := range c.providers {
|
||||
for _, p := range c.providers {
|
||||
id, err := p.CreateUser(user, password)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
|
||||
if errors.Is(err, domain.ErrNotSupported) {
|
||||
continue
|
||||
}
|
||||
slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "CreateUser", "error", err)
|
||||
continue
|
||||
}
|
||||
if idx > 0 {
|
||||
slog.Info("IDP fallback succeeded", "operation", "CreateUser", "provider", p.Name())
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return "", fmt.Errorf("no IDP providers available for CreateUser")
|
||||
}
|
||||
return "", fmt.Errorf("all IDP providers failed for CreateUser: %w", errors.Join(errs...))
|
||||
return "", domain.ErrNotSupported
|
||||
}
|
||||
|
||||
func (c *chainedProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
|
||||
var errs []error
|
||||
for idx, p := range c.providers {
|
||||
for _, p := range c.providers {
|
||||
info, err := p.SignIn(loginID, password)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
|
||||
if errors.Is(err, domain.ErrNotSupported) {
|
||||
continue
|
||||
}
|
||||
slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "SignIn", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
return nil, domain.ErrNotSupported
|
||||
}
|
||||
|
||||
func (c *chainedProvider) UserExists(loginID string) (bool, error) {
|
||||
var errs []error
|
||||
for _, p := range c.providers {
|
||||
exists, err := p.UserExists(loginID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotSupported) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
|
||||
continue
|
||||
}
|
||||
if exists {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("all IDP providers failed for UserExists: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
func (c *chainedProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
|
||||
var errs []error
|
||||
for idx, p := range c.providers {
|
||||
info, err := p.IssueSession(loginID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotSupported) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
|
||||
slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "IssueSession", "error", err)
|
||||
continue
|
||||
}
|
||||
if idx > 0 {
|
||||
slog.Info("IDP fallback succeeded", "operation", "SignIn", "provider", p.Name())
|
||||
slog.Info("IDP fallback succeeded", "operation", "IssueSession", "provider", p.Name())
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return nil, fmt.Errorf("no IDP providers available for SignIn")
|
||||
return nil, domain.ErrNotSupported
|
||||
}
|
||||
return nil, fmt.Errorf("all IDP providers failed for SignIn: %w", errors.Join(errs...))
|
||||
return nil, fmt.Errorf("all IDP providers failed for IssueSession: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
func (c *chainedProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||
var errs []error
|
||||
for idx, p := range c.providers {
|
||||
info, err := p.InitiateLinkLogin(loginID, returnTo)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotSupported) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
|
||||
slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "InitiateLinkLogin", "error", err)
|
||||
continue
|
||||
}
|
||||
if idx > 0 {
|
||||
slog.Info("IDP fallback succeeded", "operation", "InitiateLinkLogin", "provider", p.Name())
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return nil, domain.ErrNotSupported
|
||||
}
|
||||
return nil, fmt.Errorf("all IDP providers failed for InitiateLinkLogin: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
func (c *chainedProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||
var errs []error
|
||||
for idx, p := range c.providers {
|
||||
info, err := p.VerifyLoginCode(loginID, flowID, code)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotSupported) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
|
||||
slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "VerifyLoginCode", "error", err)
|
||||
continue
|
||||
}
|
||||
if idx > 0 {
|
||||
slog.Info("IDP fallback succeeded", "operation", "VerifyLoginCode", "provider", p.Name())
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return nil, domain.ErrNotSupported
|
||||
}
|
||||
return nil, fmt.Errorf("all IDP providers failed for VerifyLoginCode: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
func (c *chainedProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
||||
var errs []error
|
||||
for _, p := range c.providers {
|
||||
policy, err := p.GetPasswordPolicy()
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotSupported) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
|
||||
continue
|
||||
}
|
||||
if policy != nil {
|
||||
return policy, nil
|
||||
}
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return nil, domain.ErrNotSupported
|
||||
}
|
||||
return nil, fmt.Errorf("all IDP providers failed for GetPasswordPolicy: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
func (c *chainedProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
|
||||
|
||||
@@ -10,19 +10,31 @@ import (
|
||||
)
|
||||
|
||||
type stubProvider struct {
|
||||
name string
|
||||
metadata []string
|
||||
createErr error
|
||||
initiateErr error
|
||||
verifyErr error
|
||||
updateErr error
|
||||
signInErr error
|
||||
initiateCalls int
|
||||
verifyCalls int
|
||||
updateCalls int
|
||||
signInCalls int
|
||||
createCalls int
|
||||
verifyResponse *domain.AuthInfo
|
||||
name string
|
||||
metadata []string
|
||||
createErr error
|
||||
initiateErr error
|
||||
verifyErr error
|
||||
updateErr error
|
||||
signInErr error
|
||||
userExistsErr error
|
||||
issueErr error
|
||||
linkInitErr error
|
||||
verifyCodeErr error
|
||||
policyErr error
|
||||
initiateCalls int
|
||||
verifyCalls int
|
||||
updateCalls int
|
||||
signInCalls int
|
||||
createCalls int
|
||||
userExistsCalls int
|
||||
issueCalls int
|
||||
linkInitCalls int
|
||||
verifyCodeCalls int
|
||||
policyCalls int
|
||||
verifyResponse *domain.AuthInfo
|
||||
userExists bool
|
||||
policy *domain.PasswordPolicy
|
||||
}
|
||||
|
||||
func (s *stubProvider) Name() string { return s.name }
|
||||
@@ -47,6 +59,46 @@ func (s *stubProvider) SignIn(loginID, password string) (*domain.AuthInfo, error
|
||||
return &domain.AuthInfo{Subject: "subject-123"}, nil
|
||||
}
|
||||
|
||||
func (s *stubProvider) UserExists(loginID string) (bool, error) {
|
||||
s.userExistsCalls++
|
||||
if s.userExistsErr != nil {
|
||||
return false, s.userExistsErr
|
||||
}
|
||||
return s.userExists, nil
|
||||
}
|
||||
|
||||
func (s *stubProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
|
||||
s.issueCalls++
|
||||
if s.issueErr != nil {
|
||||
return nil, s.issueErr
|
||||
}
|
||||
return &domain.AuthInfo{Subject: "issue-subject"}, nil
|
||||
}
|
||||
|
||||
func (s *stubProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||
s.linkInitCalls++
|
||||
if s.linkInitErr != nil {
|
||||
return nil, s.linkInitErr
|
||||
}
|
||||
return &domain.LinkLoginInit{FlowID: "flow-123", Mode: "cookie"}, nil
|
||||
}
|
||||
|
||||
func (s *stubProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||
s.verifyCodeCalls++
|
||||
if s.verifyCodeErr != nil {
|
||||
return nil, s.verifyCodeErr
|
||||
}
|
||||
return &domain.AuthInfo{Subject: "verify-code-subject"}, nil
|
||||
}
|
||||
|
||||
func (s *stubProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
||||
s.policyCalls++
|
||||
if s.policyErr != nil {
|
||||
return nil, s.policyErr
|
||||
}
|
||||
return s.policy, nil
|
||||
}
|
||||
|
||||
func (s *stubProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
|
||||
s.initiateCalls++
|
||||
return s.initiateErr
|
||||
|
||||
192
backend/internal/middleware/audit_middleware.go
Normal file
192
backend/internal/middleware/audit_middleware.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AuditConfig struct {
|
||||
Repo domain.AuditRepository
|
||||
ExcludePaths map[string]struct{}
|
||||
BodyDump bool
|
||||
WorkerCount int
|
||||
QueueSize int
|
||||
}
|
||||
|
||||
func isNil(i any) bool {
|
||||
if i == nil {
|
||||
return true
|
||||
}
|
||||
v := reflect.ValueOf(i)
|
||||
return v.Kind() == reflect.Ptr && v.IsNil()
|
||||
}
|
||||
|
||||
// AuditMiddleware provides comprehensive audit logging for all requests.
|
||||
// It enforces strict logging for state-changing commands (POST, PUT, DELETE, PATCH)
|
||||
// and best-effort logging for queries (GET, HEAD, OPTIONS).
|
||||
func AuditMiddleware(config AuditConfig) fiber.Handler {
|
||||
// 0. Initialize Worker Pool for Async Logging
|
||||
if config.WorkerCount <= 0 {
|
||||
config.WorkerCount = 5 // Default workers
|
||||
}
|
||||
if config.QueueSize <= 0 {
|
||||
config.QueueSize = 1000 // Default queue size
|
||||
}
|
||||
|
||||
auditQueue := make(chan *domain.AuditLog, config.QueueSize)
|
||||
var once sync.Once
|
||||
|
||||
// Start workers only once
|
||||
once.Do(func() {
|
||||
for i := 0; i < config.WorkerCount; i++ {
|
||||
go func(workerID int) {
|
||||
slog.Debug("Audit worker started", "id", workerID)
|
||||
for log := range auditQueue {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("Audit worker panic recovery", "reason", r, "req_id", log.EventID)
|
||||
}
|
||||
}()
|
||||
if err := config.Repo.Create(log); err != nil {
|
||||
slog.Warn("Failed to write async audit log", "error", err, "req_id", log.EventID)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
})
|
||||
|
||||
// Default methods classification
|
||||
writeMethods := map[string]struct{}{
|
||||
fiber.MethodPost: {},
|
||||
fiber.MethodPut: {},
|
||||
fiber.MethodPatch: {},
|
||||
fiber.MethodDelete: {},
|
||||
}
|
||||
|
||||
if config.ExcludePaths == nil {
|
||||
config.ExcludePaths = map[string]struct{}{}
|
||||
}
|
||||
|
||||
return func(c *fiber.Ctx) error {
|
||||
// 1. Check exclusions
|
||||
if _, excluded := config.ExcludePaths[c.Path()]; excluded {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// 2. Setup context variables
|
||||
start := time.Now()
|
||||
reqID := c.Get("X-Request-Id")
|
||||
if reqID == "" {
|
||||
reqID = uuid.New().String()
|
||||
c.Set("X-Request-Id", reqID)
|
||||
}
|
||||
|
||||
// 3. Process Request
|
||||
err := c.Next()
|
||||
|
||||
// 4. Gather Metrics & Context
|
||||
latency := time.Since(start)
|
||||
status := c.Response().StatusCode()
|
||||
|
||||
// If Fiber handler returned an error, status might default to 500 or be in the error
|
||||
if err != nil {
|
||||
if fiberErr, ok := err.(*fiber.Error); ok {
|
||||
status = fiberErr.Code
|
||||
} else {
|
||||
status = fiber.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
statusText := "success"
|
||||
if status >= fiber.StatusBadRequest {
|
||||
statusText = "failure"
|
||||
}
|
||||
|
||||
// 5. Extract User Context (populated by AuthMiddleware/TenantGuard)
|
||||
userID, _ := c.Locals("user_id").(string)
|
||||
loginID, _ := c.Locals("login_id").(string)
|
||||
tenantID, _ := c.Locals("tenant_id").(string)
|
||||
|
||||
// 6. Capture & Mask Body
|
||||
var maskedBody string
|
||||
if config.BodyDump {
|
||||
if c.Method() != fiber.MethodGet && c.Method() != fiber.MethodHead {
|
||||
bodyBytes := c.Body()
|
||||
if len(bodyBytes) > 0 {
|
||||
maskedBytes := utils.MaskSensitiveJSON(bodyBytes)
|
||||
maskedBody = string(maskedBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Construct Details JSON
|
||||
details := map[string]any{
|
||||
"request_id": reqID,
|
||||
"method": c.Method(),
|
||||
"path": c.Path(),
|
||||
"status": status,
|
||||
"latency_ms": latency.Milliseconds(),
|
||||
"login_id": loginID,
|
||||
"tenant_id": tenantID,
|
||||
"request_body": maskedBody,
|
||||
}
|
||||
if err != nil {
|
||||
details["error"] = err.Error()
|
||||
}
|
||||
|
||||
detailsJSON, _ := json.Marshal(details)
|
||||
|
||||
// 8. Create Audit Log Object
|
||||
auditLog := &domain.AuditLog{
|
||||
EventID: reqID,
|
||||
Timestamp: start,
|
||||
UserID: userID,
|
||||
EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()),
|
||||
Status: statusText,
|
||||
IPAddress: c.IP(),
|
||||
UserAgent: c.Get("User-Agent"),
|
||||
Details: string(detailsJSON),
|
||||
}
|
||||
|
||||
// 9. Store Log (Policy Enforcement)
|
||||
_, isWrite := writeMethods[c.Method()]
|
||||
|
||||
if isNil(config.Repo) {
|
||||
if isWrite {
|
||||
slog.Error("Audit repository missing for command", "req_id", reqID)
|
||||
return fiber.NewError(fiber.StatusServiceUnavailable, "Audit system unavailable")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if isWrite {
|
||||
// Strict Mode: Synchronous write
|
||||
if createErr := config.Repo.Create(auditLog); createErr != nil {
|
||||
slog.Error("Failed to write audit log (sync)", "error", createErr, "req_id", reqID)
|
||||
return fiber.NewError(fiber.StatusServiceUnavailable, "Audit logging failed")
|
||||
}
|
||||
} else {
|
||||
// Best Effort: Load Shedding via Buffered Channel
|
||||
select {
|
||||
case auditQueue <- auditLog:
|
||||
// Successfully queued
|
||||
default:
|
||||
// Queue full -> DROP (Load Shedding)
|
||||
slog.Warn("Audit queue full, dropping log (load shedding)", "req_id", reqID, "path", c.Path())
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
117
backend/internal/middleware/audit_middleware_test.go
Normal file
117
backend/internal/middleware/audit_middleware_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockAuditRepository is a mock implementation of AuditRepository
|
||||
type MockAuditRepository struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockAuditRepository) Create(log *domain.AuditLog) error {
|
||||
args := m.Called(log)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockAuditRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor) ([]domain.AuditLog, error) {
|
||||
args := m.Called(ctx, limit, cursor)
|
||||
return args.Get(0).([]domain.AuditLog), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAuditRepository) Ping(ctx context.Context) error {
|
||||
args := m.Called(ctx)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func TestAuditMiddleware(t *testing.T) {
|
||||
t.Run("POST request - Sync Success", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockRepo := new(MockAuditRepository)
|
||||
|
||||
app.Use(AuditMiddleware(AuditConfig{
|
||||
Repo: mockRepo,
|
||||
BodyDump: true,
|
||||
}))
|
||||
|
||||
app.Post("/test", func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
mockRepo.On("Create", mock.MatchedBy(func(log *domain.AuditLog) bool {
|
||||
var details map[string]any
|
||||
json.Unmarshal([]byte(log.Details), &details)
|
||||
return log.Status == "success" &&
|
||||
details["method"] == "POST" &&
|
||||
details["request_body"] == `{"password":"*****","user":"test"}`
|
||||
})).Return(nil)
|
||||
|
||||
req := httptest.NewRequest("POST", "/test", strings.NewReader(`{"user": "test", "password": "mypassword"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
|
||||
mockRepo.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockRepo := new(MockAuditRepository)
|
||||
|
||||
app.Use(AuditMiddleware(AuditConfig{
|
||||
Repo: mockRepo,
|
||||
}))
|
||||
|
||||
app.Post("/test", func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
mockRepo.On("Create", mock.Anything).Return(errors.New("db error"))
|
||||
|
||||
req := httptest.NewRequest("POST", "/test", nil)
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
// Should return 503 because Audit failed on a Write method
|
||||
assert.Equal(t, fiber.StatusServiceUnavailable, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("GET request - Async Load Shedding", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockRepo := new(MockAuditRepository)
|
||||
|
||||
// Set very small queue and no workers to force load shedding
|
||||
app.Use(AuditMiddleware(AuditConfig{
|
||||
Repo: mockRepo,
|
||||
QueueSize: 1,
|
||||
WorkerCount: 0, // This will be defaulted to 5 by the code, so let's use another way or just small queue
|
||||
}))
|
||||
|
||||
app.Get("/test", func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
// 1. First request fills the queue
|
||||
mockRepo.On("Create", mock.Anything).Return(nil)
|
||||
|
||||
req1 := httptest.NewRequest("GET", "/test", nil)
|
||||
resp1, _ := app.Test(req1)
|
||||
assert.Equal(t, fiber.StatusOK, resp1.StatusCode)
|
||||
|
||||
// 2. Second request should be dropped (load shedding) if workers are slow
|
||||
// Since we can't easily pause workers without modifying code,
|
||||
// this test mostly ensures the non-blocking send doesn't hang.
|
||||
req2 := httptest.NewRequest("GET", "/test", nil)
|
||||
resp2, _ := app.Test(req2)
|
||||
assert.Equal(t, fiber.StatusOK, resp2.StatusCode)
|
||||
})
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AuditRequiredConfig struct {
|
||||
Repo domain.AuditRepository
|
||||
ExcludePaths map[string]struct{}
|
||||
CommandMethods map[string]struct{}
|
||||
}
|
||||
|
||||
func isNil(i any) bool {
|
||||
if i == nil {
|
||||
return true
|
||||
}
|
||||
v := reflect.ValueOf(i)
|
||||
return v.Kind() == reflect.Ptr && v.IsNil()
|
||||
}
|
||||
|
||||
func RequireAudit(config AuditRequiredConfig) fiber.Handler {
|
||||
commandMethods := config.CommandMethods
|
||||
if len(commandMethods) == 0 {
|
||||
commandMethods = map[string]struct{}{
|
||||
fiber.MethodPost: {},
|
||||
fiber.MethodPut: {},
|
||||
fiber.MethodPatch: {},
|
||||
fiber.MethodDelete: {},
|
||||
}
|
||||
}
|
||||
|
||||
excludePaths := config.ExcludePaths
|
||||
if excludePaths == nil {
|
||||
excludePaths = map[string]struct{}{}
|
||||
}
|
||||
|
||||
return func(c *fiber.Ctx) error {
|
||||
if _, ok := commandMethods[c.Method()]; !ok {
|
||||
return c.Next()
|
||||
}
|
||||
if _, excluded := excludePaths[c.Path()]; excluded {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
if isNil(config.Repo) {
|
||||
slog.Warn("audit repository is nil, skipping audit log creation", "path", c.Path())
|
||||
return c.Next() // Don't block the request, just skip audit
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
reqID := c.Get("X-Request-Id")
|
||||
if reqID == "" {
|
||||
reqID = uuid.New().String()
|
||||
c.Set("X-Request-Id", reqID)
|
||||
}
|
||||
|
||||
err := c.Next()
|
||||
latency := time.Since(start)
|
||||
|
||||
status := c.Response().StatusCode()
|
||||
if err != nil {
|
||||
if fiberErr, ok := err.(*fiber.Error); ok {
|
||||
status = fiberErr.Code
|
||||
} else {
|
||||
status = fiber.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
statusText := "success"
|
||||
if status >= fiber.StatusBadRequest {
|
||||
statusText = "failure"
|
||||
}
|
||||
|
||||
details := map[string]any{
|
||||
"request_id": reqID,
|
||||
"method": c.Method(),
|
||||
"path": c.Path(),
|
||||
"status": status,
|
||||
"latency_ms": latency.Milliseconds(),
|
||||
}
|
||||
if err != nil {
|
||||
details["error"] = err.Error()
|
||||
}
|
||||
|
||||
detailsJSON, jsonErr := json.Marshal(details)
|
||||
if jsonErr != nil {
|
||||
slog.Warn("failed to marshal audit details", "error", jsonErr, "req_id", reqID)
|
||||
}
|
||||
|
||||
auditLog := &domain.AuditLog{
|
||||
EventID: reqID,
|
||||
Timestamp: time.Now(),
|
||||
UserID: "",
|
||||
EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()),
|
||||
Status: statusText,
|
||||
IPAddress: c.IP(),
|
||||
UserAgent: c.Get("User-Agent"),
|
||||
DeviceID: "",
|
||||
Details: string(detailsJSON),
|
||||
}
|
||||
|
||||
if createErr := config.Repo.Create(auditLog); createErr != nil {
|
||||
slog.Error("audit log write failed", "error", createErr, "req_id", reqID, "path", c.Path())
|
||||
return fiber.NewError(fiber.StatusServiceUnavailable, "audit logging unavailable")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,101 @@ func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, er
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UserExists는 loginID(이메일/전화번호) 기준으로 사용자가 있는지 확인합니다.
|
||||
func (d *DescopeProvider) UserExists(loginID string) (bool, error) {
|
||||
if d.Client == nil {
|
||||
return false, fmt.Errorf("descope provider: client is nil")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if strings.Contains(loginID, "@") {
|
||||
user, err := d.Client.Management.User().Load(ctx, loginID)
|
||||
if err != nil {
|
||||
if isDescopeNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return user != nil, nil
|
||||
}
|
||||
|
||||
phone := normalizePhone(loginID)
|
||||
searchOptions := &descope.UserSearchOptions{
|
||||
Phones: []string{phone},
|
||||
Limit: 1,
|
||||
}
|
||||
users, _, err := d.Client.Management.User().SearchAll(ctx, searchOptions)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(users) > 0, nil
|
||||
}
|
||||
|
||||
// IssueSession은 비밀번호 없이 로그인 세션을 발급합니다.
|
||||
func (d *DescopeProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
|
||||
if d.Client == nil {
|
||||
return nil, fmt.Errorf("descope provider: client is nil")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
targetLoginID, err := d.resolveLoginID(loginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
embeddedToken, err := d.Client.Management.User().GenerateEmbeddedLink(ctx, targetLoginID, nil, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("descope provider: generate embedded link failed: %w", err)
|
||||
}
|
||||
|
||||
authInfo, err := d.Client.Auth.MagicLink().Verify(ctx, embeddedToken, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("descope provider: magic link verify failed: %w", err)
|
||||
}
|
||||
|
||||
res := &domain.AuthInfo{
|
||||
SessionToken: &domain.Token{
|
||||
JWT: authInfo.SessionToken.JWT,
|
||||
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
|
||||
},
|
||||
Subject: authInfo.User.UserID,
|
||||
}
|
||||
if authInfo.RefreshToken != nil {
|
||||
res.RefreshToken = &domain.Token{
|
||||
JWT: authInfo.RefreshToken.JWT,
|
||||
Expiration: time.Unix(authInfo.RefreshToken.Expiration, 0),
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (d *DescopeProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||
return nil, domain.ErrNotSupported
|
||||
}
|
||||
|
||||
func (d *DescopeProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||
return nil, domain.ErrNotSupported
|
||||
}
|
||||
|
||||
// GetPasswordPolicy는 Descope 비밀번호 정책을 반환합니다.
|
||||
func (d *DescopeProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
||||
if d.Client == nil {
|
||||
return nil, fmt.Errorf("descope provider: client is nil")
|
||||
}
|
||||
policy, err := d.Client.Auth.Password().GetPasswordPolicy(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &domain.PasswordPolicy{
|
||||
MinLength: int(policy.MinLength),
|
||||
Lowercase: policy.Lowercase,
|
||||
Uppercase: policy.Uppercase,
|
||||
Number: policy.Number,
|
||||
NonAlphanumeric: policy.NonAlphanumeric,
|
||||
MinCharacterTypes: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DescopeProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
|
||||
ctx := context.Background()
|
||||
err := d.Client.Auth.Password().SendPasswordReset(ctx, loginID, redirectUrl, nil)
|
||||
@@ -197,3 +292,57 @@ func (d *DescopeProvider) UpdateUserPassword(loginID, newPassword string, r *htt
|
||||
ctx := context.Background()
|
||||
return d.Client.Auth.Password().UpdateUserPassword(ctx, loginID, newPassword, r)
|
||||
}
|
||||
|
||||
func (d *DescopeProvider) resolveLoginID(loginID string) (string, error) {
|
||||
if strings.Contains(loginID, "@") {
|
||||
return loginID, nil
|
||||
}
|
||||
|
||||
phone := normalizePhone(loginID)
|
||||
searchOptions := &descope.UserSearchOptions{
|
||||
Phones: []string{phone},
|
||||
Limit: 1,
|
||||
}
|
||||
users, _, err := d.Client.Management.User().SearchAll(context.Background(), searchOptions)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("descope provider: user search failed: %w", err)
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return "", fmt.Errorf("descope provider: user not found")
|
||||
}
|
||||
if len(users[0].LoginIDs) > 0 {
|
||||
return users[0].LoginIDs[0], nil
|
||||
}
|
||||
if users[0].UserID != "" {
|
||||
return users[0].UserID, nil
|
||||
}
|
||||
return "", fmt.Errorf("descope provider: user found but login id missing")
|
||||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
normalized := strings.ReplaceAll(phone, "-", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
if strings.HasPrefix(normalized, "010") {
|
||||
return "+82" + normalized[1:]
|
||||
}
|
||||
if strings.HasPrefix(normalized, "82") {
|
||||
return "+" + normalized
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func isDescopeNotFound(err error) bool {
|
||||
if de, ok := err.(*descope.Error); ok {
|
||||
if rawStatus, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok {
|
||||
switch v := rawStatus.(type) {
|
||||
case int:
|
||||
return v == http.StatusNotFound
|
||||
case float64:
|
||||
return int(v) == http.StatusNotFound
|
||||
case string:
|
||||
return v == fmt.Sprintf("%d", http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -63,6 +64,15 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
if existingID != "" {
|
||||
return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email)
|
||||
}
|
||||
if user.PhoneNumber != "" {
|
||||
existingPhoneID, err := o.findIdentityID(user.PhoneNumber)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ory provider: search identity failed: %w", err)
|
||||
}
|
||||
if existingPhoneID != "" {
|
||||
return "", fmt.Errorf("ory provider: identity already exists for phone=%s", user.PhoneNumber)
|
||||
}
|
||||
}
|
||||
|
||||
traits := map[string]interface{}{
|
||||
"email": user.Email,
|
||||
@@ -84,6 +94,27 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
|
||||
},
|
||||
},
|
||||
}
|
||||
verifiable := []map[string]interface{}{
|
||||
{
|
||||
"value": user.Email,
|
||||
"verified": true,
|
||||
"via": "email",
|
||||
},
|
||||
}
|
||||
if user.PhoneNumber != "" {
|
||||
verifiable = append(verifiable, map[string]interface{}{
|
||||
"value": user.PhoneNumber,
|
||||
"verified": true,
|
||||
"via": "sms",
|
||||
})
|
||||
}
|
||||
payload["verifiable_addresses"] = verifiable
|
||||
payload["recovery_addresses"] = []map[string]interface{}{
|
||||
{
|
||||
"value": user.Email,
|
||||
"via": "email",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, fmt.Sprintf("%s/admin/identities", o.KratosAdminURL), bytes.NewReader(body))
|
||||
@@ -119,7 +150,7 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error)
|
||||
return nil, fmt.Errorf("ory provider: loginID and password are required")
|
||||
}
|
||||
|
||||
flowID, err := o.startLoginFlow()
|
||||
flowID, err := o.startLoginFlow("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -178,6 +209,462 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UserExists는 Kratos Admin API로 loginID 존재 여부를 확인합니다.
|
||||
func (o *OryProvider) UserExists(loginID string) (bool, error) {
|
||||
if loginID == "" {
|
||||
return false, fmt.Errorf("ory provider: loginID is empty")
|
||||
}
|
||||
identityID, err := o.findIdentityID(loginID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ory provider: find identity failed: %w", err)
|
||||
}
|
||||
return identityID != "", nil
|
||||
}
|
||||
|
||||
// IssueSession은 Ory에서 별도 세션 발급이 필요할 때 사용합니다. (현재 미지원)
|
||||
func (o *OryProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
|
||||
return nil, domain.ErrNotSupported
|
||||
}
|
||||
|
||||
// InitiateLinkLogin은 Kratos Public API로 링크 로그인 플로우를 시작하고 이메일 전송을 트리거합니다.
|
||||
func (o *OryProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||
if loginID == "" {
|
||||
return nil, fmt.Errorf("ory provider: loginID is required")
|
||||
}
|
||||
|
||||
effectiveLoginID, err := o.resolveEffectiveLoginID(loginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := o.ensureCodeLoginIdentifier(effectiveLoginID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
init, err := o.submitLoginCodeInit(effectiveLoginID, returnTo)
|
||||
if err == nil {
|
||||
init.LoginID = effectiveLoginID
|
||||
return init, nil
|
||||
}
|
||||
|
||||
if shouldBootstrapCodeLogin(err) {
|
||||
if ensureErr := o.ensureCodeLoginIdentifier(effectiveLoginID); ensureErr == nil {
|
||||
init, initErr := o.submitLoginCodeInit(effectiveLoginID, returnTo)
|
||||
if initErr == nil {
|
||||
init.LoginID = effectiveLoginID
|
||||
}
|
||||
return init, initErr
|
||||
} else {
|
||||
slog.Warn("Ory code login bootstrap failed", "loginID", effectiveLoginID, "error", ensureErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (o *OryProvider) resolveEffectiveLoginID(loginID string) (string, error) {
|
||||
if strings.Contains(loginID, "@") {
|
||||
return loginID, nil
|
||||
}
|
||||
|
||||
identityID, err := o.findIdentityID(loginID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if identityID == "" {
|
||||
return "", fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
|
||||
}
|
||||
|
||||
fullIdentity, err := o.fetchIdentityFull(identityID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if fullIdentity != nil {
|
||||
if emailRaw, ok := fullIdentity.Traits["email"]; ok {
|
||||
if email, ok := emailRaw.(string); ok && email != "" {
|
||||
return email, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ory provider: email trait missing for loginID=%s", loginID)
|
||||
}
|
||||
|
||||
func (o *OryProvider) submitLoginCodeInit(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||
flowID, err := o.startLoginFlow(returnTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"method": "code",
|
||||
"identifier": loginID,
|
||||
})
|
||||
loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ory provider: build link login request failed: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := o.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ory provider: link login request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode >= 300 {
|
||||
init, ok := parseKratosLinkLoginResponse(flowID, respBody)
|
||||
if ok {
|
||||
slog.Info("Ory link login initiated with non-2xx response", "loginID", loginID, "flow_id", flowID, "status", resp.StatusCode)
|
||||
return init, nil
|
||||
}
|
||||
return nil, fmt.Errorf("ory provider: link login failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
_ = json.Unmarshal(respBody, &result)
|
||||
|
||||
slog.Info("Ory link login initiated", "loginID", loginID, "flow_id", flowID)
|
||||
|
||||
return &domain.LinkLoginInit{
|
||||
FlowID: flowID,
|
||||
ExpiresAt: result.ExpiresAt,
|
||||
Mode: "link",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseKratosLinkLoginResponse(flowID string, body []byte) (*domain.LinkLoginInit, bool) {
|
||||
if len(body) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
var parsed struct {
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
State string `json:"state"`
|
||||
Active string `json:"active"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
state := strings.ToLower(parsed.State)
|
||||
active := strings.ToLower(parsed.Active)
|
||||
if strings.Contains(state, "sent") || active == "code" {
|
||||
return &domain.LinkLoginInit{
|
||||
FlowID: flowID,
|
||||
ExpiresAt: parsed.ExpiresAt,
|
||||
Mode: "link",
|
||||
}, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func shouldBootstrapCodeLogin(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "has not setup sign in with code") ||
|
||||
strings.Contains(msg, "4000035")
|
||||
}
|
||||
|
||||
type kratosVerifiableAddress struct {
|
||||
Value string `json:"value"`
|
||||
Via string `json:"via"`
|
||||
Verified bool `json:"verified"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
|
||||
identityID, err := o.findIdentityID(loginID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ory provider: find identity failed: %w", err)
|
||||
}
|
||||
if identityID == "" {
|
||||
return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
|
||||
}
|
||||
|
||||
identity, err := o.fetchIdentity(identityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
via := "sms"
|
||||
if strings.Contains(loginID, "@") {
|
||||
via = "email"
|
||||
}
|
||||
|
||||
exists := false
|
||||
existingIndex := -1
|
||||
addresses := make([]kratosVerifiableAddress, 0, len(identity.VerifiableAddresses)+1)
|
||||
for idx, addr := range identity.VerifiableAddresses {
|
||||
addresses = append(addresses, kratosVerifiableAddress{
|
||||
Value: addr.Value,
|
||||
Via: addr.Via,
|
||||
Verified: addr.Verified,
|
||||
Status: addr.Status,
|
||||
})
|
||||
if addr.Value == loginID && addr.Via == via {
|
||||
exists = true
|
||||
existingIndex = idx
|
||||
}
|
||||
}
|
||||
ops := make([]map[string]interface{}, 0, 2)
|
||||
if !exists {
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"op": "add",
|
||||
"path": "/verifiable_addresses/-",
|
||||
"value": map[string]interface{}{
|
||||
"value": loginID,
|
||||
"via": via,
|
||||
"verified": true,
|
||||
"status": "completed",
|
||||
},
|
||||
})
|
||||
} else {
|
||||
addr := identity.VerifiableAddresses[existingIndex]
|
||||
if !addr.Verified {
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"op": "replace",
|
||||
"path": fmt.Sprintf("/verifiable_addresses/%d/verified", existingIndex),
|
||||
"value": true,
|
||||
})
|
||||
}
|
||||
if addr.Status != "" && addr.Status != "completed" {
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"op": "replace",
|
||||
"path": fmt.Sprintf("/verifiable_addresses/%d/status", existingIndex),
|
||||
"value": "completed",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(ops) == 0 {
|
||||
slog.Info("Ory identity verifiable address already ready", "identity_id", identityID, "loginID", loginID, "via", via)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := o.patchIdentity(identityID, ops); err != nil {
|
||||
slog.Warn("Ory identity patch failed, trying full update", "identity_id", identityID, "error", err)
|
||||
}
|
||||
|
||||
fullIdentity, err := o.fetchIdentityFull(identityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addresses = make([]kratosVerifiableAddress, 0, len(fullIdentity.VerifiableAddresses)+1)
|
||||
found := false
|
||||
for _, addr := range fullIdentity.VerifiableAddresses {
|
||||
addresses = append(addresses, kratosVerifiableAddress{
|
||||
Value: addr.Value,
|
||||
Via: addr.Via,
|
||||
Verified: addr.Verified,
|
||||
Status: addr.Status,
|
||||
})
|
||||
if addr.Value == loginID && addr.Via == via {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
addresses = append(addresses, kratosVerifiableAddress{
|
||||
Value: loginID,
|
||||
Via: via,
|
||||
Verified: true,
|
||||
Status: "completed",
|
||||
})
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"schema_id": fullIdentity.SchemaID,
|
||||
"traits": fullIdentity.Traits,
|
||||
"verifiable_addresses": addresses,
|
||||
}
|
||||
if len(fullIdentity.RecoveryAddresses) > 0 {
|
||||
payload["recovery_addresses"] = fullIdentity.RecoveryAddresses
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("ory provider: build identity update failed: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := o.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ory provider: identity update failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("ory provider: identity update failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
slog.Info("Ory identity updated with verifiable address", "identity_id", identityID, "loginID", loginID, "via", via)
|
||||
return nil
|
||||
}
|
||||
|
||||
type kratosIdentity struct {
|
||||
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
|
||||
}
|
||||
|
||||
type kratosRecoveryAddress struct {
|
||||
Value string `json:"value"`
|
||||
Via string `json:"via"`
|
||||
}
|
||||
|
||||
type kratosIdentityFull struct {
|
||||
SchemaID string `json:"schema_id"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
|
||||
RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"`
|
||||
}
|
||||
|
||||
func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error {
|
||||
body, _ := json.Marshal(ops)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("ory provider: build identity patch failed: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
||||
|
||||
resp, err := o.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ory provider: identity patch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("ory provider: identity patch failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
slog.Info("Ory identity patched", "identity_id", identityID, "ops", len(ops))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *OryProvider) fetchIdentity(identityID string) (*kratosIdentity, error) {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ory provider: build identity get failed: %w", err)
|
||||
}
|
||||
|
||||
resp, err := o.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ory provider: identity get failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("ory provider: identity get failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var identity kratosIdentity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
|
||||
return nil, fmt.Errorf("ory provider: decode identity failed: %w", err)
|
||||
}
|
||||
return &identity, nil
|
||||
}
|
||||
|
||||
func (o *OryProvider) fetchIdentityFull(identityID string) (*kratosIdentityFull, error) {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ory provider: build identity get failed: %w", err)
|
||||
}
|
||||
|
||||
resp, err := o.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ory provider: identity get failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("ory provider: identity get failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var identity kratosIdentityFull
|
||||
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
|
||||
return nil, fmt.Errorf("ory provider: decode identity failed: %w", err)
|
||||
}
|
||||
return &identity, nil
|
||||
}
|
||||
|
||||
// VerifyLoginCode는 Kratos 로그인 코드 제출로 세션을 발급합니다.
|
||||
func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||
if loginID == "" || flowID == "" || code == "" {
|
||||
return nil, fmt.Errorf("ory provider: loginID, flowID and code are required")
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"method": "code",
|
||||
"identifier": loginID,
|
||||
"code": code,
|
||||
})
|
||||
loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ory provider: build login code request failed: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := o.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ory provider: login code request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("ory provider: login code failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
SessionToken string `json:"session_token"`
|
||||
SessionTokenExpiresAt time.Time `json:"session_token_expires_at"`
|
||||
Session struct {
|
||||
Identity struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"identity"`
|
||||
} `json:"session"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("ory provider: decode login code response failed: %w", err)
|
||||
}
|
||||
if result.SessionToken == "" {
|
||||
return nil, fmt.Errorf("ory provider: empty session token returned")
|
||||
}
|
||||
|
||||
slog.Info("Ory login code successful",
|
||||
"identity_id", result.Session.Identity.ID,
|
||||
"loginID", loginID,
|
||||
"expires_at", result.SessionTokenExpiresAt,
|
||||
)
|
||||
|
||||
return &domain.AuthInfo{
|
||||
SessionToken: &domain.Token{
|
||||
JWT: result.SessionToken,
|
||||
Expiration: result.SessionTokenExpiresAt,
|
||||
},
|
||||
Subject: result.Session.Identity.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPasswordPolicy는 Ory 환경에서 사용하는 기본 정책을 반환합니다.
|
||||
func (o *OryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
||||
return &domain.PasswordPolicy{
|
||||
MinLength: 12,
|
||||
Lowercase: true,
|
||||
Uppercase: false,
|
||||
Number: true,
|
||||
NonAlphanumeric: true,
|
||||
MinCharacterTypes: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InitiatePasswordReset는 현재 내부 토큰/메일 흐름을 사용하고 있으므로 NO-OP로 둡니다.
|
||||
func (o *OryProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
|
||||
slog.Info("Ory InitiatePasswordReset bypassed (handled by app internal flow)", "loginID", loginID, "redirect", redirectUrl)
|
||||
@@ -301,8 +788,12 @@ func (o *OryProvider) httpClient() *http.Client {
|
||||
}
|
||||
|
||||
// startLoginFlow는 Kratos Public API에서 login flow ID를 발급받습니다.
|
||||
func (o *OryProvider) startLoginFlow() (string, error) {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/self-service/login/api", o.KratosPublicURL), nil)
|
||||
func (o *OryProvider) startLoginFlow(returnTo string) (string, error) {
|
||||
loginURL := fmt.Sprintf("%s/self-service/login/api", o.KratosPublicURL)
|
||||
if returnTo != "" {
|
||||
loginURL = loginURL + "?return_to=" + url.QueryEscape(returnTo)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, loginURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ory provider: build login flow request failed: %w", err)
|
||||
}
|
||||
|
||||
79
backend/internal/utils/masking.go
Normal file
79
backend/internal/utils/masking.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var sensitiveKeys = map[string]struct{}{
|
||||
"password": {},
|
||||
"newpassword": {},
|
||||
"oldpassword": {},
|
||||
"token": {},
|
||||
"accesstoken": {},
|
||||
"access_token": {},
|
||||
"refreshtoken": {},
|
||||
"refresh_token": {},
|
||||
"secret": {},
|
||||
"clientsecret": {},
|
||||
"client_secret": {},
|
||||
"authorization": {},
|
||||
"cookie": {},
|
||||
"set-cookie": {},
|
||||
"verificationcode": {},
|
||||
"verification_code": {},
|
||||
"code": {}, // Auth code (sensitive)
|
||||
}
|
||||
|
||||
// MaskSensitiveJSON parses a JSON byte slice and masks values of sensitive keys.
|
||||
// Returns the original data if it's not valid JSON.
|
||||
func MaskSensitiveJSON(data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
|
||||
var obj interface{}
|
||||
if err := json.Unmarshal(data, &obj); err != nil {
|
||||
// Not a JSON object/array, return as is
|
||||
return data
|
||||
}
|
||||
|
||||
masked := maskValue(obj)
|
||||
|
||||
result, err := json.Marshal(masked)
|
||||
if err != nil {
|
||||
return data
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func maskValue(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case map[string]interface{}:
|
||||
newMap := make(map[string]interface{}, len(val))
|
||||
for k, v := range val {
|
||||
if isSensitive(k) {
|
||||
newMap[k] = "*****"
|
||||
} else {
|
||||
newMap[k] = maskValue(v)
|
||||
}
|
||||
}
|
||||
return newMap
|
||||
case []interface{}:
|
||||
newArr := make([]interface{}, len(val))
|
||||
for i, v := range val {
|
||||
newArr[i] = maskValue(v)
|
||||
}
|
||||
return newArr
|
||||
default:
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
func isSensitive(key string) bool {
|
||||
// Check case-insensitive
|
||||
// Remove common separators for looser matching? No, stick to lowercase check for now.
|
||||
k := strings.ToLower(key)
|
||||
_, ok := sensitiveKeys[k]
|
||||
return ok
|
||||
}
|
||||
59
backend/internal/utils/masking_test.go
Normal file
59
backend/internal/utils/masking_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMaskSensitiveJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string // We'll check containment or specific structure
|
||||
}{
|
||||
{
|
||||
name: "Flat object with password",
|
||||
input: `{"username": "user", "password": "secret123"}`,
|
||||
expected: `{"password":"*****","username":"user"}`,
|
||||
},
|
||||
{
|
||||
name: "Nested object with token",
|
||||
input: `{"data": {"token": "abc-def", "id": 123}}`,
|
||||
expected: `{"data":{"id":123,"token":"*****"}}`,
|
||||
},
|
||||
{
|
||||
name: "Case insensitive key",
|
||||
input: `{"NewPassword": "changed"}`,
|
||||
expected: `{"NewPassword":"*****"}`,
|
||||
},
|
||||
{
|
||||
name: "Array of objects",
|
||||
input: `[{"secret": "s1"}, {"secret": "s2"}]`,
|
||||
expected: `[{"secret":"*****"},{"secret":"*****"}]`,
|
||||
},
|
||||
{
|
||||
name: "Invalid JSON",
|
||||
input: `not-json`,
|
||||
expected: `not-json`,
|
||||
},
|
||||
{
|
||||
name: "Empty JSON",
|
||||
input: ``,
|
||||
expected: ``,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := MaskSensitiveJSON([]byte(tt.input))
|
||||
// Since JSON map order is undefined, exact string match might fail if keys are reordered.
|
||||
// Ideally we should unmarshal and compare maps, or use assert.JSONEq
|
||||
if tt.name == "Invalid JSON" || tt.name == "Empty JSON" {
|
||||
assert.Equal(t, tt.expected, string(result))
|
||||
} else {
|
||||
assert.JSONEq(t, tt.expected, string(result))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,26 @@ func (m *MockProvider) SignIn(loginID, password string) (*domain.AuthInfo, error
|
||||
return &domain.AuthInfo{}, nil
|
||||
}
|
||||
|
||||
func (m *MockProvider) UserExists(loginID string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *MockProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
|
||||
return nil, domain.ErrNotSupported
|
||||
}
|
||||
|
||||
func (m *MockProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||
return nil, domain.ErrNotSupported
|
||||
}
|
||||
|
||||
func (m *MockProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||
return nil, domain.ErrNotSupported
|
||||
}
|
||||
|
||||
func (m *MockProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
||||
return nil, domain.ErrNotSupported
|
||||
}
|
||||
|
||||
// Stub implementations to satisfy the IdentityProvider interface for this unit test.
|
||||
func (m *MockProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
|
||||
return nil
|
||||
|
||||
@@ -27,6 +27,7 @@ services:
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:latest
|
||||
container_name: baron_clickhouse
|
||||
restart: always
|
||||
environment:
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-baron}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-password}
|
||||
|
||||
@@ -71,27 +71,9 @@ services:
|
||||
- ory-net
|
||||
- kratosnet
|
||||
|
||||
kratos-mcp-server:
|
||||
build:
|
||||
context: ./mcp/kratos-mcp
|
||||
container_name: mcp_ory_kratos
|
||||
profiles:
|
||||
- mcp
|
||||
stdin_open: true
|
||||
tty: true
|
||||
init: true
|
||||
environment:
|
||||
- KRATOS_ADMIN_URL=http://kratos:4434
|
||||
depends_on:
|
||||
- kratos
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
kratos-ui:
|
||||
image: oryd/kratos-selfservice-ui-node:${KRATOS_UI_NODE_VERSION:-v25.4.0}
|
||||
container_name: ory_kratos_ui
|
||||
ports:
|
||||
- "${KRATOS_UI_PORT:-4455}:4455"
|
||||
environment:
|
||||
- KRATOS_PUBLIC_URL=${KRATOS_PUBLIC_URL:-http://kratos:4433/}
|
||||
- KRATOS_BROWSER_URL=${KRATOS_BROWSER_URL:-http://localhost:${KRATOS_PUBLIC_PORT:-4433}}
|
||||
@@ -119,8 +101,6 @@ services:
|
||||
hydra:
|
||||
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
|
||||
container_name: ory_hydra
|
||||
ports:
|
||||
- "${HYDRA_PUBLIC_PORT:-4441}:4444"
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20
|
||||
- URLS_SELF_ISSUER=${BACKEND_URL:-http://127.0.0.1:3000}
|
||||
@@ -137,39 +117,7 @@ services:
|
||||
- ory-net
|
||||
- hydranet
|
||||
|
||||
hydra-mcp-server:
|
||||
build:
|
||||
context: ./mcp/hydra-mcp
|
||||
container_name: mcp_ory_hydra
|
||||
profiles:
|
||||
- mcp
|
||||
stdin_open: true
|
||||
tty: true
|
||||
init: true
|
||||
environment:
|
||||
- HYDRA_PUBLIC_URL=http://hydra:4444
|
||||
- HYDRA_ADMIN_URL=http://hydra:4445
|
||||
depends_on:
|
||||
- hydra
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
keto-mcp-server:
|
||||
build:
|
||||
context: ./mcp/keto-mcp
|
||||
container_name: mcp_ory_keto
|
||||
profiles:
|
||||
- mcp
|
||||
stdin_open: true
|
||||
tty: true
|
||||
init: true
|
||||
environment:
|
||||
- KETO_READ_URL=http://keto:4466
|
||||
- KETO_WRITE_URL=http://keto:4467
|
||||
depends_on:
|
||||
- keto
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
# --- Keto ---
|
||||
keto-migrate:
|
||||
@@ -188,9 +136,6 @@ services:
|
||||
keto:
|
||||
image: oryd/keto:${KETO_VERSION:-v25.4.0}
|
||||
container_name: ory_keto
|
||||
ports:
|
||||
- "${KETO_READ_PORT:-4466}:4466"
|
||||
- "${KETO_WRITE_PORT:-4467}:4467"
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20
|
||||
volumes:
|
||||
@@ -204,16 +149,43 @@ services:
|
||||
|
||||
# --- Oathkeeper ---
|
||||
oathkeeper:
|
||||
image: oryd/oathkeeper:v0.40.6
|
||||
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v25.4.0}
|
||||
container_name: ory_oathkeeper
|
||||
user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}"
|
||||
ports:
|
||||
- "4456:4456" # API
|
||||
- "4457:4455" # Proxy
|
||||
environment:
|
||||
- LOG_LEVEL=debug
|
||||
- APP_ENV=${APP_ENV:-development}
|
||||
volumes:
|
||||
- ./docker/ory/oathkeeper:/etc/config/oathkeeper
|
||||
command: serve proxy -c /etc/config/oathkeeper/oathkeeper.yml
|
||||
- ./docker/ory/oathkeeper/logs:/var/log/oathkeeper
|
||||
entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"]
|
||||
networks:
|
||||
- ory-net
|
||||
- public_net
|
||||
|
||||
ory_clickhouse:
|
||||
image: clickhouse/clickhouse-server:latest
|
||||
container_name: ory_clickhouse
|
||||
environment:
|
||||
- CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory}
|
||||
- CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass}
|
||||
volumes:
|
||||
- ory_clickhouse_data:/var/lib/clickhouse
|
||||
- ./docker/ory/clickhouse:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
ory_vector:
|
||||
image: timberio/vector:0.36.0-alpine
|
||||
container_name: ory_vector
|
||||
volumes:
|
||||
- ./docker/ory/vector:/etc/vector
|
||||
- ./docker/ory/oathkeeper/logs:/var/log/oathkeeper
|
||||
command: ["-c", "/etc/vector/vector.toml"]
|
||||
depends_on:
|
||||
- oathkeeper
|
||||
- ory_clickhouse
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
@@ -268,6 +240,7 @@ services:
|
||||
|
||||
volumes:
|
||||
ory_postgres_data:
|
||||
ory_clickhouse_data:
|
||||
|
||||
networks:
|
||||
ory-net:
|
||||
@@ -279,3 +252,6 @@ networks:
|
||||
kratosnet:
|
||||
external: true
|
||||
name: kratosnet
|
||||
public_net:
|
||||
external: true
|
||||
name: public_net
|
||||
|
||||
@@ -36,7 +36,7 @@ services:
|
||||
- ory-net
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
command: ["go", "run", "./cmd/server/main.go"]
|
||||
command: ["go", "run", "./cmd/server"]
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
@@ -80,7 +80,6 @@ services:
|
||||
- /app/node_modules
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
userfront:
|
||||
build:
|
||||
context: ./userfront
|
||||
@@ -97,6 +96,8 @@ services:
|
||||
- "${USERFRONT_PORT:-5000}:5000"
|
||||
networks:
|
||||
- baron_net
|
||||
- public_net
|
||||
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
@@ -126,3 +127,6 @@ networks:
|
||||
ory-net:
|
||||
external: true
|
||||
name: ory-net
|
||||
public_net:
|
||||
external: true
|
||||
name: public_net
|
||||
|
||||
22
docker/ory/clickhouse/init.sql
Normal file
22
docker/ory/clickhouse/init.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
CREATE DATABASE IF NOT EXISTS ory;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ory.oathkeeper_access_logs (
|
||||
timestamp DateTime64(3) DEFAULT now64(3),
|
||||
request_id String DEFAULT '',
|
||||
method String DEFAULT '',
|
||||
path String DEFAULT '',
|
||||
status UInt16 DEFAULT 0,
|
||||
latency_ms UInt32 DEFAULT 0,
|
||||
rp String DEFAULT '',
|
||||
action String DEFAULT '',
|
||||
target String DEFAULT '',
|
||||
subject String DEFAULT '',
|
||||
client_ip String DEFAULT '',
|
||||
user_agent String DEFAULT '',
|
||||
decision String DEFAULT '',
|
||||
trace_id String DEFAULT '',
|
||||
span_id String DEFAULT '',
|
||||
raw String DEFAULT ''
|
||||
) ENGINE = MergeTree()
|
||||
ORDER BY (timestamp, request_id)
|
||||
TTL timestamp + INTERVAL 30 DAY;
|
||||
8
docker/ory/kratos/courier-http.jsonnet
Normal file
8
docker/ory/kratos/courier-http.jsonnet
Normal file
@@ -0,0 +1,8 @@
|
||||
// Kratos courier HTTP payload을 backend로 전달하는 템플릿입니다.
|
||||
function(ctx)
|
||||
local data = if std.objectHas(ctx, "template_data") && ctx.template_data != null then ctx.template_data else {};
|
||||
{
|
||||
recipient: ctx.recipient,
|
||||
template_type: ctx.template_type,
|
||||
template_data: data,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<body style="font-family: sans-serif; line-height: 1.6;">
|
||||
<h2>Baron SSO 로그인</h2>
|
||||
<p>아래 버튼을 클릭하면 로그인이 완료됩니다.</p>
|
||||
<!-- 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }} -->
|
||||
<p>
|
||||
<a href="http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }}"
|
||||
style="display: inline-block; padding: 10px 16px; background: #1a1f2c; color: #fff; text-decoration: none; border-radius: 6px;">
|
||||
로그인 완료하기
|
||||
</a>
|
||||
</p>
|
||||
<p>또는 아래 로그인 코드를 입력해도 됩니다.</p>
|
||||
<p style="font-size: 18px; font-weight: bold;">{{ .LoginCode }}</p>
|
||||
<p style="color: #666; font-size: 12px;">요청하지 않았다면 이 메일을 무시해 주세요.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
Baron SSO 로그인
|
||||
|
||||
# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }}
|
||||
|
||||
아래 링크를 클릭하면 로그인이 완료됩니다.
|
||||
http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }}
|
||||
|
||||
로그인 코드: {{ .LoginCode }}
|
||||
|
||||
요청하지 않았다면 이 메일을 무시해 주세요.
|
||||
@@ -0,0 +1 @@
|
||||
Baron SSO 로그인 링크
|
||||
@@ -0,0 +1,4 @@
|
||||
[Baron 통합로그인] 로그인 링크
|
||||
# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }}
|
||||
http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }}
|
||||
코드: {{ .LoginCode }}
|
||||
@@ -16,6 +16,10 @@
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
},
|
||||
"code": {
|
||||
"identifier": true,
|
||||
"via": "email"
|
||||
}
|
||||
},
|
||||
"recovery": {
|
||||
@@ -27,17 +31,68 @@
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"first": {
|
||||
"type": "string",
|
||||
"title": "First Name"
|
||||
},
|
||||
"last": {
|
||||
"type": "string",
|
||||
"title": "Last Name"
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": "string",
|
||||
"title": "Phone Number",
|
||||
"minLength": 7,
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
},
|
||||
"code": {
|
||||
"identifier": true,
|
||||
"via": "sms"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"department": {
|
||||
"type": "string",
|
||||
"title": "Department"
|
||||
},
|
||||
"affiliationType": {
|
||||
"type": "string",
|
||||
"title": "Affiliation Type"
|
||||
},
|
||||
"companyCode": {
|
||||
"type": "string",
|
||||
"title": "Company Code"
|
||||
},
|
||||
"displayname": {
|
||||
"type": "string",
|
||||
"title": "Display Name"
|
||||
},
|
||||
"completeForm": {
|
||||
"type": "boolean",
|
||||
"title": "Complete Form"
|
||||
},
|
||||
"team": {
|
||||
"type": "string",
|
||||
"title": "Team"
|
||||
},
|
||||
"taxCode": {
|
||||
"type": "string",
|
||||
"title": "Tax Code"
|
||||
},
|
||||
"familyCompany": {
|
||||
"type": "string",
|
||||
"title": "Family Company"
|
||||
},
|
||||
"familyUniqueKey": {
|
||||
"type": "string",
|
||||
"title": "Family Unique Key"
|
||||
},
|
||||
"personal": {
|
||||
"type": "boolean",
|
||||
"title": "Personal"
|
||||
},
|
||||
"grade": {
|
||||
"type": "string",
|
||||
"title": "Grade"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -46,4 +101,4 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,74 +3,90 @@ version: v1.3.0
|
||||
dsn: memory
|
||||
|
||||
serve:
|
||||
public:
|
||||
base_url: http://localhost:4433/
|
||||
cors:
|
||||
enabled: true
|
||||
admin:
|
||||
base_url: http://localhost:4434/
|
||||
public:
|
||||
base_url: http://localhost:4433/
|
||||
cors:
|
||||
enabled: true
|
||||
admin:
|
||||
base_url: http://localhost:4434/
|
||||
|
||||
selfservice:
|
||||
default_browser_return_url: http://localhost:4455/
|
||||
allowed_return_urls:
|
||||
- http://localhost:4455
|
||||
- http://localhost:5000
|
||||
default_browser_return_url: http://localhost:4455/
|
||||
allowed_return_urls:
|
||||
- http://localhost:4455
|
||||
- http://localhost:5000
|
||||
- https://sss.hmac.kr
|
||||
- https://sss.hmac.kr/
|
||||
- https://sso.hmac.kr
|
||||
- https://sso.hmac.kr/
|
||||
- https://app.hmac.kr
|
||||
- https://app.hmac.kr/
|
||||
|
||||
methods:
|
||||
password:
|
||||
enabled: true
|
||||
link:
|
||||
enabled: true
|
||||
code:
|
||||
enabled: true
|
||||
methods:
|
||||
password:
|
||||
enabled: true
|
||||
link:
|
||||
enabled: true
|
||||
code:
|
||||
enabled: true
|
||||
passwordless_enabled: true
|
||||
|
||||
flows:
|
||||
error:
|
||||
ui_url: http://localhost:4455/error
|
||||
settings:
|
||||
ui_url: http://localhost:4455/settings
|
||||
privileged_session_max_age: 15m
|
||||
recovery:
|
||||
ui_url: http://localhost:4455/recovery
|
||||
use: code
|
||||
verification:
|
||||
ui_url: http://localhost:4455/verification
|
||||
use: code
|
||||
logout:
|
||||
after:
|
||||
default_browser_return_url: http://localhost:4455/login
|
||||
login:
|
||||
ui_url: http://localhost:4455/login
|
||||
lifespan: 10m
|
||||
registration:
|
||||
ui_url: http://localhost:4455/registration
|
||||
lifespan: 10m
|
||||
flows:
|
||||
error:
|
||||
ui_url: http://localhost:4455/error
|
||||
settings:
|
||||
ui_url: http://localhost:4455/settings
|
||||
privileged_session_max_age: 15m
|
||||
recovery:
|
||||
ui_url: http://localhost:4455/recovery
|
||||
use: code
|
||||
verification:
|
||||
ui_url: http://localhost:4455/verification
|
||||
use: code
|
||||
logout:
|
||||
after:
|
||||
default_browser_return_url: http://localhost:4455/login
|
||||
login:
|
||||
ui_url: http://localhost:4455/login
|
||||
lifespan: 10m
|
||||
registration:
|
||||
ui_url: http://localhost:4455/registration
|
||||
lifespan: 10m
|
||||
|
||||
log:
|
||||
level: debug
|
||||
format: text
|
||||
leak_sensitive_values: true
|
||||
level: debug
|
||||
format: text
|
||||
leak_sensitive_values: true
|
||||
|
||||
secrets:
|
||||
cookie:
|
||||
- PLEASE-CHANGE-ME-I-AM-VERY-INSECURE
|
||||
cipher:
|
||||
- 32-LONG-SECRET-NOT-SECURE-AT-ALL
|
||||
cookie:
|
||||
- PLEASE-CHANGE-ME-I-AM-VERY-INSECURE
|
||||
cipher:
|
||||
- 32-LONG-SECRET-NOT-SECURE-AT-ALL
|
||||
|
||||
ciphers:
|
||||
algorithm: xchacha20-poly1305
|
||||
algorithm: xchacha20-poly1305
|
||||
|
||||
hashers:
|
||||
algorithm: bcrypt
|
||||
bcrypt:
|
||||
cost: 8
|
||||
algorithm: bcrypt
|
||||
bcrypt:
|
||||
cost: 8
|
||||
|
||||
identity:
|
||||
default_schema_id: default
|
||||
schemas:
|
||||
- id: default
|
||||
url: file:///etc/config/kratos/identity.schema.json
|
||||
default_schema_id: default
|
||||
schemas:
|
||||
- id: default
|
||||
url: file:///etc/config/kratos/identity.schema.json
|
||||
|
||||
courier:
|
||||
smtp:
|
||||
connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true
|
||||
template_override_path: /etc/config/kratos/courier-templates
|
||||
delivery_strategy: http
|
||||
http:
|
||||
request_config:
|
||||
url: http://baron_backend:3000/api/v1/auth/webhooks/kratos-courier
|
||||
method: POST
|
||||
body: file:///etc/config/kratos/courier-http.jsonnet
|
||||
headers:
|
||||
Content-Type: application/json
|
||||
smtp:
|
||||
connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true
|
||||
|
||||
38
docker/ory/oathkeeper/entrypoint.sh
Executable file
38
docker/ory/oathkeeper/entrypoint.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
APP_ENV_VALUE="${APP_ENV:-}"
|
||||
|
||||
case "$APP_ENV_VALUE" in
|
||||
production|prod)
|
||||
RULES_FILE="/etc/config/oathkeeper/rules.prod.json"
|
||||
;;
|
||||
stage|staging)
|
||||
RULES_FILE="/etc/config/oathkeeper/rules.stage.json"
|
||||
;;
|
||||
*)
|
||||
RULES_FILE="/etc/config/oathkeeper/rules.json"
|
||||
;;
|
||||
esac
|
||||
|
||||
export RULES_FILE
|
||||
|
||||
echo "[oathkeeper] APP_ENV=$APP_ENV_VALUE rules=$RULES_FILE"
|
||||
|
||||
RULES_ACTIVE="/etc/config/oathkeeper/rules.active.json"
|
||||
if [ ! -f "$RULES_FILE" ]; then
|
||||
echo "[oathkeeper] rules file not found: $RULES_FILE"
|
||||
exit 1
|
||||
fi
|
||||
cp "$RULES_FILE" "$RULES_ACTIVE"
|
||||
|
||||
LOG_DIR="/var/log/oathkeeper"
|
||||
LOG_FILE="${LOG_DIR}/access.log"
|
||||
mkdir -p "$LOG_DIR"
|
||||
if ! touch "$LOG_FILE" 2>/dev/null; then
|
||||
echo "[oathkeeper] log file not writable: $LOG_FILE"
|
||||
ls -ld "$LOG_DIR" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml 2>&1 | tee \"$LOG_FILE\""
|
||||
@@ -4,13 +4,17 @@ serve:
|
||||
api:
|
||||
port: 4456
|
||||
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
|
||||
errors:
|
||||
fallback:
|
||||
- json
|
||||
|
||||
access_rules:
|
||||
repositories:
|
||||
- file:///etc/config/oathkeeper/rules.json
|
||||
- file:///etc/config/oathkeeper/rules.active.json
|
||||
|
||||
authenticators:
|
||||
noop:
|
||||
@@ -30,6 +34,13 @@ authorizers:
|
||||
enabled: true
|
||||
config:
|
||||
remote: http://keto:4466/check
|
||||
payload: |
|
||||
{
|
||||
"namespace": "permissions",
|
||||
"object": "{{ print .Request.URL.Path }}",
|
||||
"relation": "access",
|
||||
"subject_id": "{{ print .Subject }}"
|
||||
}
|
||||
|
||||
mutators:
|
||||
noop:
|
||||
|
||||
92
docker/ory/oathkeeper/rules.active.json
Normal file
92
docker/ory/oathkeeper/rules.active.json
Normal file
@@ -0,0 +1,92 @@
|
||||
[
|
||||
{
|
||||
"id": "public-health",
|
||||
"description": "공개 헬스체크",
|
||||
"match": {
|
||||
"url": "http://<.*>/health",
|
||||
"methods": ["GET"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "public-preflight",
|
||||
"description": "CORS preflight",
|
||||
"match": {
|
||||
"url": "http://<.*>/api/v1/<.*>",
|
||||
"methods": ["OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "public-auth",
|
||||
"description": "인증/회원가입 등 공개 엔드포인트",
|
||||
"match": {
|
||||
"url": "http://<.*>/api/v1/auth/<.*>",
|
||||
"methods": ["GET", "POST", "OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "backend-command",
|
||||
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
|
||||
"match": {
|
||||
"url": "http://<.*>/api/v1/<.*>",
|
||||
"methods": ["POST", "PUT", "PATCH", "DELETE"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "cookie_session" }
|
||||
],
|
||||
"authorizer": { "handler": "remote_json" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "backend-query",
|
||||
"description": "Backend Query (admin/dev 포함)",
|
||||
"match": {
|
||||
"url": "http://<.*>/api/v1/<.*>",
|
||||
"methods": ["GET"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "cookie_session" }
|
||||
],
|
||||
"authorizer": { "handler": "remote_json" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
}
|
||||
]
|
||||
112
docker/ory/oathkeeper/rules.draft.json
Normal file
112
docker/ory/oathkeeper/rules.draft.json
Normal file
@@ -0,0 +1,112 @@
|
||||
[
|
||||
{
|
||||
"id": "public-health",
|
||||
"description": "공개 헬스체크 (TODO: 도메인 제한)",
|
||||
"match": {
|
||||
"url": "http://<.*>/health",
|
||||
"methods": ["GET"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "public-auth",
|
||||
"description": "인증/회원가입 등 공개 엔드포인트",
|
||||
"match": {
|
||||
"url": "http://<.*>/api/v1/auth/<.*>",
|
||||
"methods": ["GET", "POST", "OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "backend-command",
|
||||
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
|
||||
"match": {
|
||||
"url": "http://<.*>/api/v1/<.*>",
|
||||
"methods": ["POST", "PUT", "PATCH", "DELETE"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "cookie_session" }
|
||||
],
|
||||
"authorizer": { "handler": "remote_json" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "backend-query",
|
||||
"description": "Backend Query (admin/dev 포함)",
|
||||
"match": {
|
||||
"url": "http://<.*>/api/v1/<.*>",
|
||||
"methods": ["GET"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "cookie_session" }
|
||||
],
|
||||
"authorizer": { "handler": "remote_json" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "kratos-public",
|
||||
"description": "Kratos Public API를 /kratos로 노출",
|
||||
"match": {
|
||||
"url": "http://<.*>/kratos/<.*>",
|
||||
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://kratos:4433",
|
||||
"strip_path": "/kratos"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hydra-public",
|
||||
"description": "Hydra Public API를 /hydra로 노출",
|
||||
"match": {
|
||||
"url": "http://<.*>/hydra/<.*>",
|
||||
"methods": ["GET", "POST", "OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://hydra:4444",
|
||||
"strip_path": "/hydra"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +1,92 @@
|
||||
[]
|
||||
[
|
||||
{
|
||||
"id": "public-health",
|
||||
"description": "공개 헬스체크",
|
||||
"match": {
|
||||
"url": "http://<.*>/health",
|
||||
"methods": ["GET"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "public-preflight",
|
||||
"description": "CORS preflight",
|
||||
"match": {
|
||||
"url": "http://<.*>/api/v1/<.*>",
|
||||
"methods": ["OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "public-auth",
|
||||
"description": "인증/회원가입 등 공개 엔드포인트",
|
||||
"match": {
|
||||
"url": "http://<.*>/api/v1/auth/<.*>",
|
||||
"methods": ["GET", "POST", "OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "backend-command",
|
||||
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
|
||||
"match": {
|
||||
"url": "http://<.*>/api/v1/<.*>",
|
||||
"methods": ["POST", "PUT", "PATCH", "DELETE"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "cookie_session" }
|
||||
],
|
||||
"authorizer": { "handler": "remote_json" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "backend-query",
|
||||
"description": "Backend Query (admin/dev 포함)",
|
||||
"match": {
|
||||
"url": "http://<.*>/api/v1/<.*>",
|
||||
"methods": ["GET"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "cookie_session" }
|
||||
],
|
||||
"authorizer": { "handler": "remote_json" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
92
docker/ory/oathkeeper/rules.prod.json
Normal file
92
docker/ory/oathkeeper/rules.prod.json
Normal file
@@ -0,0 +1,92 @@
|
||||
[
|
||||
{
|
||||
"id": "public-health",
|
||||
"description": "공개 헬스체크 (PROD 도메인)",
|
||||
"match": {
|
||||
"url": "https://app.brsw.kr/health",
|
||||
"methods": ["GET"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "public-preflight",
|
||||
"description": "CORS preflight (PROD 도메인)",
|
||||
"match": {
|
||||
"url": "https://app.brsw.kr/api/v1/<.*>",
|
||||
"methods": ["OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "public-auth",
|
||||
"description": "인증/회원가입 등 공개 엔드포인트 (PROD 도메인)",
|
||||
"match": {
|
||||
"url": "https://app.brsw.kr/api/v1/auth/<.*>",
|
||||
"methods": ["GET", "POST", "OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "backend-command",
|
||||
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
|
||||
"match": {
|
||||
"url": "https://app.brsw.kr/api/v1/<.*>",
|
||||
"methods": ["POST", "PUT", "PATCH", "DELETE"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "cookie_session" }
|
||||
],
|
||||
"authorizer": { "handler": "remote_json" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "backend-query",
|
||||
"description": "Backend Query (admin/dev 포함)",
|
||||
"match": {
|
||||
"url": "https://app.brsw.kr/api/v1/<.*>",
|
||||
"methods": ["GET"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "cookie_session" }
|
||||
],
|
||||
"authorizer": { "handler": "remote_json" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
}
|
||||
]
|
||||
92
docker/ory/oathkeeper/rules.stage.json
Normal file
92
docker/ory/oathkeeper/rules.stage.json
Normal file
@@ -0,0 +1,92 @@
|
||||
[
|
||||
{
|
||||
"id": "public-health",
|
||||
"description": "공개 헬스체크 (STAGE 도메인)",
|
||||
"match": {
|
||||
"url": "https://sso.hmac.kr/health",
|
||||
"methods": ["GET"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "public-preflight",
|
||||
"description": "CORS preflight (STAGE 도메인)",
|
||||
"match": {
|
||||
"url": "https://sso.hmac.kr/api/v1/<.*>",
|
||||
"methods": ["OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "public-auth",
|
||||
"description": "인증/회원가입 등 공개 엔드포인트 (STAGE 도메인)",
|
||||
"match": {
|
||||
"url": "https://sso.hmac.kr/api/v1/auth/<.*>",
|
||||
"methods": ["GET", "POST", "OPTIONS"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "noop" }
|
||||
],
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "backend-command",
|
||||
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
|
||||
"match": {
|
||||
"url": "https://sso.hmac.kr/api/v1/<.*>",
|
||||
"methods": ["POST", "PUT", "PATCH", "DELETE"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "cookie_session" }
|
||||
],
|
||||
"authorizer": { "handler": "remote_json" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "backend-query",
|
||||
"description": "Backend Query (admin/dev 포함)",
|
||||
"match": {
|
||||
"url": "https://sso.hmac.kr/api/v1/<.*>",
|
||||
"methods": ["GET"]
|
||||
},
|
||||
"upstream": {
|
||||
"url": "http://baron_backend:3000"
|
||||
},
|
||||
"authenticators": [
|
||||
{ "handler": "cookie_session" }
|
||||
],
|
||||
"authorizer": { "handler": "remote_json" },
|
||||
"mutators": [
|
||||
{ "handler": "noop" }
|
||||
]
|
||||
}
|
||||
]
|
||||
52
docker/ory/vector/vector.toml
Normal file
52
docker/ory/vector/vector.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
[sources.oathkeeper_file]
|
||||
type = "file"
|
||||
include = ["/var/log/oathkeeper/access.log"]
|
||||
read_from = "beginning"
|
||||
|
||||
[transforms.oathkeeper_parse]
|
||||
type = "remap"
|
||||
inputs = ["oathkeeper_file"]
|
||||
source = '''
|
||||
.raw = .message
|
||||
parsed = parse_json(.message) ?? {}
|
||||
|
||||
.timestamp = to_timestamp(.timestamp) ?? now()
|
||||
.request_id = parsed.request_id ?? parsed.req_id ?? ""
|
||||
request_method = get(parsed, ["request", "method"]) ?? ""
|
||||
request_path = get(parsed, ["request", "path"]) ?? ""
|
||||
request_url = get(parsed, ["request", "url"]) ?? ""
|
||||
.method = parsed.method ?? parsed.http_method ?? request_method ?? ""
|
||||
.path = parsed.path ?? parsed.http_path ?? request_path ?? request_url ?? ""
|
||||
response_status = get(parsed, ["response", "status"]) ?? 0
|
||||
.status = to_int(parsed.status ?? parsed.status_code ?? response_status ?? 0) ?? 0
|
||||
.latency_ms = to_int(parsed.latency_ms ?? parsed.duration_ms ?? parsed.took ?? 0) ?? 0
|
||||
identity_id = get(parsed, ["identity", "id"]) ?? ""
|
||||
.subject = parsed.subject ?? identity_id ?? ""
|
||||
.client_ip = parsed.client_ip ?? parsed.remote_ip ?? parsed.ip ?? ""
|
||||
headers = get(parsed, ["headers"]) ?? {}
|
||||
.user_agent = parsed.user_agent
|
||||
if is_null(.user_agent) { .user_agent = get(headers, ["User-Agent"]) }
|
||||
if is_null(.user_agent) { .user_agent = "" }
|
||||
|
||||
.decision = parsed.decision
|
||||
if is_null(.decision) { .decision = parsed.result }
|
||||
if is_null(.decision) { .decision = "" }
|
||||
|
||||
.trace_id = parsed.trace_id
|
||||
if is_null(.trace_id) { .trace_id = "" }
|
||||
|
||||
.span_id = parsed.span_id
|
||||
if is_null(.span_id) { .span_id = "" }
|
||||
|
||||
.rp = ""
|
||||
.action = ""
|
||||
.target = ""
|
||||
'''
|
||||
|
||||
[sinks.clickhouse]
|
||||
type = "clickhouse"
|
||||
inputs = ["oathkeeper_parse"]
|
||||
endpoint = "http://ory_clickhouse:8123"
|
||||
database = "ory"
|
||||
table = "oathkeeper_access_logs"
|
||||
compression = "gzip"
|
||||
89
docs/auth-flow.md
Normal file
89
docs/auth-flow.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# 인증/로그인 플로우 정리 (Backend IDP 추상화 기준)
|
||||
|
||||
이 문서는 **Backend IDP 추상화(IdentityProvider)**를 기준으로, 현재 지원하는 로그인 방식과 UserFront 연동 API, 그리고 **Kratos 세션 공유 방식**을 정리합니다.
|
||||
|
||||
> 목적: ID/Password 방식부터 시작해, 현재 지원 중인 로그인 플로우를 **IDP 추상화를 해치지 않는 범위**에서 일관되게 구현하고, Front/Backend/Oathkeeper 간 세션 전달 방식을 명확히 한다.
|
||||
|
||||
---
|
||||
|
||||
## 1) 지원 로그인 방식 요약
|
||||
|
||||
| 방식 | Backend 엔드포인트 | 세션 토큰 반환 | 비고 |
|
||||
|---|---|---|---|
|
||||
| ID/Password | `POST /api/v1/auth/password/login` | `sessionJwt` | IDP 추상화 사용 (Ory/Descope) |
|
||||
| Enchanted Link (Email/SMS) | `POST /api/v1/auth/enchanted-link/init` → `POST /api/v1/auth/enchanted-link/poll` | `sessionJwt` | 링크 클릭 시 `POST /api/v1/auth/magic-link/verify` 호출 |
|
||||
| Magic Link Verify | `POST /api/v1/auth/magic-link/verify` | `token` | Polling 세션 갱신용 |
|
||||
| SMS 코드 | `POST /api/v1/auth/sms` → `POST /api/v1/auth/verify-sms` | `token` | 현재는 내부 토큰(placeholder). Kratos 세션 교환 필요 |
|
||||
| QR 로그인 | `POST /api/v1/auth/qr/init` → `POST /api/v1/auth/qr/poll` | `sessionJwt` | 모바일 승인: `POST /api/v1/auth/qr/approve` |
|
||||
|
||||
---
|
||||
|
||||
## 2) UserFront 연동 API 매핑
|
||||
|
||||
### 2.1 ID/Password 로그인
|
||||
1. `POST /api/v1/auth/password/login`
|
||||
2. 응답의 `sessionJwt` 사용
|
||||
|
||||
### 2.2 Enchanted Link (Email/SMS)
|
||||
1. `POST /api/v1/auth/enchanted-link/init` → `pendingRef` 수신
|
||||
2. `POST /api/v1/auth/enchanted-link/poll`로 폴링
|
||||
3. 사용자가 링크 클릭하면 UserFront가 `POST /api/v1/auth/magic-link/verify` 호출
|
||||
4. Polling 응답에서 `sessionJwt` 수신
|
||||
|
||||
### 2.3 QR 로그인
|
||||
1. `POST /api/v1/auth/qr/init` → `qrCode`, `pendingRef` 수신
|
||||
2. 웹은 `POST /api/v1/auth/qr/poll`로 폴링
|
||||
3. 모바일 앱은 `POST /api/v1/auth/qr/approve`로 승인 (모바일 세션 토큰은 승인 검증용)
|
||||
4. Polling 응답에서 `sessionJwt` 수신
|
||||
|
||||
### 2.4 SMS 코드 로그인
|
||||
1. `POST /api/v1/auth/sms`로 코드 발송
|
||||
2. `POST /api/v1/auth/verify-sms`로 코드 검증
|
||||
3. 현재는 내부 토큰 반환 (IDP 세션 교환은 TODO)
|
||||
|
||||
---
|
||||
|
||||
## 3) Kratos 세션 생성/공유 방식
|
||||
|
||||
### 3.1 생성 (ID/Password 기준)
|
||||
- Backend가 IDP 추상화(`IdentityProvider.SignIn`)를 호출해 `sessionJwt`를 발급
|
||||
- **Ory(Kratos)**의 경우:
|
||||
- Kratos Login API를 통해 `session_token`을 반환
|
||||
- 이 값이 `sessionJwt`로 응답됨
|
||||
|
||||
### 3.2 공유 (Backend → UserFront / Oathkeeper)
|
||||
현재 공유 방식은 **두 가지 선택지**가 있습니다.
|
||||
|
||||
**A) Backend가 쿠키로 전달 (권장 방향)**
|
||||
- `sessionJwt`가 Kratos `session_token`인 경우 `ory_kratos_session` 쿠키로 `Set-Cookie`
|
||||
- Oathkeeper `cookie_session` authenticator가 Kratos `/sessions/whoami`로 검증 가능
|
||||
|
||||
**B) UserFront가 토큰을 보관/전달 (현재 동작)**
|
||||
- `sessionJwt`를 로컬에 저장 후 Backend 호출 시 `Authorization: Bearer <token>`로 전달
|
||||
- Oathkeeper 경유 경로에서는 쿠키가 필요하므로, 별도 토큰 교환 또는 Oathkeeper 인증기 추가가 필요
|
||||
|
||||
> 현재 구현은 **B 방식에 가깝고**, Oathkeeper 통과를 위한 쿠키 전달은 추가 구현이 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4) IDP 추상화 관점에서의 구현 상태
|
||||
|
||||
- **ID/Password 로그인**: IDP 추상화 사용 (Ory/Descope) — 정상
|
||||
- **Enchanted/Magic Link**: 현재는 Descope 기반 로직이 포함됨. Ory 전환 시 Kratos `code/link` 플로우로 교체 필요
|
||||
- **SMS 코드**: 내부 토큰(placeholder). Kratos 세션 교환 로직 추가 필요
|
||||
- **QR 로그인**: 모바일 세션 토큰은 승인 검증용으로만 사용하고, 백엔드에서 웹 전용 세션을 새로 발급
|
||||
|
||||
---
|
||||
|
||||
## 5) UserFront 주의사항
|
||||
|
||||
- `sessionJwt`가 **JWT 형식이 아닐 수 있음** (Kratos session token은 opaque 가능)
|
||||
- 현재 UserFront는 Descope SDK 기반 세션 처리 로직이 포함되어 있어, Ory 사용 시 이 부분은 분리/대체가 필요함
|
||||
|
||||
---
|
||||
|
||||
## 6) 다음 액션 제안
|
||||
|
||||
1. **Kratos 세션 쿠키 전달 방식(A) 구현**
|
||||
2. Enchanted/Magic Link의 Ory 대응(로그인 코드/링크 방식) 설계
|
||||
3. SMS 코드/QR 플로우의 Kratos 세션 교환 정책 확정
|
||||
34
docs/kratos-integration-report.md
Normal file
34
docs/kratos-integration-report.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Ory Kratos 인증 엔진 전환 작업 보고서
|
||||
|
||||
## 1. 개요
|
||||
기존 Descope SaaS 기반 인증 시스템을 자가 호스팅(Self-hosted) IDP인 **Ory Kratos**로 전환하고, 이를 백엔드(Go Fiber)와 연동하는 작업을 수행하였습니다.
|
||||
|
||||
## 2. 주요 작업 내용
|
||||
|
||||
### 2.1 인프라 및 SDK 설정
|
||||
* **SDK 설치**: Ory Kratos Go SDK (`github.com/ory/kratos-client-go`)를 백엔드 프로젝트에 추가.
|
||||
* **클라이언트 초기화**: `AuthHandler` 내부에 Kratos Public API 통신을 위한 API Client 주입 및 환경 변수 연동.
|
||||
|
||||
### 2.2 인증 Flow 핸들러 구현 (`auth_handler.go`)
|
||||
Ory Kratos의 API-first 방식(Native Flow)에 맞춘 신규 핸들러 구현:
|
||||
* **InitializeLoginFlow**: 로그인 프로세스 시작을 위한 `flow_id` 발급 API.
|
||||
* **InitializeRegistrationFlow**: 회원가입 프로세스 시작을 위한 `flow_id` 발급 API.
|
||||
* **LoginSubmit**: 사용자의 ID/PW를 Kratos에 제출하고 성공 시 세션 쿠키를 클라이언트에 전달.
|
||||
* **RegistrationSubmit**: 커스텀 Traits(사용자 정보)와 비밀번호를 Kratos에 전달하여 계정 생성.
|
||||
|
||||
### 2.3 라우팅 설정 (`main.go`)
|
||||
신규 인증 엔진을 위한 전용 엔드포인트 그룹 등록:
|
||||
* `GET /api/v1/auth/ory/login/initialize`
|
||||
* `POST /api/v1/auth/ory/login/submit`
|
||||
* `GET /api/v1/auth/ory/registration/initialize`
|
||||
* `POST /api/v1/auth/ory/registration/submit`
|
||||
|
||||
### 2.4 보안 및 감사 (Security & Audit)
|
||||
* **세션 관리**: Kratos에서 발급한 `Set-Cookie` 헤더를 추출하여 클라이언트에 투명하게 전달(Pass-through).
|
||||
* **감사 로그**: 로그인 시도 및 성공 시 시각, IP, 대상 아이디 등을 ClickHouse 감사 로그 시스템에 기록.
|
||||
* **타입 오류 해결**: Kratos SDK의 구조체 타입 미스매치 이슈 해결(`result.Session` nil 비교 로직 수정).
|
||||
|
||||
## 3. 향후 과제 (Next Steps)
|
||||
1. **UI 연동**: `userfront` (Flutter)의 API 엔드포인트를 기존 Descope에서 신규 Ory 경로로 전환.
|
||||
2. **계정 복구**: 비밀번호 찾기(Recovery) 및 이메일 확인(Verification) Flow 추가 연동.
|
||||
3. **관리자 기능**: `adminfront`에서 Kratos Identities를 직접 조회/삭제하는 관리 API 연결.
|
||||
27
docs/kratos-todo-list.md
Normal file
27
docs/kratos-todo-list.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Kratos 기반 SSO 추가 기능 구현 로드맵
|
||||
|
||||
Ory Kratos의 표준 기능을 바탕으로 우리 프로젝트에 추가해야 할 핵심 기능 목록입니다.
|
||||
|
||||
## 1. 인증 수단 고도화
|
||||
- [ ] **소셜 로그인 연동**: Google, GitHub, Apple 등 주요 OIDC 제공자 연결.
|
||||
- [ ] **Passkeys (WebAuthn)**: 생체 인증을 통한 Passwordless 로그인 구현.
|
||||
- [ ] **MFA (Multi-Factor Authentication)**: TOTP(Authenticator 앱), Lookup Secret(복구 코드) 지원.
|
||||
|
||||
## 2. 사용자 셀프 서비스 (Self-Service)
|
||||
- [ ] **계정 복구 Flow**: 비밀번호 분실 시 이메일/SMS 링크를 통한 재설정 기능.
|
||||
- [ ] **계정 확인 (Verification)**: 가입 시 이메일/전화번호 점유 인증 절차.
|
||||
- [ ] **프로필 및 설정 화면**: 사용자가 직접 자신의 정보(Traits)와 비밀번호를 수정하는 화면.
|
||||
|
||||
## 3. 세션 보안 관리
|
||||
- [ ] **기기별 세션 관리**: 현재 로그인된 모든 브라우저/기기 목록 조회 및 특정 세션 강제 종료 기능.
|
||||
- [ ] **보안 로그 제공**: 사용자 본인의 최근 로그인 기록 및 보안 이벤트 확인 기능.
|
||||
|
||||
## 4. 관리자 기능 (Admin Operations)
|
||||
- [ ] **커스텀 아이덴티티 스키마**: 테넌트 요구사항에 맞춘 사용자 필드(부서, 직번 등) 동적 정의.
|
||||
- [ ] **사용자 일괄 마이그레이션**: 외부 데이터 대량 Import/Export API 및 도구.
|
||||
- [ ] **계정 상태 강제 제어**: 관리자에 의한 계정 잠금(Ban) 및 활성화 처리.
|
||||
|
||||
## 5. 시스템 연동 및 브랜딩
|
||||
- [ ] **메시지 템플릿 관리**: 이메일/SMS 발송 템플릿의 커스텀 HTML 에디터 및 미리보기.
|
||||
- [ ] **Webhook 이벤트 연동**: 가입/로그인 등 주요 이벤트 발생 시 외부 시스템으로 실시간 데이터 전송.
|
||||
- [ ] **멀티테넌시 브랜딩**: 접속 도메인이나 테넌트에 따른 로그인 화면 로고/컬러 동적 적용.
|
||||
84
docs/ory-stack-guide.md
Normal file
84
docs/ory-stack-guide.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Ory Stack 상세 가이드 (Baron SSO)
|
||||
|
||||
이 문서는 Baron SSO의 핵심 엔진인 Ory Stack의 구성 요소와 전체적인 인증/인가 플로우를 설명합니다.
|
||||
|
||||
## 1. 구성 요소별 상세 역할
|
||||
|
||||
|
||||
| 구성 요소 | 별칭 | 주요 역할 | 핵심 기능 |
|
||||
| :------------- | :------------ | :--------------- | :-------------------------------------------- |
|
||||
| **Kratos** | Identity | **사용자 관리** | 회원가입, 로그인, MFA, 프로필 수정, 계정 복구 |
|
||||
| | | | |
|
||||
| **Hydra** | OAuth2/OIDC | **연동 및 토큰** | Access/ID 토큰 발급, 외부 서비스 SSO 연동 |
|
||||
| **Keto** | Authorization | **권한 제어** | RBAC, ACL, "누가 무엇을 할 수 있는가" 판별 |
|
||||
| **Oathkeeper** | Proxy/Gateway | **접근 통제** | 요청 검증, 세션 확인, 헤더 변환, API 보호 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 시스템 플로우 (System Flow)
|
||||
|
||||
사용자가 보호된 백엔드 리소스에 접근할 때의 일반적인 흐름입니다.
|
||||
|
||||
### [인증 및 접근 흐름]
|
||||
|
||||
1. **Request**: 사용자가 API 요청을 보냄 (예: `GET /api/data`).
|
||||
2. **Intercept (Oathkeeper)**: Oathkeeper가 요청을 가로챔.
|
||||
3. **Authenticate (Kratos)**: Oathkeeper가 Kratos에게 사용자의 세션 쿠키가 유효한지 확인.
|
||||
4. **Authorize (Keto)**: Oathkeeper가 Keto에게 해당 사용자가 `/api/data`를 볼 권한이 있는지 확인.
|
||||
5. **Transform**: 모든 검증이 끝나면 Oathkeeper가 사용자 정보를 헤더(예: `X-User-ID`)에 담아 백엔드로 전달.
|
||||
6. **Response**: 백엔드가 로직을 수행하고 결과를 반환.
|
||||
|
||||
### [SSO 연동 흐름 (OIDC)]
|
||||
|
||||
1. **Discovery**: 외부 서비스(App A)가 로그인 필요 시 Hydra로 인증 요청을 보냄.
|
||||
2. **Login Challenge**: Hydra가 로그인 UI(`userfront`)로 리다이렉트하며 챌린지를 보냄.
|
||||
3. **Auth (Kratos)**: 사용자가 `userfront`에서 로그인(Kratos 사용).
|
||||
4. **Accept**: `userfront`가 로그인 성공 시 Hydra에게 챌린지 수락을 알림.
|
||||
5. **Token Issuance**: Hydra가 App A에게 Auth Code를 주고, App A는 이를 Access/ID Token으로 교환.
|
||||
|
||||
---
|
||||
|
||||
## 3. 아키텍처 다이어그램
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User((사용자))
|
||||
|
||||
subgraph "Edge / Gateway"
|
||||
OK[Ory Oathkeeper]
|
||||
end
|
||||
|
||||
subgraph "Identity & Access Layer"
|
||||
KR[Ory Kratos]
|
||||
HY[Ory Hydra]
|
||||
KE[Ory Keto]
|
||||
end
|
||||
|
||||
subgraph "Application Layer"
|
||||
BE[Backend API]
|
||||
AF[Admin Front]
|
||||
UF[User Front]
|
||||
end
|
||||
|
||||
User -->|API Request| OK
|
||||
User -->|Login/Register| UF
|
||||
UF --> KR
|
||||
|
||||
OK -->|1. 세션 확인| KR
|
||||
OK -->|2. 권한 확인| KE
|
||||
OK -->|3. 요청 전달| BE
|
||||
|
||||
AF -->|관리 작업| BE
|
||||
BE -->|Admin API 호출| KR & HY & KE
|
||||
|
||||
HY -->|SSO 토큰 발급| User
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 요약
|
||||
|
||||
- **Kratos**는 사용자의 정보를 알고 있습니다.
|
||||
- **Keto**는 사용자의 권한을 알고 있습니다.
|
||||
- **Hydra**는 사용자를 외부 서비스에 증명합니다.
|
||||
- **Oathkeeper**는 위 서비스들을 이용해 입구를 지킵니다.
|
||||
@@ -95,3 +95,4 @@ docker run --rm --network baron_net curlimages/curl:8.10.1 -fsS http://kratos:44
|
||||
- `compose.ory.yaml`
|
||||
- `docker/ory/kratos/kratos.yml`
|
||||
- `.env.sample`
|
||||
- `docs/auth-flow.md`
|
||||
|
||||
43
mcp/compose.mcp.ory.yaml
Normal file
43
mcp/compose.mcp.ory.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
services:
|
||||
kratos-mcp-server:
|
||||
build:
|
||||
context: ./kratos-mcp
|
||||
container_name: mcp_ory_kratos
|
||||
stdin_open: true
|
||||
tty: true
|
||||
init: true
|
||||
environment:
|
||||
- KRATOS_ADMIN_URL=http://kratos:4434
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
hydra-mcp-server:
|
||||
build:
|
||||
context: ./hydra-mcp
|
||||
container_name: mcp_ory_hydra
|
||||
stdin_open: true
|
||||
tty: true
|
||||
init: true
|
||||
environment:
|
||||
- HYDRA_PUBLIC_URL=http://hydra:4444
|
||||
- HYDRA_ADMIN_URL=http://hydra:4445
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
keto-mcp-server:
|
||||
build:
|
||||
context: ./keto-mcp
|
||||
container_name: mcp_ory_keto
|
||||
stdin_open: true
|
||||
tty: true
|
||||
init: true
|
||||
environment:
|
||||
- KETO_READ_URL=http://keto:4466
|
||||
- KETO_WRITE_URL=http://keto:4467
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
networks:
|
||||
ory-net:
|
||||
external: true
|
||||
name: ory-net
|
||||
@@ -61,7 +61,7 @@ def main():
|
||||
"contentType": "COMM",
|
||||
"countryCode": "82",
|
||||
"from": sender_phone,
|
||||
"content": "[Baron SSO] Test message from Python script.",
|
||||
"content": "[Baron 통합로그인] Test message from Python script.",
|
||||
"messages": [
|
||||
{
|
||||
"to": recipient_phone
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# ==========================================
|
||||
# Baron SSO - Unified Environment Configuration
|
||||
# ==========================================
|
||||
|
||||
# --- General System ---
|
||||
APP_ENV=development
|
||||
TZ=Asia/Seoul
|
||||
|
||||
# --- Infrastructure Ports ---
|
||||
DB_PORT=5432
|
||||
CLICKHOUSE_PORT_HTTP=8123
|
||||
CLICKHOUSE_PORT_NATIVE=9000
|
||||
BACKEND_PORT=3000
|
||||
USERFRONT_PORT=5000
|
||||
|
||||
# --- Database Credentials (PostgreSQL) ---
|
||||
DB_USER=baron
|
||||
DB_PASSWORD=password
|
||||
DB_NAME=baron_sso
|
||||
|
||||
# --- Backend Configuration ---
|
||||
# Must be 32 bytes. Generate with `openssl rand -hex 32`
|
||||
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
|
||||
REDIS_ADDR=redis:6379
|
||||
|
||||
# --- Frontend Configuration ---
|
||||
# Descope Project ID (Required for Auth)
|
||||
DESCOPE_PROJECT_ID=P2t...your_descope_project_id
|
||||
|
||||
# --- Naver Cloud Services ---
|
||||
NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
|
||||
NAVER_CLOUD_SECRET_KEY=ncp_iam_...
|
||||
NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:...:...
|
||||
NAVER_SENDER_PHONE_NUMBER=...
|
||||
@@ -1,5 +1,6 @@
|
||||
# Stage 1: Build Flutter
|
||||
FROM ghcr.io/cirruslabs/flutter:stable AS build
|
||||
ENV RUN_FLUTTER_AS_ROOT=true
|
||||
# ENV RUN_FLUTTER_AS_ROOT=true
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
@@ -29,7 +29,6 @@ class AuditService {
|
||||
'event_type': eventType,
|
||||
'status': status,
|
||||
'details': details,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'http_client.dart';
|
||||
|
||||
class AuthProxyService {
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
@@ -22,17 +23,42 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> initEnchantedLink(String loginId, {String? method}) async {
|
||||
static Future<Map<String, dynamic>> checkCookieSession() async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final client = createHttpClient(withCredentials: true);
|
||||
try {
|
||||
final response = await client.get(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
}
|
||||
throw Exception('Failed to load profile: ${response.body}');
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> initEnchantedLink(
|
||||
String loginId, {
|
||||
String? method,
|
||||
bool? codeOnly,
|
||||
}) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
|
||||
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
|
||||
|
||||
final body = {
|
||||
final body = <String, dynamic>{
|
||||
'loginId': loginId,
|
||||
'uri': userfrontUrl,
|
||||
};
|
||||
if (method != null) {
|
||||
body['method'] = method;
|
||||
}
|
||||
if (codeOnly == true) {
|
||||
body['codeOnly'] = true;
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
@@ -60,9 +86,11 @@ class AuthProxyService {
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('Polling failed: ${response.body}');
|
||||
}
|
||||
if (response.statusCode == 400) {
|
||||
return jsonDecode(response.body);
|
||||
}
|
||||
throw Exception('Polling failed: ${response.body}');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> verifyMagicLink(String token) async {
|
||||
@@ -83,6 +111,48 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> verifyLoginCode(String loginId, String code, {String? pendingRef}) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify');
|
||||
|
||||
final payload = {
|
||||
'loginId': loginId,
|
||||
'code': code,
|
||||
};
|
||||
if (pendingRef != null && pendingRef.isNotEmpty) {
|
||||
payload['pendingRef'] = pendingRef;
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(payload),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('Verification failed: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> verifyLoginShortCode(String shortCode) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify-short');
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'shortCode': shortCode,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('Verification failed: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> loginWithPassword(String loginId, String password) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/password/login');
|
||||
|
||||
@@ -205,24 +275,48 @@ class AuthProxyService {
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('QR Polling failed: ${response.body}');
|
||||
}
|
||||
if (response.statusCode == 400) {
|
||||
return jsonDecode(response.body);
|
||||
}
|
||||
throw Exception('QR Polling failed: ${response.body}');
|
||||
}
|
||||
|
||||
static Future<void> approveQrLogin(String pendingRef, String token) async {
|
||||
static Future<void> approveQrLogin(
|
||||
String pendingRef, {
|
||||
String? token,
|
||||
bool withCredentials = false,
|
||||
}) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/approve'); // Mapping to ScanQRLogin on backend
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'pendingRef': pendingRef,
|
||||
'token': token,
|
||||
}),
|
||||
);
|
||||
final payload = <String, dynamic>{
|
||||
'pendingRef': pendingRef,
|
||||
};
|
||||
if (token != null && token.isNotEmpty) {
|
||||
payload['token'] = token;
|
||||
}
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('QR Approval failed: ${response.body}');
|
||||
http.Client? client;
|
||||
try {
|
||||
if (withCredentials) {
|
||||
client = createHttpClient(withCredentials: true);
|
||||
}
|
||||
final response = await (client != null
|
||||
? client.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(payload),
|
||||
)
|
||||
: http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(payload),
|
||||
));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('QR Approval failed: ${response.body}');
|
||||
}
|
||||
} finally {
|
||||
client?.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
userfront/lib/core/services/auth_token_store.dart
Normal file
32
userfront/lib/core/services/auth_token_store.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'auth_token_store_stub.dart'
|
||||
if (dart.library.html) 'auth_token_store_web.dart';
|
||||
|
||||
class AuthTokenStore {
|
||||
static String? getToken() => authTokenStore.getToken();
|
||||
|
||||
static String? getProvider() => authTokenStore.getProvider();
|
||||
|
||||
static bool usesCookie() => authTokenStore.usesCookie();
|
||||
|
||||
static void setToken(String token, {String? provider}) {
|
||||
authTokenStore.setToken(token, provider: provider);
|
||||
}
|
||||
|
||||
static void setCookieMode({String? provider}) {
|
||||
authTokenStore.setCookieMode(provider: provider);
|
||||
}
|
||||
|
||||
static String? getPendingProvider() => authTokenStore.getPendingProvider();
|
||||
|
||||
static void setPendingProvider(String? provider) {
|
||||
authTokenStore.setPendingProvider(provider);
|
||||
}
|
||||
|
||||
static void clearPendingProvider() {
|
||||
authTokenStore.setPendingProvider(null);
|
||||
}
|
||||
|
||||
static void clear() {
|
||||
authTokenStore.clear();
|
||||
}
|
||||
}
|
||||
41
userfront/lib/core/services/auth_token_store_stub.dart
Normal file
41
userfront/lib/core/services/auth_token_store_stub.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
class AuthTokenStore {
|
||||
String? _token;
|
||||
String? _provider;
|
||||
bool _cookieMode = false;
|
||||
String? _pendingProvider;
|
||||
|
||||
String? getToken() => _token;
|
||||
|
||||
String? getProvider() => _provider;
|
||||
|
||||
bool usesCookie() => _cookieMode;
|
||||
|
||||
void setToken(String token, {String? provider}) {
|
||||
_token = token;
|
||||
_cookieMode = false;
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
void setCookieMode({String? provider}) {
|
||||
_cookieMode = true;
|
||||
_token = null;
|
||||
if (provider != null) {
|
||||
_provider = provider;
|
||||
}
|
||||
}
|
||||
|
||||
String? getPendingProvider() => _pendingProvider;
|
||||
|
||||
void setPendingProvider(String? provider) {
|
||||
_pendingProvider = provider;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_token = null;
|
||||
_provider = null;
|
||||
_cookieMode = false;
|
||||
_pendingProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
final authTokenStore = AuthTokenStore();
|
||||
49
userfront/lib/core/services/auth_token_store_web.dart
Normal file
49
userfront/lib/core/services/auth_token_store_web.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'dart:html' as html;
|
||||
|
||||
class AuthTokenStore {
|
||||
static const _tokenKey = 'baron_auth_token';
|
||||
static const _providerKey = 'baron_auth_provider';
|
||||
static const _cookieModeKey = 'baron_auth_cookie_mode';
|
||||
static const _pendingProviderKey = 'baron_auth_pending_provider';
|
||||
|
||||
String? getToken() => html.window.localStorage[_tokenKey];
|
||||
|
||||
String? getProvider() => html.window.localStorage[_providerKey];
|
||||
|
||||
bool usesCookie() => html.window.localStorage[_cookieModeKey] == '1';
|
||||
|
||||
void setToken(String token, {String? provider}) {
|
||||
html.window.localStorage[_tokenKey] = token;
|
||||
html.window.localStorage.remove(_cookieModeKey);
|
||||
if (provider != null) {
|
||||
html.window.localStorage[_providerKey] = provider;
|
||||
}
|
||||
}
|
||||
|
||||
void setCookieMode({String? provider}) {
|
||||
html.window.localStorage[_cookieModeKey] = '1';
|
||||
html.window.localStorage.remove(_tokenKey);
|
||||
if (provider != null) {
|
||||
html.window.localStorage[_providerKey] = provider;
|
||||
}
|
||||
}
|
||||
|
||||
String? getPendingProvider() => html.window.localStorage[_pendingProviderKey];
|
||||
|
||||
void setPendingProvider(String? provider) {
|
||||
if (provider == null || provider.isEmpty) {
|
||||
html.window.localStorage.remove(_pendingProviderKey);
|
||||
return;
|
||||
}
|
||||
html.window.localStorage[_pendingProviderKey] = provider;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
html.window.localStorage.remove(_tokenKey);
|
||||
html.window.localStorage.remove(_providerKey);
|
||||
html.window.localStorage.remove(_cookieModeKey);
|
||||
html.window.localStorage.remove(_pendingProviderKey);
|
||||
}
|
||||
}
|
||||
|
||||
final authTokenStore = AuthTokenStore();
|
||||
7
userfront/lib/core/services/http_client.dart
Normal file
7
userfront/lib/core/services/http_client.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'http_client_stub.dart'
|
||||
if (dart.library.html) 'http_client_web.dart';
|
||||
|
||||
http.Client createHttpClient({bool withCredentials = false}) {
|
||||
return httpClientFactory.create(withCredentials: withCredentials);
|
||||
}
|
||||
9
userfront/lib/core/services/http_client_stub.dart
Normal file
9
userfront/lib/core/services/http_client_stub.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class HttpClientFactory {
|
||||
http.Client create({bool withCredentials = false}) {
|
||||
return http.Client();
|
||||
}
|
||||
}
|
||||
|
||||
final httpClientFactory = HttpClientFactory();
|
||||
12
userfront/lib/core/services/http_client_web.dart
Normal file
12
userfront/lib/core/services/http_client_web.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:http/browser_client.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class HttpClientFactory {
|
||||
http.Client create({bool withCredentials = false}) {
|
||||
final client = BrowserClient();
|
||||
client.withCredentials = withCredentials;
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
final httpClientFactory = HttpClientFactory();
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:descope/descope.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
|
||||
class ApproveQrScreen extends StatefulWidget {
|
||||
final String? pendingRef;
|
||||
@@ -15,12 +16,46 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
bool _isLoading = false;
|
||||
String? _message;
|
||||
bool _success = false;
|
||||
bool _isCheckingSession = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrapCookieSession();
|
||||
}
|
||||
|
||||
Future<bool> _bootstrapCookieSession() async {
|
||||
if (AuthTokenStore.usesCookie()) {
|
||||
return true;
|
||||
}
|
||||
if (_isCheckingSession) {
|
||||
return false;
|
||||
}
|
||||
setState(() => _isCheckingSession = true);
|
||||
try {
|
||||
await AuthProxyService.checkCookieSession();
|
||||
AuthTokenStore.setCookieMode(provider: 'ory');
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isCheckingSession = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleApprove() async {
|
||||
if (widget.pendingRef == null) return;
|
||||
|
||||
final storedToken = AuthTokenStore.getToken();
|
||||
final session = Descope.sessionManager.session;
|
||||
if (session == null || session.refreshToken.isExpired) {
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
var hasCookie = usesCookie;
|
||||
if (storedToken == null && (session == null || session.refreshToken.isExpired) && !hasCookie) {
|
||||
hasCookie = await _bootstrapCookieSession();
|
||||
}
|
||||
if (storedToken == null && (session == null || session.refreshToken.isExpired) && !hasCookie) {
|
||||
setState(() => _message = "Please log in on your phone first.");
|
||||
context.go('/signin'); // Redirect to login
|
||||
return;
|
||||
@@ -32,9 +67,11 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
});
|
||||
// jwt 유효성 확인
|
||||
try {
|
||||
final token = storedToken ?? session?.sessionToken.jwt ?? '';
|
||||
await AuthProxyService.approveQrLogin(
|
||||
widget.pendingRef!,
|
||||
session.sessionToken.jwt,
|
||||
token: token,
|
||||
withCredentials: hasCookie,
|
||||
);
|
||||
setState(() {
|
||||
_success = true;
|
||||
@@ -54,7 +91,10 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLoggedIn = Descope.sessionManager.session?.refreshToken.isExpired == false;
|
||||
final hasStoredToken = AuthTokenStore.getToken() != null;
|
||||
final hasDescopeSession = Descope.sessionManager.session?.refreshToken.isExpired == false;
|
||||
final usesCookie = AuthTokenStore.usesCookie();
|
||||
final isLoggedIn = hasStoredToken || hasDescopeSession || usesCookie || _isCheckingSession;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("QR Login Approval")),
|
||||
|
||||
@@ -8,9 +8,9 @@ import 'package:descope/descope.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import '../../../core/services/audit_service.dart';
|
||||
import '../../../core/services/web_auth_integration.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/services/auth_token_store.dart';
|
||||
import '../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
|
||||
@@ -37,6 +37,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
Timer? _qrPollingTimer;
|
||||
int _qrRemainingSeconds = 0;
|
||||
Timer? _qrCountdownTimer;
|
||||
int _qrPollIntervalMs = 2000;
|
||||
final TextEditingController _shortCodePrefixController = TextEditingController();
|
||||
final TextEditingController _shortCodeDigitsController = TextEditingController();
|
||||
String? _linkPendingRef;
|
||||
String? _lastLinkLoginId;
|
||||
bool _lastLinkIsEmail = true;
|
||||
int _linkResendSeconds = 0;
|
||||
Timer? _linkResendTimer;
|
||||
int _linkExpireSeconds = 0;
|
||||
Timer? _linkExpireTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -47,22 +57,111 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
// Check for tokens (Path Parameter or Legacy Query Parameter)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (widget.verificationToken != null) {
|
||||
final uri = Uri.base;
|
||||
final loginIdParam = uri.queryParameters['loginId'];
|
||||
final codeParam = uri.queryParameters['code'];
|
||||
final pendingRefParam = uri.queryParameters['pendingRef'];
|
||||
if (uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l') {
|
||||
final shortCode = uri.pathSegments[1];
|
||||
_verifyShortCode(shortCode);
|
||||
}
|
||||
if (loginIdParam != null && codeParam != null) {
|
||||
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
|
||||
} else if (widget.verificationToken != null) {
|
||||
_verifyToken(widget.verificationToken!);
|
||||
} else {
|
||||
final uri = Uri.base;
|
||||
if (uri.queryParameters.containsKey('t')) {
|
||||
_verifyToken(uri.queryParameters['t']!);
|
||||
}
|
||||
} else if (uri.queryParameters.containsKey('t')) {
|
||||
_verifyToken(uri.queryParameters['t']!);
|
||||
}
|
||||
|
||||
final uri = Uri.base;
|
||||
_tryCookieSession();
|
||||
|
||||
if (uri.queryParameters.containsKey('redirect_url')) {
|
||||
_redirectUrl = uri.queryParameters['redirect_url'];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _tryCookieSession({bool silent = true}) async {
|
||||
if (AuthTokenStore.getToken() != null) {
|
||||
return;
|
||||
}
|
||||
final pendingProvider = AuthTokenStore.getPendingProvider();
|
||||
final provider = pendingProvider ?? AuthTokenStore.getProvider();
|
||||
if (provider == null || !provider.toLowerCase().contains('ory')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await AuthProxyService.checkCookieSession();
|
||||
AuthTokenStore.setCookieMode(provider: provider);
|
||||
AuthTokenStore.clearPendingProvider();
|
||||
if (mounted) {
|
||||
await ref.read(profileProvider.notifier).loadProfile();
|
||||
_onCookieLoginSuccess(provider);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
_showError("로그인 확인 실패: ${e.toString().replaceFirst("Exception: ", "")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onCookieLoginSuccess(String provider) {
|
||||
debugPrint("[Auth] Cookie-based login success. Provider: $provider");
|
||||
AuthNotifier.instance.notify();
|
||||
if (mounted) {
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
|
||||
void _resetLinkLoginState() {
|
||||
_linkPendingRef = null;
|
||||
_lastLinkLoginId = null;
|
||||
_lastLinkIsEmail = true;
|
||||
_linkResendTimer?.cancel();
|
||||
_linkResendTimer = null;
|
||||
_linkResendSeconds = 0;
|
||||
_linkExpireTimer?.cancel();
|
||||
_linkExpireTimer = null;
|
||||
_linkExpireSeconds = 0;
|
||||
_shortCodePrefixController.clear();
|
||||
_shortCodeDigitsController.clear();
|
||||
}
|
||||
|
||||
void _startLinkResendTimer(int seconds) {
|
||||
_linkResendSeconds = seconds;
|
||||
_linkResendTimer?.cancel();
|
||||
_linkResendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (_linkResendSeconds > 0) {
|
||||
_linkResendSeconds--;
|
||||
} else {
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _startLinkExpireTimer(int seconds) {
|
||||
_linkExpireSeconds = seconds;
|
||||
_linkExpireTimer?.cancel();
|
||||
_linkExpireTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) return;
|
||||
if (_linkExpireSeconds > 0) {
|
||||
setState(() {
|
||||
_linkExpireSeconds--;
|
||||
});
|
||||
return;
|
||||
}
|
||||
timer.cancel();
|
||||
if (mounted) {
|
||||
setState(_resetLinkLoginState);
|
||||
context.go('/signin');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to decode JWT and get loginId
|
||||
String _getLoginIdFromJwt(String jwt) {
|
||||
try {
|
||||
@@ -117,6 +216,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_qrImageBase64 = res['qrCode'];
|
||||
_qrPendingRef = res['pendingRef'];
|
||||
_qrRemainingSeconds = res['expiresIn'] ?? 300;
|
||||
final interval = res['interval'];
|
||||
if (interval is int && interval > 0) {
|
||||
_qrPollIntervalMs = interval * 1000;
|
||||
} else {
|
||||
_qrPollIntervalMs = 2000;
|
||||
}
|
||||
_isQrLoading = false;
|
||||
});
|
||||
_startQrPolling();
|
||||
@@ -144,7 +249,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
void _startQrPolling() {
|
||||
_qrPollingTimer?.cancel();
|
||||
_qrPollingTimer = Timer.periodic(const Duration(milliseconds: 1500), (timer) async {
|
||||
_qrPollingTimer = Timer.periodic(Duration(milliseconds: _qrPollIntervalMs), (timer) async {
|
||||
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
|
||||
timer.cancel();
|
||||
return;
|
||||
@@ -152,37 +257,67 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
try {
|
||||
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
|
||||
if (res['error'] == 'slow_down') {
|
||||
final interval = res['interval'];
|
||||
if (interval is int && interval > 0) {
|
||||
final nextIntervalMs = interval * 1000;
|
||||
if (nextIntervalMs != _qrPollIntervalMs) {
|
||||
_qrPollIntervalMs = nextIntervalMs;
|
||||
timer.cancel();
|
||||
_startQrPolling();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
_qrPollIntervalMs += 500;
|
||||
timer.cancel();
|
||||
_startQrPolling();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (res['error'] == 'authorization_pending') {
|
||||
return;
|
||||
}
|
||||
if (res['error'] == 'expired_token') {
|
||||
timer.cancel();
|
||||
_qrCountdownTimer?.cancel();
|
||||
_showError("QR 세션이 만료되었습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (res['status'] == 'ok' && res['sessionJwt'] != null) {
|
||||
timer.cancel();
|
||||
_qrCountdownTimer?.cancel();
|
||||
|
||||
final jwt = res['sessionJwt'];
|
||||
final displayName = _getLoginIdFromJwt(jwt);
|
||||
// Create User & Session for Descope SDK
|
||||
final dummyUser = DescopeUser(
|
||||
'unknown', // userId
|
||||
[], // loginIds
|
||||
0, // createdAt
|
||||
displayName, // name
|
||||
null, // picture (Uri?)
|
||||
'', // email
|
||||
false, // isVerifiedEmail
|
||||
'', // phone
|
||||
false, // isVerifiedPhone
|
||||
{}, // customAttributes
|
||||
'', // givenName
|
||||
'', // middleName
|
||||
'', // familyName
|
||||
false, // hasPassword
|
||||
'enabled', // status
|
||||
[], // roleNames
|
||||
[], // ssoAppIds
|
||||
[], // oauthProviders (List<String>)
|
||||
);
|
||||
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
||||
Descope.sessionManager.manageSession(session);
|
||||
final token = res['sessionJwt'] as String;
|
||||
final isJwt = token.split('.').length == 3;
|
||||
if (isJwt) {
|
||||
final displayName = _getLoginIdFromJwt(token);
|
||||
// Create User & Session for Descope SDK
|
||||
final dummyUser = DescopeUser(
|
||||
'unknown', // userId
|
||||
[], // loginIds
|
||||
0, // createdAt
|
||||
displayName, // name
|
||||
null, // picture (Uri?)
|
||||
'', // email
|
||||
false, // isVerifiedEmail
|
||||
'', // phone
|
||||
false, // isVerifiedPhone
|
||||
{}, // customAttributes
|
||||
'', // givenName
|
||||
'', // middleName
|
||||
'', // familyName
|
||||
false, // hasPassword
|
||||
'enabled', // status
|
||||
[], // roleNames
|
||||
[], // ssoAppIds
|
||||
[], // oauthProviders (List<String>)
|
||||
);
|
||||
final session = DescopeSession.fromJwt(token, token, dummyUser);
|
||||
Descope.sessionManager.manageSession(session);
|
||||
}
|
||||
|
||||
_onLoginSuccess(jwt);
|
||||
_onLoginSuccess(token);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[QR] Polling error: $e");
|
||||
@@ -233,6 +368,83 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyLoginCode(String loginId, String code, {String? pendingRef}) async {
|
||||
final sanitizedLoginId = loginId.replaceAll(' ', '+');
|
||||
debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId");
|
||||
try {
|
||||
final res = await AuthProxyService.verifyLoginCode(
|
||||
sanitizedLoginId,
|
||||
code,
|
||||
pendingRef: pendingRef,
|
||||
);
|
||||
final jwt = res['sessionJwt'] ?? res['token'];
|
||||
final status = res['status']?.toString();
|
||||
debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId");
|
||||
|
||||
if (jwt == null && status == 'approved') {
|
||||
if (mounted) {
|
||||
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (jwt != null && mounted) {
|
||||
final isJwt = (jwt as String).split('.').length == 3;
|
||||
if (isJwt) {
|
||||
final displayName = _getLoginIdFromJwt(jwt);
|
||||
final dummyUser = DescopeUser(
|
||||
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
|
||||
);
|
||||
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
||||
Descope.sessionManager.manageSession(session);
|
||||
}
|
||||
_onLoginSuccess(jwt, provider: res['provider'] as String?);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
|
||||
if (mounted) {
|
||||
_showError("Verification failed: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyShortCode(String shortCode) async {
|
||||
final sanitized = shortCode.trim().toUpperCase();
|
||||
if (sanitized.isEmpty) return;
|
||||
debugPrint("[Auth] Starting short code verification for code: $sanitized");
|
||||
try {
|
||||
final res = await AuthProxyService.verifyLoginShortCode(sanitized);
|
||||
final jwt = res['sessionJwt'] ?? res['token'];
|
||||
final status = res['status']?.toString();
|
||||
debugPrint("[Auth] Short code verification successful");
|
||||
|
||||
if (jwt == null && status == 'approved') {
|
||||
if (mounted) {
|
||||
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (jwt != null && mounted) {
|
||||
final isJwt = (jwt as String).split('.').length == 3;
|
||||
if (isJwt) {
|
||||
final displayName = _getLoginIdFromJwt(jwt);
|
||||
final dummyUser = DescopeUser(
|
||||
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
|
||||
);
|
||||
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
||||
Descope.sessionManager.manageSession(session);
|
||||
}
|
||||
_onLoginSuccess(jwt, provider: res['provider'] as String?);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Short code verification FAILED. Error: $e");
|
||||
if (mounted) {
|
||||
_showError("Verification failed: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopQrPolling();
|
||||
@@ -240,6 +452,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_linkIdController.dispose();
|
||||
_passwordLoginIdController.dispose();
|
||||
_passwordController.dispose();
|
||||
_shortCodePrefixController.dispose();
|
||||
_shortCodeDigitsController.dispose();
|
||||
_linkResendTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -271,9 +486,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
try {
|
||||
final res = await AuthProxyService.loginWithPassword(loginId, password);
|
||||
final jwt = res['sessionJwt'];
|
||||
final provider = res['provider'] as String?;
|
||||
if (jwt != null && mounted) {
|
||||
Navigator.of(context).pop(); // 로딩 닫기
|
||||
_onLoginSuccess(jwt);
|
||||
_onLoginSuccess(jwt, provider: provider);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) Navigator.of(context).pop(); // 로딩 닫기
|
||||
@@ -313,7 +529,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startEnchantedFlow(String loginId, {required bool isEmail}) async {
|
||||
Future<void> _startEnchantedFlow(String loginId, {required bool isEmail, bool codeOnly = false}) async {
|
||||
try {
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
@@ -324,45 +540,48 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
|
||||
// 1. Init via Backend API
|
||||
final initResponse = await AuthProxyService.initEnchantedLink(loginId);
|
||||
final initResponse = await AuthProxyService.initEnchantedLink(
|
||||
loginId,
|
||||
codeOnly: codeOnly,
|
||||
);
|
||||
final pendingRef = initResponse['pendingRef'];
|
||||
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef");
|
||||
final mode = (initResponse['mode'] ?? '').toString();
|
||||
final provider = (initResponse['provider'] ?? '').toString();
|
||||
final interval = initResponse['interval'];
|
||||
final resendAfter = initResponse['resendAfter'];
|
||||
final expiresIn = initResponse['expiresIn'];
|
||||
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider");
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_linkPendingRef = pendingRef?.toString();
|
||||
_lastLinkLoginId = loginId;
|
||||
_lastLinkIsEmail = isEmail;
|
||||
});
|
||||
Navigator.of(context).pop(); // Close Loading
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(isEmail ? "이메일 전송됨" : "SMS 전송됨"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(isEmail
|
||||
? "입력하신 이메일로 로그인 링크를 보냈습니다."
|
||||
: "입력하신 번호로 로그인 링크를 보냈습니다."),
|
||||
const SizedBox(height: 16),
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
debugPrint("[Auth] Polling canceled by user");
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text("취소")
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
_showInfo(isEmail
|
||||
? "입력하신 이메일로 로그인 링크를 보냈습니다."
|
||||
: "입력하신 번호로 로그인 링크를 보냈습니다.");
|
||||
|
||||
// 2. Poll Backend manually
|
||||
_pollForSession(pendingRef);
|
||||
final initialInterval = (interval is int && interval > 0)
|
||||
? Duration(seconds: interval)
|
||||
: const Duration(seconds: 2);
|
||||
if (resendAfter is int && resendAfter > 0) {
|
||||
_startLinkResendTimer(resendAfter);
|
||||
}
|
||||
if (expiresIn is int && expiresIn > 0) {
|
||||
_startLinkExpireTimer(expiresIn);
|
||||
}
|
||||
_pollForSession(pendingRef, initialInterval: initialInterval);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Initialization failed: $e");
|
||||
if (mounted && Navigator.canPop(context)) Navigator.of(context).pop();
|
||||
if (mounted) {
|
||||
setState(_resetLinkLoginState);
|
||||
}
|
||||
if (e.toString().contains("User not registered")) {
|
||||
_showUnregisteredDialog();
|
||||
} else {
|
||||
@@ -371,18 +590,42 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pollForSession(String pendingRef) async {
|
||||
Future<void> _pollForSession(String pendingRef, {Duration? initialInterval}) async {
|
||||
int attempts = 0;
|
||||
const maxAttempts = 60; // 2 minutes
|
||||
var pollInterval = initialInterval ?? const Duration(seconds: 2);
|
||||
debugPrint("[Auth] Starting poll for ref: $pendingRef");
|
||||
|
||||
while (attempts < maxAttempts && mounted) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (_linkPendingRef != pendingRef) {
|
||||
return;
|
||||
}
|
||||
await Future.delayed(pollInterval);
|
||||
attempts++;
|
||||
|
||||
try {
|
||||
final result = await AuthProxyService.pollEnchantedLink(pendingRef);
|
||||
|
||||
if (result['error'] == 'slow_down') {
|
||||
final interval = result['interval'];
|
||||
if (interval is int && interval > 0) {
|
||||
pollInterval = Duration(seconds: interval);
|
||||
} else {
|
||||
pollInterval += const Duration(seconds: 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (result['error'] == 'authorization_pending') {
|
||||
continue;
|
||||
}
|
||||
if (result['error'] == 'expired_token') {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Close Polling Dialog
|
||||
_showError("Login timed out.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result['status'] == 'ok') {
|
||||
final jwt = result['sessionJwt'];
|
||||
if (jwt != null) {
|
||||
@@ -427,6 +670,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void _showInfo(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.green),
|
||||
);
|
||||
}
|
||||
|
||||
void _logTokenDetails(String jwt) {
|
||||
try {
|
||||
final parts = jwt.split('.');
|
||||
@@ -452,23 +702,31 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void _onLoginSuccess(String token) async {
|
||||
void _onLoginSuccess(String token, {String? provider}) async {
|
||||
if (!mounted) return;
|
||||
|
||||
_logTokenDetails(token);
|
||||
|
||||
final userId = _getUserIdFromJwt(token);
|
||||
final providerName = provider ?? AuthTokenStore.getProvider();
|
||||
final isJwt = token.split('.').length == 3;
|
||||
final isOry = (providerName ?? '').toLowerCase().contains('ory') || !isJwt;
|
||||
|
||||
AuthTokenStore.setToken(token, provider: providerName);
|
||||
AuthTokenStore.clearPendingProvider();
|
||||
|
||||
// [New] 로그인 성공 직후 백엔드에서 전체 프로필 정보를 가져와 세션 업데이트
|
||||
try {
|
||||
// 임시 세션 생성 (API 호출을 위해)
|
||||
final tempUser = DescopeUser(userId, [], 0, 'User', null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], []);
|
||||
final tempSession = DescopeSession.fromJwt(token, token, tempUser);
|
||||
Descope.sessionManager.manageSession(tempSession);
|
||||
if (!isOry) {
|
||||
// 임시 세션 생성 (API 호출을 위해)
|
||||
final tempUser = DescopeUser(userId, [], 0, 'User', null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], []);
|
||||
final tempSession = DescopeSession.fromJwt(token, token, tempUser);
|
||||
Descope.sessionManager.manageSession(tempSession);
|
||||
}
|
||||
|
||||
// 백엔드 GetMe 호출 (프로필 노티파이어 사용)
|
||||
final profile = await ref.read(profileProvider.notifier).loadProfile();
|
||||
if (profile != null) {
|
||||
if (profile != null && !isOry) {
|
||||
// 실제 정보로 세션 유저 정보 교체
|
||||
final realUser = DescopeUser(
|
||||
userId, [], 0, profile.name, null, profile.email, false, profile.phone, false, {}, '', '', '', false, 'enabled', [], [], [],
|
||||
@@ -480,14 +738,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
debugPrint("[Auth] Failed to pre-fetch profile: $e");
|
||||
}
|
||||
|
||||
// Record Audit Log
|
||||
AuditService.logEvent(
|
||||
userId: userId,
|
||||
eventType: "LOGIN_SUCCESS",
|
||||
status: "SUCCESS",
|
||||
details: "User logged in via Baron SSO",
|
||||
);
|
||||
|
||||
// 1. Handle Popup Flow
|
||||
if (WebAuthIntegration.isPopup()) {
|
||||
debugPrint("[Auth] Popup detected. Notifying opener and attempting to close.");
|
||||
@@ -525,6 +775,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_resetLinkLoginState();
|
||||
context.push('/signup');
|
||||
},
|
||||
child: const Text("회원가입 하기"),
|
||||
@@ -612,35 +863,143 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
),
|
||||
|
||||
// 2. 로그인 링크 전송 폼
|
||||
// 2. 로그인 링크 전송 -> 전송 후 코드 입력으로 전환
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _linkIdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "이메일 또는 휴대폰 번호",
|
||||
hintText: "",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
if (_linkPendingRef == null) ...[
|
||||
TextField(
|
||||
controller: _linkIdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "이메일 또는 휴대폰 번호",
|
||||
hintText: "",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
onSubmitted: (_) => _handleLinkLogin(),
|
||||
),
|
||||
onSubmitted: (_) => _handleLinkLogin(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _handleLinkLogin,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _handleLinkLogin,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: const Text("로그인 링크 전송"),
|
||||
),
|
||||
child: const Text("로그인 링크 전송"),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
"입력하신 정보로 로그인 링크를 전송합니다.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
"입력하신 정보로 로그인 링크를 전송합니다.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (_linkPendingRef != null) ...[
|
||||
const Text(
|
||||
"링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
controller: _shortCodePrefixController,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "영문 2자리",
|
||||
border: OutlineInputBorder(),
|
||||
hintText: "AB",
|
||||
hintStyle: TextStyle(color: Colors.grey),
|
||||
),
|
||||
maxLength: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: TextField(
|
||||
controller: _shortCodeDigitsController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: "숫자 6자리",
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: "345678",
|
||||
hintStyle: const TextStyle(color: Colors.grey),
|
||||
suffixText: _linkExpireSeconds > 0
|
||||
? "유효시간 ${_formatTime(_linkExpireSeconds)}"
|
||||
: null,
|
||||
),
|
||||
maxLength: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final prefix = _shortCodePrefixController.text.trim().toUpperCase();
|
||||
final digits = _shortCodeDigitsController.text.trim();
|
||||
if (prefix.length != 2 || digits.length != 6) {
|
||||
_showError("문자 2개와 숫자 6자리를 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
_verifyShortCode(prefix + digits);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(45),
|
||||
),
|
||||
child: const Text("코드로 로그인"),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (_linkResendSeconds > 0) {
|
||||
_showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다.");
|
||||
return;
|
||||
}
|
||||
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
|
||||
if (loginId.isEmpty) {
|
||||
_showError("이메일 또는 휴대폰 번호를 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
_startEnchantedFlow(
|
||||
loginId,
|
||||
isEmail: _lastLinkIsEmail || loginId.contains('@'),
|
||||
codeOnly: false,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
_linkResendSeconds > 0
|
||||
? "재발송 (${_formatTime(_linkResendSeconds)})"
|
||||
: "재발송",
|
||||
),
|
||||
),
|
||||
if (!_lastLinkIsEmail) ...[
|
||||
const SizedBox(height: 4),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (_linkResendSeconds > 0) {
|
||||
_showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다.");
|
||||
return;
|
||||
}
|
||||
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
|
||||
if (loginId.isEmpty) {
|
||||
_showError("휴대폰 번호를 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
_startEnchantedFlow(
|
||||
loginId,
|
||||
isEmail: false,
|
||||
codeOnly: true,
|
||||
);
|
||||
},
|
||||
child: Text("코드만 받기(${_formatTime(_linkResendSeconds)})"),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -727,4 +1086,4 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:descope/descope.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/services/auth_token_store.dart';
|
||||
|
||||
class QRScanScreen extends StatefulWidget {
|
||||
const QRScanScreen({super.key});
|
||||
@@ -18,6 +19,38 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
);
|
||||
bool _isScanned = false;
|
||||
bool _isCheckingSession = false;
|
||||
bool _isProcessing = false;
|
||||
bool? _isSuccess;
|
||||
String? _resultMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrapCookieSession();
|
||||
}
|
||||
|
||||
Future<bool> _bootstrapCookieSession() async {
|
||||
if (AuthTokenStore.usesCookie()) {
|
||||
return true;
|
||||
}
|
||||
if (_isCheckingSession) {
|
||||
return false;
|
||||
}
|
||||
setState(() => _isCheckingSession = true);
|
||||
try {
|
||||
await AuthProxyService.checkCookieSession();
|
||||
AuthTokenStore.setCookieMode(provider: 'ory');
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.info('Cookie session check failed: $e');
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isCheckingSession = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -32,6 +65,9 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
for (final barcode in barcodes) {
|
||||
if (barcode.rawValue != null) {
|
||||
_isScanned = true;
|
||||
if (mounted) {
|
||||
setState(() => _isProcessing = true);
|
||||
}
|
||||
String qrData = barcode.rawValue!;
|
||||
String pendingRef = qrData;
|
||||
|
||||
@@ -41,6 +77,12 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
final uri = Uri.parse(qrData);
|
||||
if (uri.queryParameters.containsKey('ref')) {
|
||||
pendingRef = uri.queryParameters['ref']!;
|
||||
} else if (uri.pathSegments.isNotEmpty) {
|
||||
final segments = uri.pathSegments;
|
||||
final qlIndex = segments.indexOf('ql');
|
||||
if (qlIndex != -1 && qlIndex + 1 < segments.length) {
|
||||
pendingRef = segments[qlIndex + 1];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.warning('Failed to parse QR URL: $qrData', e);
|
||||
@@ -48,9 +90,15 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
}
|
||||
|
||||
_log.info('QR Code detected raw: $qrData, ref: $pendingRef');
|
||||
final approveRef = qrData;
|
||||
|
||||
final sessionToken = Descope.sessionManager.session?.sessionToken.jwt;
|
||||
if (sessionToken == null) {
|
||||
final storedToken = AuthTokenStore.getToken();
|
||||
final sessionToken = storedToken ?? Descope.sessionManager.session?.sessionToken.jwt;
|
||||
var usesCookie = AuthTokenStore.usesCookie();
|
||||
if (sessionToken == null && !usesCookie) {
|
||||
usesCookie = await _bootstrapCookieSession();
|
||||
}
|
||||
if (sessionToken == null && !usesCookie) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('로그인이 필요합니다.'), backgroundColor: Colors.red),
|
||||
@@ -62,28 +110,27 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
|
||||
try {
|
||||
// Call backend API to approve login with clean ref
|
||||
await AuthProxyService.approveQrLogin(pendingRef, sessionToken);
|
||||
await AuthProxyService.approveQrLogin(
|
||||
approveRef,
|
||||
token: sessionToken,
|
||||
withCredentials: usesCookie,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('로그인 승인 완료!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
// Wait a bit and go back
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (mounted) context.pop();
|
||||
setState(() {
|
||||
_isSuccess = true;
|
||||
_resultMessage = 'QR 승인 완료! PC 화면에서 로그인이 진행됩니다.';
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe("QR Approval Failed", e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('승인 실패: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
// Allow rescanning after a delay
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
_isScanned = false;
|
||||
setState(() {
|
||||
_isSuccess = false;
|
||||
_resultMessage = 'QR 승인 실패: $e';
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -91,6 +138,58 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _resetScan() {
|
||||
setState(() {
|
||||
_isScanned = false;
|
||||
_isProcessing = false;
|
||||
_isSuccess = null;
|
||||
_resultMessage = null;
|
||||
});
|
||||
controller.start();
|
||||
}
|
||||
|
||||
Widget _buildResultView() {
|
||||
final success = _isSuccess == true;
|
||||
final icon = success ? Icons.check_circle_outline : Icons.error_outline;
|
||||
final color = success ? Colors.green : Colors.red;
|
||||
final title = success ? '승인 완료' : '승인 실패';
|
||||
final message = _resultMessage ?? '';
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 72),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.black54),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (!success)
|
||||
FilledButton(
|
||||
onPressed: _resetScan,
|
||||
child: const Text('다시 스캔'),
|
||||
),
|
||||
if (success)
|
||||
FilledButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -101,22 +200,30 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
body: MobileScanner(
|
||||
controller: controller,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error, child) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
body: _isSuccess == null
|
||||
? Stack(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red, size: 50),
|
||||
const SizedBox(height: 10),
|
||||
Text('Camera Error: ${error.errorCode}'),
|
||||
MobileScanner(
|
||||
controller: controller,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error, child) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red, size: 50),
|
||||
const SizedBox(height: 10),
|
||||
Text('Camera Error: ${error.errorCode}'),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_isProcessing || _isCheckingSession)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: _buildResultView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,13 +110,17 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
if (_isPolicyLoading) {
|
||||
return "비밀번호 정책을 불러오는 중입니다...";
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
final requiresLower = _policy?['lowercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? false;
|
||||
final requiresNumber = _policy?['number'] ?? true;
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>["최소 ${minLength}자 이상"];
|
||||
if (minTypes > 0) {
|
||||
parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상");
|
||||
}
|
||||
if (requiresLower) parts.add("소문자 1개 이상");
|
||||
if (requiresUpper) parts.add("대문자 1개 이상");
|
||||
if (requiresNumber) parts.add("숫자 1개 이상");
|
||||
@@ -182,20 +186,35 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
if (val.isEmpty) {
|
||||
return '비밀번호를 입력해주세요.';
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||
if (val.length < minLength) {
|
||||
return '비밀번호는 최소 $minLength자 이상이어야 합니다.';
|
||||
}
|
||||
if ((_policy?['lowercase'] ?? true) && !RegExp(r'(?=.*[a-z])').hasMatch(val)) {
|
||||
final hasLower = RegExp(r'[a-z]').hasMatch(val);
|
||||
final hasUpper = RegExp(r'[A-Z]').hasMatch(val);
|
||||
final hasNumber = RegExp(r'[0-9]').hasMatch(val);
|
||||
final hasSymbol = RegExp(r'[\W_]').hasMatch(val);
|
||||
int typeCount = 0;
|
||||
if (hasLower) typeCount++;
|
||||
if (hasUpper) typeCount++;
|
||||
if (hasNumber) typeCount++;
|
||||
if (hasSymbol) typeCount++;
|
||||
|
||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
if (minTypes > 0 && typeCount < minTypes) {
|
||||
return '비밀번호는 영문 대/소문자/숫자/특수문자 중 $minTypes가지 이상 포함해야 합니다.';
|
||||
}
|
||||
|
||||
if ((_policy?['lowercase'] ?? true) && !hasLower) {
|
||||
return '최소 1개 이상의 소문자를 포함해야 합니다.';
|
||||
}
|
||||
if ((_policy?['uppercase'] ?? true) && !RegExp(r'(?=.*[A-Z])').hasMatch(val)) {
|
||||
if ((_policy?['uppercase'] ?? false) && !hasUpper) {
|
||||
return '최소 1개 이상의 대문자를 포함해야 합니다.';
|
||||
}
|
||||
if ((_policy?['number'] ?? true) && !RegExp(r'(?=.*\d)').hasMatch(val)) {
|
||||
if ((_policy?['number'] ?? true) && !hasNumber) {
|
||||
return '최소 1개 이상의 숫자를 포함해야 합니다.';
|
||||
}
|
||||
if ((_policy?['nonAlphanumeric'] ?? true) && !RegExp(r'(?=.*[\W_])').hasMatch(val)) {
|
||||
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
|
||||
return '최소 1개 이상의 특수문자를 포함해야 합니다.';
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -781,36 +781,47 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
if (_isPolicyLoading) {
|
||||
return "비밀번호 정책을 불러오는 중입니다...";
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
final requiresLower = _policy?['lowercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? false;
|
||||
final requiresNumber = _policy?['number'] ?? true;
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>["최소 $minLength자 이상"];
|
||||
if (minTypes > 0) {
|
||||
parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상");
|
||||
}
|
||||
if (requiresUpper) parts.add("대문자");
|
||||
if (requiresLower) parts.add("소문자");
|
||||
if (requiresNumber) parts.add("숫자");
|
||||
if (requiresSymbol) parts.add("특수문자");
|
||||
|
||||
return "보안 정책: ${parts.join(', ')}를 각각 최소 1자 이상 포함해야 합니다.";
|
||||
return "보안 정책: ${parts.join(', ')}";
|
||||
}
|
||||
|
||||
Widget _buildStepPassword() {
|
||||
String p = _passwordController.text;
|
||||
|
||||
// Default Policy Fallback
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
final requiresLower = _policy?['lowercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? false;
|
||||
final requiresNumber = _policy?['number'] ?? true;
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
bool hasLength = p.length >= minLength;
|
||||
bool hasUpper = !requiresUpper || p.contains(RegExp(r'[A-Z]'));
|
||||
bool hasLower = !requiresLower || p.contains(RegExp(r'[a-z]'));
|
||||
bool hasDigit = !requiresNumber || p.contains(RegExp(r'[0-9]'));
|
||||
bool hasSpecial = !requiresSymbol || p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'));
|
||||
bool hasUpper = p.contains(RegExp(r'[A-Z]'));
|
||||
bool hasLower = p.contains(RegExp(r'[a-z]'));
|
||||
bool hasDigit = p.contains(RegExp(r'[0-9]'));
|
||||
bool hasSpecial = p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'));
|
||||
int typeCount = 0;
|
||||
if (hasUpper) typeCount++;
|
||||
if (hasLower) typeCount++;
|
||||
if (hasDigit) typeCount++;
|
||||
if (hasSpecial) typeCount++;
|
||||
bool hasTypeCount = minTypes <= 0 || typeCount >= minTypes;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -850,6 +861,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
spacing: 10,
|
||||
children: [
|
||||
_cryptoCheck('$minLength자 이상', hasLength),
|
||||
if (minTypes > 0) _cryptoCheck('문자 유형 ${minTypes}가지 이상', hasTypeCount),
|
||||
if (requiresUpper) _cryptoCheck('대문자', hasUpper),
|
||||
if (requiresLower) _cryptoCheck('소문자', hasLower),
|
||||
if (requiresNumber) _cryptoCheck('숫자', hasDigit),
|
||||
@@ -967,4 +979,4 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:descope/descope.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
|
||||
class DashboardScreen extends StatelessWidget {
|
||||
class DashboardScreen extends ConsumerWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
Future<void> _logout(BuildContext context) async {
|
||||
// ignore: use_build_context_synchronously
|
||||
Descope.sessionManager.clearSession();
|
||||
AuthTokenStore.clear();
|
||||
AuthNotifier.instance.notify();
|
||||
}
|
||||
|
||||
@@ -18,9 +22,16 @@ class DashboardScreen extends StatelessWidget {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final profile = ref.watch(profileProvider).value;
|
||||
final user = Descope.sessionManager.session?.user;
|
||||
final userName = user?.name ?? user?.email ?? user?.phone ?? 'User';
|
||||
final userName = user?.name ??
|
||||
user?.email ??
|
||||
user?.phone ??
|
||||
profile?.name ??
|
||||
profile?.email ??
|
||||
profile?.phone ??
|
||||
'User';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[50],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../models/user_profile_model.dart';
|
||||
import 'package:descope/descope.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
|
||||
class ProfileRepository {
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
@@ -16,22 +16,26 @@ class ProfileRepository {
|
||||
|
||||
// Helper to get session token
|
||||
static Future<String?> _getToken() async {
|
||||
final session = await Descope.sessionManager.session;
|
||||
return session?.sessionToken.jwt;
|
||||
return AuthTokenStore.getToken();
|
||||
}
|
||||
|
||||
Future<UserProfile> getMyProfile() async {
|
||||
final token = await _getToken();
|
||||
if (token == null) throw Exception('No active session');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) {
|
||||
throw Exception('No active session');
|
||||
}
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
);
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.get(url, headers: headers);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return UserProfile.fromJson(jsonDecode(response.body));
|
||||
@@ -46,21 +50,27 @@ class ProfileRepository {
|
||||
required String department,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
if (token == null) throw Exception('No active session');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) throw Exception('No active session');
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final response = await http.put(
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.put(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
headers: headers,
|
||||
body: jsonEncode({
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'department': department,
|
||||
}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to update profile: ${response.body}');
|
||||
@@ -69,17 +79,23 @@ class ProfileRepository {
|
||||
|
||||
Future<void> sendUpdateCode(String phone) async {
|
||||
final token = await _getToken();
|
||||
if (token == null) throw Exception('No active session');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) throw Exception('No active session');
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
|
||||
final response = await http.post(
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
headers: headers,
|
||||
body: jsonEncode({'phone': phone}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('인증번호 전송 실패: ${response.body}');
|
||||
@@ -88,17 +104,23 @@ class ProfileRepository {
|
||||
|
||||
Future<void> verifyUpdateCode(String phone, String code) async {
|
||||
final token = await _getToken();
|
||||
if (token == null) throw Exception('No active session');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) throw Exception('No active session');
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
|
||||
final response = await http.post(
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
headers: headers,
|
||||
body: jsonEncode({'phone': phone, 'code': code}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('인증 실패: ${response.body}');
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'features/admin/presentation/user_management_screen.dart';
|
||||
import 'features/profile/presentation/pages/profile_page.dart';
|
||||
import 'features/profile/presentation/pages/edit_profile_page.dart';
|
||||
import 'core/services/auth_proxy_service.dart';
|
||||
import 'core/services/auth_token_store.dart';
|
||||
import 'core/services/logger_service.dart';
|
||||
import 'core/notifiers/auth_notifier.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -108,6 +109,13 @@ final _router = GoRouter(
|
||||
return const SignupScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verify',
|
||||
builder: (context, state) {
|
||||
_routerLogger.info("Navigating to /verify (query)");
|
||||
return const LoginScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verify/:token',
|
||||
builder: (context, state) {
|
||||
@@ -116,6 +124,14 @@ final _router = GoRouter(
|
||||
return LoginScreen(verificationToken: token);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/l/:shortCode',
|
||||
builder: (context, state) {
|
||||
final shortCode = state.pathParameters['shortCode'];
|
||||
_routerLogger.info("Navigating to /l with code: $shortCode");
|
||||
return const LoginScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/forgot-password',
|
||||
builder: (context, state) {
|
||||
@@ -141,6 +157,14 @@ final _router = GoRouter(
|
||||
return ApproveQrScreen(pendingRef: ref);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/ql/:ref',
|
||||
builder: (context, state) {
|
||||
final ref = state.pathParameters['ref'];
|
||||
_routerLogger.info("Navigating to /ql with ref: $ref");
|
||||
return ApproveQrScreen(pendingRef: ref);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/scan',
|
||||
builder: (context, state) {
|
||||
@@ -157,15 +181,20 @@ final _router = GoRouter(
|
||||
),
|
||||
],
|
||||
redirect: (context, state) {
|
||||
final isLoggedIn =
|
||||
final hasDescopeSession =
|
||||
Descope.sessionManager.session?.refreshToken?.isExpired == false;
|
||||
final hasStoredToken = AuthTokenStore.getToken() != null;
|
||||
final hasCookieSession = AuthTokenStore.usesCookie();
|
||||
final isLoggedIn = hasDescopeSession || hasStoredToken || hasCookieSession;
|
||||
final path = state.uri.path;
|
||||
|
||||
// Public paths that don't require login
|
||||
final isPublicPath = path == '/signin' ||
|
||||
path == '/signup' ||
|
||||
path == '/verify' ||
|
||||
path.startsWith('/verify/') ||
|
||||
path == '/approve' ||
|
||||
path.startsWith('/ql/') ||
|
||||
path == '/forgot-password' ||
|
||||
path == '/reset-password';
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ server {
|
||||
|
||||
access_log /var/log/nginx/access.log json_combined;
|
||||
|
||||
# Backend API Proxy
|
||||
# --- Backend API Proxy ---
|
||||
location /api {
|
||||
proxy_pass http://baron_backend:3000;
|
||||
proxy_set_header Host $host;
|
||||
@@ -34,7 +34,55 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Frontend Static Files
|
||||
# --- Ory Stack Proxy (via Oathkeeper) ---
|
||||
# Kratos Public API
|
||||
location /auth {
|
||||
proxy_pass http://oathkeeper:4455;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Hydra Public API
|
||||
location /oidc {
|
||||
proxy_pass http://oathkeeper:4455;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# --- Internal Web Apps Proxy --- 초반에는 외부 오픈 없이 Private Net 내부에서만 운영
|
||||
# AdminFront (Vite Dev Server or Nginx)
|
||||
# location /admin {
|
||||
# proxy_pass http://baron_adminfront:5173;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# # WebSocket support (for Vite HMR)
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection "upgrade";
|
||||
# }
|
||||
|
||||
# # DevFront (Vite Dev Server or Nginx)
|
||||
# location /dev {
|
||||
# proxy_pass http://baron_devfront:5173;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# # WebSocket support (for Vite HMR)
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection "upgrade";
|
||||
# }
|
||||
|
||||
# --- UserFront Static Files ---
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
Reference in New Issue
Block a user