diff --git a/.env.sample b/.env.sample index bec6f5aa..35fad84e 100644 --- a/.env.sample +++ b/.env.sample @@ -3,7 +3,7 @@ # ========================================== # --- General System --- -APP_ENV=dev # 애플리케이션 실행 환경 (dev, stage, production) +APP_ENV=stage # 애플리케이션 실행 환경 (dev, stage, production) TZ=Asia/Seoul # --- Infrastructure Ports --- @@ -21,6 +21,7 @@ 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! +JWT_SECRET=super-secret-key-must-be-32-bytes! REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준) # Descope Project ID (Required for Auth) @@ -42,10 +43,54 @@ AWS_SECRET_ACCESS_KEY=... AWS_SES_SENDER=no-reply@baron.co.kr # --- 관리자 page pw --- -ADMIN_PASSWORD=admin +ADMIN_EMAIL=admin@baron.co.kr +ADMIN_PASSWORD=adminPasswordIsNotSimple # --- URLs for Proxy/Handoff --- FRONTEND_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용) BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소 -IDP_PROVIDER=descopse, hydra ... +# IDP_PROVIDER는 우선순위 순으로 콤마 구분 (예: Kratos/Hydra 우선, Descope 백업) +IDP_PROVIDER=ory,descope + + +# ory-stack 변수들 +ORY_POSTGRES_TAG=17-trixie +ORY_POSTGRES_USER=ory +ORY_POSTGRES_PASSWORD=EuBV5ywvXFehkggHQrnYo5727MseEi6i9 +ORY_POSTGRES_DB=ory +ORY_POSTGRES_PORT=5433 + +KRATOS_DB=ory_kratos +HYDRA_DB=ory_hydra +KETO_DB=ory_keto + +# Ory Kratos Configuration +KRATOS_VERSION=v25.4.0-distroless +KRATOS_PUBLIC_PORT=4433 +KRATOS_ADMIN_PORT=4434 + +KRATOS_UI_NODE_VERSION=v25.4.0 +KRATOS_UI_PORT=4455 + +# Ory Hydra Configuration +HYDRA_VERSION=v25.4.0-distroless +HYDRA_PUBLIC_PORT=4441 +HYDRA_ADMIN_PORT=4445 + +# Ory Keto Configuration +KETO_VERSION=v25.4.0-distroless +KETO_READ_PORT=4466 +KETO_WRITE_PORT=4467 + +# 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 +HYDRA_ADMIN_URL=http://hydra:4445 +JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json + +# Kratos Selfservice UI required secrets (local only) +COOKIE_SECRET=localcookie123 +CSRF_COOKIE_NAME=__HOST-baronSSO_csrf +CSRF_COOKIE_SECRET=localcsrf123 diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..3bbfca16 --- /dev/null +++ b/Makefile @@ -0,0 +1,67 @@ +# Makefile for Ory Stack + +# 환경 변수 로드 +ifneq (,$(wildcard ./.env)) + include .env + export +endif + +# --- 기본 실행 (All Apps) --- +# DB 상태 체크 후 모든 App 서비스 실행 +up: check-db + @echo "Starting ALL Ory services (Profile: app)..." + docker compose --profile app up -d + +# --- 개별 서비스 실행 --- +# Kratos만 실행 +up-kratos: check-db + @echo "Starting Ory Kratos..." + docker compose --profile kratos 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 + +# --- 종료 (Down) --- +# 모든 서비스 및 인프라 종료 +down: + @echo "Stopping ALL services (Infra + App)..." + docker compose --profile infra --profile app down + +# App 서비스만 종료 (DB는 유지) +down-app: + @echo "Stopping App services..." + docker compose --profile app down + +# 인프라만 종료 (주의: App 서비스 에러 가능성 있음) +down-infra: + @echo "Stopping Infrastructure..." + docker compose --profile infra 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."; \ + echo "Please run 'make up-infra' first."; \ + exit 1; \ + else \ + echo "Database is healthy."; \ + fi + +# 로그 확인 +logs: + docker compose -f compose.ory.yaml logs -f diff --git a/README.md b/README.md index 7b6ba87f..9d2e2836 100644 --- a/README.md +++ b/README.md @@ -86,24 +86,43 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. Magic ```env DESCOPE_PROJECT_ID=P2t... ``` +3. **IDP 우선순위와 Ory 엔드포인트를 지정**합니다. 기본값은 Ory 우선 + Descope 폴백입니다. + ```env + IDP_PROVIDER=ory,descope + KRATOS_ADMIN_URL=http://kratos:4434 + HYDRA_ADMIN_URL=http://hydra:4445 + ``` ### 전체 스택 실행 (Running the Stack) -#### 1. 인프라 실행 (데이터베이스) -데이터 레이어를 먼저 실행합니다. +#### 1. 네트워크 생성 (최초 1회) +Ory Stack과 애플리케이션 간 통신을 위한 도커 네트워크를 생성합니다. ```bash -docker compose -f compose.infra.yaml up -d +# ory-net은 bridge 모드로 생성 +docker network create -d bridge ory-net +docker network create hydranet +docker network create kratosnet ``` -#### 2. 애플리케이션 실행 +#### 2. 인프라 및 Ory Stack 실행 +데이터베이스와 Ory 서비스(Kratos, Hydra, Keto 등)를 실행합니다. +```bash +docker compose -f compose.infra.yaml -f compose.ory.yaml up -d +``` + +#### 3. 애플리케이션 실행 Frontend와 Backend 서비스를 실행합니다. ```bash -docker compose up +docker compose -f docker-compose.yaml up -d ``` +(또는 한번에 실행: `docker compose -f compose.infra.yaml -f compose.ory.yaml -f docker-compose.yaml up -d`) - **Frontend**: http://localhost:5000 접속 - **Backend**: http://localhost:3000 (API) - **ClickHouse**: http://localhost:8123 +- **Kratos Public**: http://localhost:4433 +- **Hydra Public**: http://localhost:4444 +- **Kratos UI**: http://localhost:4455 ### 로컬 개발 (Manual) Docker 없이 코드를 수정하며 개발하려면: diff --git a/README_en.md b/README_en.md index cf220a8e..f70ec13a 100644 --- a/README_en.md +++ b/README_en.md @@ -44,6 +44,12 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link ```env DESCOPE_PROJECT_ID=P2t... ``` +3. Set the **IDP priority and Ory admin endpoints**. The default is Ory first with Descope as fallback. + ```env + IDP_PROVIDER=ory,descope + KRATOS_ADMIN_URL=http://kratos:4434 + HYDRA_ADMIN_URL=http://hydra:4445 + ``` ### Running the Stack diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c0c63452..39495683 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -9,6 +9,7 @@ import ( "baron-sso-backend/internal/service" "baron-sso-backend/internal/validator" "fmt" + "log" "log/slog" "os" "strconv" @@ -22,6 +23,11 @@ import ( "github.com/gofiber/fiber/v2/middleware/recover" "github.com/gofiber/fiber/v2/middleware/requestid" "github.com/joho/godotenv" + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormLogger "gorm.io/gorm/logger" + + "baron-sso-backend/internal/bootstrap" ) func getEnv(key, fallback string) string { @@ -93,6 +99,7 @@ func main() { // ----------------------------------- // 2. Initialize DB Connections + // ClickHouse chHost := getEnv("CLICKHOUSE_HOST", "localhost") chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000")) chUser := getEnv("CLICKHOUSE_USER", "default") @@ -104,6 +111,46 @@ func main() { slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err) } + // PostgreSQL (Meta Store) + pgHost := getEnv("DB_HOST", "localhost") + pgPort := getEnv("DB_PORT", "5432") + pgUser := getEnv("DB_USER", "baron") + pgPass := getEnv("DB_PASSWORD", "password") + pgName := getEnv("DB_NAME", "baron_sso") + + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul", + pgHost, pgUser, pgPass, pgName, pgPort) + + gormLog := gormLogger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + gormLogger.Config{ + SlowThreshold: time.Second, + LogLevel: gormLogger.Warn, + IgnoreRecordNotFoundError: true, + Colorful: true, + }, + ) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: gormLog, + }) + if err != nil { + slog.Error("❌ Failed to connect to PostgreSQL", "error", err) + // For local dev without Postgres, we might want to continue or panic. + // But bootstrap requires DB. + if getEnv("APP_ENV", "dev") == "production" { + os.Exit(1) + } + } else { + slog.Info("✅ Connected to PostgreSQL") + + // Run Bootstrap (Migrations & Seeding) + if err := bootstrap.Run(db); err != nil { + slog.Error("❌ Bootstrap failed", "error", err) + // Panic or Exit depending on policy. + } + } + redisService, err := service.NewRedisService() if err != nil { slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err) @@ -111,7 +158,7 @@ func main() { // 2. Initialize Handlers auditHandler := handler.NewAuditHandler(auditRepo) - authHandler := handler.NewAuthHandler(redisService) + authHandler := handler.NewAuthHandler(redisService, idpProvider) adminHandler := handler.NewAdminHandler() // 3. Initialize Fiber diff --git a/backend/go.mod b/backend/go.mod index 4fa90ee1..b82896d0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -13,6 +13,10 @@ require ( github.com/go-redis/redis/v8 v8.11.5 github.com/gofiber/fiber/v2 v2.52.10 github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.46.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 ) require ( @@ -35,8 +39,12 @@ require ( github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect github.com/goccy/go-json v0.10.4 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lestrrat-go/blackmagic v1.0.3 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect @@ -50,6 +58,7 @@ require ( github.com/paulmach/orb v0.12.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // 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/valyala/bytebufferpool v1.0.0 // indirect @@ -58,7 +67,8 @@ require ( go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 4ddc9e4c..0614daa0 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -68,6 +68,18 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -75,8 +87,9 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -117,12 +130,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 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/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= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -169,6 +186,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -197,8 +216,9 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= @@ -206,3 +226,7 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go new file mode 100644 index 00000000..fcbe9b5d --- /dev/null +++ b/backend/internal/bootstrap/bootstrap.go @@ -0,0 +1,81 @@ +package bootstrap + +import ( + "baron-sso-backend/internal/domain" + "errors" + "fmt" + "log/slog" + "os" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// Run executes the application bootstrap logic (migrations, seeding, etc.) +func Run(db *gorm.DB) error { + slog.Info("[Bootstrap] Starting application bootstrap...") + + // 1. Auto Migration + if err := migrateSchemas(db); err != nil { + return fmt.Errorf("migration failed: %w", err) + } + + // 2. Seed Initial Admin User + if err := seedAdminUser(db); err != nil { + return fmt.Errorf("seeding admin failed: %w", err) + } + + slog.Info("[Bootstrap] Bootstrap completed successfully.") + return nil +} + +func migrateSchemas(db *gorm.DB) error { + slog.Info("[Bootstrap] Migrating database schemas...") + // Add all domain models here + return db.AutoMigrate( + &domain.User{}, + // &domain.RelyingParty{}, // TODO: Uncomment when model is ready + // &domain.UserConsent{}, // TODO: Uncomment when model is ready + ) +} + +func seedAdminUser(db *gorm.DB) error { + adminEmail := os.Getenv("ADMIN_EMAIL") + adminPassword := os.Getenv("ADMIN_PASSWORD") + + if adminEmail == "" || adminPassword == "" { + slog.Warn("[Bootstrap] ADMIN_EMAIL or ADMIN_PASSWORD not set. Skipping admin seeding.") + return nil + } + + var user domain.User + if err := db.Where("email = ?", adminEmail).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + slog.Info("[Bootstrap] Creating initial admin user", "email", adminEmail) + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + + adminUser := domain.User{ + Email: adminEmail, + PasswordHash: string(hashedPassword), + Name: "System Admin", + Role: "admin", // Assuming 'role' field exists or handling via attributes + // Add other required fields + } + + if err := db.Create(&adminUser).Error; err != nil { + return err + } + slog.Info("[Bootstrap] Admin user created successfully.") + } else { + return err + } + } else { + slog.Info("[Bootstrap] Admin user already exists.", "email", adminEmail) + } + + return nil +} diff --git a/backend/internal/domain/idp_models.go b/backend/internal/domain/idp_models.go index 3c71936c..694640d0 100644 --- a/backend/internal/domain/idp_models.go +++ b/backend/internal/domain/idp_models.go @@ -34,6 +34,8 @@ type Token struct { type AuthInfo struct { SessionToken *Token RefreshToken *Token + // Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다. + Subject string } // IdentityProvider is the interface that all IDP adapters must implement. @@ -42,6 +44,10 @@ type IdentityProvider interface { // GetMetadata returns the schema support information for this IDP. // This is used for startup-time validation. GetMetadata() (*IDPMetadata, error) + // CreateUser는 BrokerUser 스키마를 기반으로 신규 사용자를 생성하고 주체 ID(예: identity.id)를 반환합니다. + CreateUser(user *BrokerUser, password string) (string, error) + // SignIn은 로그인 ID/비밀번호로 인증해 세션 정보를 반환합니다. + SignIn(loginID, password string) (*AuthInfo, error) InitiatePasswordReset(loginID, redirectUrl string) error VerifyPasswordResetToken(token string) (*AuthInfo, error) UpdateUserPassword(loginID, newPassword string, r *http.Request) error diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go new file mode 100644 index 00000000..68b45d82 --- /dev/null +++ b/backend/internal/domain/user.go @@ -0,0 +1,33 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// User represents the user model stored in PostgreSQL +type User struct { + ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + PasswordHash string `gorm:"not null" json:"-"` + Name string `gorm:"not null" json:"name"` + Phone string `json:"phone"` + Role string `gorm:"default:'user'" json:"role"` // 'admin', 'user' + AffiliationType string `json:"affiliationType"` + CompanyCode string `json:"companyCode"` + Department string `json:"department"` + Status string `gorm:"default:'active'" json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// BeforeCreate hook to generate UUID if not present +func (u *User) BeforeCreate(tx *gorm.DB) (err error) { + if u.ID == "" { + u.ID = uuid.New().String() + } + return +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 66d51330..035340b5 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -2,7 +2,6 @@ package handler import ( "baron-sso-backend/internal/domain" - "baron-sso-backend/internal/idp" "baron-sso-backend/internal/logger" "baron-sso-backend/internal/service" "context" @@ -14,7 +13,6 @@ import ( "math/rand" "os" "regexp" - "strconv" "strings" "time" @@ -70,7 +68,7 @@ func GenerateSecureToken(length int) string { return hex.EncodeToString(b) } -func NewAuthHandler(redisService *service.RedisService) *AuthHandler { +func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider) *AuthHandler { projectID := os.Getenv("DESCOPE_PROJECT_ID") managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY") @@ -86,13 +84,6 @@ func NewAuthHandler(redisService *service.RedisService) *AuthHandler { } } - idpProvider, err := idp.InitializeProvider() - if err != nil { - slog.Error("Failed to initialize IDP Provider", "error", err) - // Depending on the application's needs, you might want to panic here - // if the IDP provider is essential for the application to run. - } - return &AuthHandler{ ProjectID: projectID, SmsService: service.NewSmsService(), @@ -342,12 +333,11 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Phone not verified"}) } - // 3. Create User in Descope - if h.DescopeClient == nil { + if h.IdpProvider == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"}) } - // Normalize Phone for Descope (E.164) + // Normalize Phone (E.164 형태로 보관) normalizedPhone := strings.ReplaceAll(req.Phone, "-", "") normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "") if strings.HasPrefix(normalizedPhone, "010") { @@ -356,54 +346,42 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { normalizedPhone = "+" + normalizedPhone } - descopeUser := &descope.UserRequest{} - descopeUser.Email = req.Email - descopeUser.Phone = normalizedPhone - descopeUser.Name = req.Name - descopeUser.CustomAttributes = map[string]any{ + // IDP에 전달할 BrokerUser 스키마 구성 + attributes := map[string]interface{}{ + "department": req.Department, "affiliationType": req.AffiliationType, "companyCode": req.CompanyCode, - "department": req.Department, - "termsAccepted": req.TermsAccepted, - "createdAt": time.Now().Format(time.RFC3339), + // grade는 기존 스키마 필수 키이므로 기본값을 설정 + "grade": "member", + } + brokerUser := &domain.BrokerUser{ + Email: req.Email, + Name: req.Name, + PhoneNumber: normalizedPhone, + Attributes: attributes, } - // Create user - // Note: Descope `Create` does not set password. We usually need `Create` then `Password().Update` - // or use a specialized signup flow. - // `Management.User().Create` creates a user but doesn't set a password credential immediately unless specified? - // Actually `User().Create` creates the identity. - // To set password, we use `h.DescopeClient.Management.User().SetPassword(...)` - - // Check if user exists (Double check) - exists, _ := h.DescopeClient.Management.User().Load(context.Background(), req.Email) - if exists != nil { - return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"}) - } - - // Create - _, err := h.DescopeClient.Management.User().Create(context.Background(), req.Email, descopeUser) + providerID, err := h.IdpProvider.CreateUser(brokerUser, req.Password) if err != nil { - slog.Error("[Signup] Failed to create user in Descope", "error", err) + slog.Error("[Signup] Failed to create user via IDP", "provider", h.IdpProvider.Name(), "error", err) + if strings.Contains(err.Error(), "already exists") { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"}) + } return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create user"}) } - // Set Password - err = h.DescopeClient.Management.User().SetPassword(context.Background(), req.Email, req.Password) - if err != nil { - slog.Error("[Signup] Failed to set password", "error", err) - // Rollback? Delete user? - h.DescopeClient.Management.User().Delete(context.Background(), req.Email) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": fmt.Sprintf("Failed to set password: %v", err)}) - } - // 4. Cleanup Redis h.RedisService.Delete(emailKey) h.RedisService.Delete(phoneKey) - slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType) + slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType, "provider", h.IdpProvider.Name(), "subject", providerID) - return c.JSON(fiber.Map{"success": true, "message": "User registered successfully"}) + return c.JSON(fiber.Map{ + "success": true, + "message": "User registered successfully", + "provider": h.IdpProvider.Name(), + "subject": providerID, + }) } // --- Helpers --- @@ -750,38 +728,43 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { ale.Log(slog.LevelInfo, "Attempting to login") - if h.DescopeClient == nil { + if h.IdpProvider == nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) - ale.DescopeError = "Descope Client is nil!" - ale.Log(slog.LevelError, "Descope Client is nil") + ale.DescopeError = "IDP Provider is nil" + ale.Log(slog.LevelError, "IDP Provider is nil") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) } - // Sign in using Descope - authInfo, err := h.DescopeClient.Auth.Password().SignIn(context.Background(), req.LoginID, req.Password, nil) + authInfo, err := h.IdpProvider.SignIn(loginID, req.Password) if err != nil { ale.Status = fiber.StatusUnauthorized ale.LatencyMs = time.Since(startTime) ale.DescopeError = err.Error() - ale.Log(slog.LevelWarn, "Descope sign-in failed") - - // [Changed] Check if it's a "User not found" error to be more specific - if strings.Contains(err.Error(), "E062107") || strings.Contains(err.Error(), "not found") { + ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name())) + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "identity") { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"}) } - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"}) } ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) ale.SessionJwt = authInfo.SessionToken.JWT - ale.Log(slog.LevelInfo, "Login successful") - return c.JSON(fiber.Map{ + ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject)) + + resp := fiber.Map{ "sessionJwt": authInfo.SessionToken.JWT, "status": "ok", - }) + "provider": h.IdpProvider.Name(), + } + if authInfo.RefreshToken != nil { + resp["refreshJwt"] = authInfo.RefreshToken.JWT + } + if authInfo.Subject != "" { + resp["subject"] = authInfo.Subject + } + return c.JSON(resp) } // InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 Descope를 통해 이메일 또는 SMS를 보냅니다. @@ -997,6 +980,12 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { ale := logger.NewAuditLogEntry(c, "complete") ale.Operation = "UpdateUserPassword" + providerName := "unknown" + if h.IdpProvider != nil { + providerName = h.IdpProvider.Name() + } + isDescopeProvider := strings.Contains(strings.ToLower(providerName), "descope") + var req struct { NewPassword string `json:"newPassword"` } @@ -1044,78 +1033,79 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { // 디버깅을 위해 요청된 새 비밀번호를 로그로 출력 ale.Log(slog.LevelInfo, "Received new password for reset") - // Validate password complexity dynamically based on Descope policy - policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background()) - if err != nil { - // If policy fetch fails, log warning and proceed (or fallback to basic check) - ale.Log(slog.LevelWarn, "Failed to fetch password policy, skipping dynamic validation: "+err.Error()) - } else { - if len(req.NewPassword) < int(policy.MinLength) { - ale.Status = fiber.StatusBadRequest - ale.LatencyMs = time.Since(startTime) - ale.DescopeError = fmt.Sprintf("Password must be at least %d characters long", policy.MinLength) - ale.Log(slog.LevelWarn, "Validation failed: password too short") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError}) - } - if policy.Lowercase { - if ok, _ := regexp.MatchString(`[a-z]`, req.NewPassword); !ok { - ale.Status = fiber.StatusBadRequest - ale.LatencyMs = time.Since(startTime) - ale.DescopeError = "Password must contain at least one lowercase letter" - ale.Log(slog.LevelWarn, "Validation failed: no lowercase letter") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one lowercase letter"}) - } - } - if policy.Uppercase { - if ok, _ := regexp.MatchString(`[A-Z]`, req.NewPassword); !ok { - ale.Status = fiber.StatusBadRequest - ale.LatencyMs = time.Since(startTime) - ale.DescopeError = "Password must contain at least one uppercase letter" - ale.Log(slog.LevelWarn, "Validation failed: no uppercase letter") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one uppercase letter"}) - } - } - if policy.Number { - if ok, _ := regexp.MatchString(`[0-9]`, req.NewPassword); !ok { - ale.Status = fiber.StatusBadRequest - ale.LatencyMs = time.Since(startTime) - ale.DescopeError = "Password must contain at least one number" - ale.Log(slog.LevelWarn, "Validation failed: no number") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one number"}) - } - } - if policy.NonAlphanumeric { - if ok, _ := regexp.MatchString(`[\W_]`, req.NewPassword); !ok { - ale.Status = fiber.StatusBadRequest - ale.LatencyMs = time.Since(startTime) - ale.DescopeError = "Password must contain at least one special character" - ale.Log(slog.LevelWarn, "Validation failed: no special character") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one special character"}) - } - } + if len(req.NewPassword) < 8 { + ale.Status = fiber.StatusBadRequest + ale.LatencyMs = time.Since(startTime) + ale.DescopeError = "Password must be at least 8 characters long" + ale.Log(slog.LevelWarn, "Validation failed: password too short (fallback policy)") + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError}) } - ale.Log(slog.LevelInfo, "Attempting to update password via Descope Auth API") + if isDescopeProvider && h.DescopeClient != nil { + // Validate password complexity (Descope only) + policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background()) + if err != nil { + ale.Log(slog.LevelWarn, "Failed to fetch password policy, skipping dynamic validation: "+err.Error()) + } else { + if len(req.NewPassword) < int(policy.MinLength) { + ale.Status = fiber.StatusBadRequest + ale.LatencyMs = time.Since(startTime) + ale.DescopeError = fmt.Sprintf("Password must be at least %d characters long", policy.MinLength) + ale.Log(slog.LevelWarn, "Validation failed: password too short") + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError}) + } + if policy.Lowercase { + if ok, _ := regexp.MatchString(`[a-z]`, req.NewPassword); !ok { + ale.Status = fiber.StatusBadRequest + ale.LatencyMs = time.Since(startTime) + ale.DescopeError = "Password must contain at least one lowercase letter" + ale.Log(slog.LevelWarn, "Validation failed: no lowercase letter") + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one lowercase letter"}) + } + } + if policy.Uppercase { + if ok, _ := regexp.MatchString(`[A-Z]`, req.NewPassword); !ok { + ale.Status = fiber.StatusBadRequest + ale.LatencyMs = time.Since(startTime) + ale.DescopeError = "Password must contain at least one uppercase letter" + ale.Log(slog.LevelWarn, "Validation failed: no uppercase letter") + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one uppercase letter"}) + } + } + if policy.Number { + if ok, _ := regexp.MatchString(`[0-9]`, req.NewPassword); !ok { + ale.Status = fiber.StatusBadRequest + ale.LatencyMs = time.Since(startTime) + ale.DescopeError = "Password must contain at least one number" + ale.Log(slog.LevelWarn, "Validation failed: no number") + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one number"}) + } + } + if policy.NonAlphanumeric { + if ok, _ := regexp.MatchString(`[\W_]`, req.NewPassword); !ok { + ale.Status = fiber.StatusBadRequest + ale.LatencyMs = time.Since(startTime) + ale.DescopeError = "Password must contain at least one special character" + ale.Log(slog.LevelWarn, "Validation failed: no special character") + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one special character"}) + } + } + } + } else if isDescopeProvider && h.DescopeClient == nil { + ale.Log(slog.LevelWarn, "Descope selected but client is nil; skipping policy validation") + } - // Descope Management API를 통해 비밀번호 업데이트 - if h.DescopeClient == nil { + ale.Log(slog.LevelInfo, "Attempting to update password via IDP", slog.String("idp", providerName)) + + if h.IdpProvider == nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) - ale.DescopeError = "Descope Client is nil!" - ale.Log(slog.LevelError, "Descope Client is nil") + ale.DescopeError = "IDP Provider is nil" + ale.Log(slog.LevelError, "IDP Provider is nil") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) } - if err := h.DescopeClient.Management.User().SetActivePassword(context.Background(), loginID, req.NewPassword); err != nil { - // Descope 에러 상세를 감사 로그에 포함 - if de, ok := err.(*descope.Error); ok { - if statusRaw, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok { - if statusInt, convErr := strconv.Atoi(fmt.Sprintf("%v", statusRaw)); convErr == nil { - ale.DescopeStatus = statusInt - } - } - ale.DescopeBody = de.Message - } + if err := h.IdpProvider.UpdateUserPassword(loginID, req.NewPassword, nil); err != nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = err.Error() diff --git a/backend/internal/idp/factory.go b/backend/internal/idp/factory.go index 5b82721a..b05b812c 100644 --- a/backend/internal/idp/factory.go +++ b/backend/internal/idp/factory.go @@ -3,12 +3,26 @@ package idp import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" + "errors" "fmt" "log/slog" + "net/http" "os" "strings" ) +// Ory 계열(kratos/hydra)와 Descope 등 공급자 문자열을 정규화하기 위한 매핑. +var providerAliases = map[string]string{ + "ory": "ory", + "hydra": "ory", + "kratos": "ory", + "ory-kratos": "ory", + "ory_hydra": "ory", + "ory_kratos": "ory", + "descope": "descope", + "descope_sso": "descope", +} + // getEnv는 환경 변수를 읽거나 대체 값을 반환하는 헬퍼 함수입니다. func getEnv(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { @@ -22,42 +36,182 @@ func getEnv(key, fallback string) string { func InitializeProvider() (domain.IdentityProvider, error) { rawProviders := getEnv("IDP_PROVIDER", "descope") // 기본값은 descope입니다. providers := strings.Split(rawProviders, ",") - slog.Info("Initializing IDP", "providers", rawProviders) + slog.Info("Initializing IDP chain", "providers", rawProviders) + var initialized []domain.IdentityProvider for _, p := range providers { providerName := strings.TrimSpace(strings.ToLower(p)) + if canonical, ok := providerAliases[providerName]; ok { + providerName = canonical + } + switch providerName { + case "ory": + // Kratos/Hydra 주 공급자 + oryProvider := service.NewOryProvider() + initialized = append(initialized, oryProvider) + case "descope": descopeProjectID := getEnv("DESCOPE_PROJECT_ID", "") descopeManagementKey := getEnv("DESCOPE_MANAGEMENT_KEY", "") // 선택된 공급자에 대한 키가 설정되었는지 확인하기 위한 기본 유효성 검사 if descopeProjectID == "" || descopeManagementKey == "" { - return nil, fmt.Errorf("DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY must be set for the 'descope' provider") + slog.Warn("Skipping Descope provider due to missing credentials") + continue } - return service.NewDescopeProvider(descopeProjectID, descopeManagementKey), nil + initialized = append(initialized, service.NewDescopeProvider(descopeProjectID, descopeManagementKey)) - // --- 향후 공급자 구현 --- - // case "ory": - // // oryURL := getEnv("ORY_URL", "") - // // if oryURL == "" { - // // return nil, fmt.Errorf("ORY_URL must be set for the 'ory' provider") - // // } - // // return service.NewOryProvider(oryURL), nil - // // return nil, fmt.Errorf(\"'ory' provider is not yet implemented\") - - // case "keycloak": - // // keycloakURL := getEnv("KEYCLOAK_URL", "") - // // keycloakRealm := getEnv("KEYCLOAK_REALM", "") - // // if keycloakURL == "" || keycloakRealm == "" { - // // return nil, fmt.Errorf("KEYCLOAK_URL and KEYCLOAK_REALM must be set for the 'keycloak' provider") - // // } - // // return service.NewKeycloakProvider(keycloakURL, keycloakRealm), nil - // // return nil, fmt.Errorf(\"'keycloak' provider is not yet implemented\") default: // 알 수 없는 공급자는 건너뛰고 다음 후보를 시도 slog.Warn("Skipping unsupported IDP provider entry", "provider", providerName) } } - return nil, fmt.Errorf("unsupported or unknown IDP_PROVIDER specified: %s", rawProviders) + if len(initialized) == 0 { + return nil, fmt.Errorf("no valid IDP_PROVIDER entries configured from: %s", rawProviders) + } + + if len(initialized) == 1 { + slog.Info("Initialized IDP provider", "provider", initialized[0].Name()) + return initialized[0], nil + } + + chain := newChainedProvider(initialized) + slog.Info("Initialized IDP provider chain", "providers", chain.Name()) + return chain, nil +} + +// newChainedProvider는 우선순위 순으로 IDP를 시도하는 체인을 생성합니다. +func newChainedProvider(providers []domain.IdentityProvider) domain.IdentityProvider { + names := make([]string, len(providers)) + for i, p := range providers { + names[i] = p.Name() + } + return &chainedProvider{ + providers: providers, + names: names, + } +} + +// chainedProvider는 다중 IDP를 우선순위대로 호출하며 실패 시 폴백합니다. +type chainedProvider struct { + providers []domain.IdentityProvider + names []string +} + +func (c *chainedProvider) Name() string { + return strings.Join(c.names, " > ") +} + +func (c *chainedProvider) GetMetadata() (*domain.IDPMetadata, error) { + supported := make([]string, 0) + seen := make(map[string]bool) + + for _, p := range c.providers { + meta, err := p.GetMetadata() + if err != nil { + return nil, fmt.Errorf("failed to fetch metadata from %s: %w", p.Name(), err) + } + for _, field := range meta.SupportedFields { + if !seen[field] { + seen[field] = true + supported = append(supported, field) + } + } + } + + return &domain.IDPMetadata{SupportedFields: supported}, nil +} + +func (c *chainedProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { + var errs []error + for idx, p := range c.providers { + id, err := p.CreateUser(user, password) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + 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 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...)) +} + +func (c *chainedProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { + var errs []error + for idx, p := range c.providers { + info, err := p.SignIn(loginID, password) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "SignIn", "error", err) + continue + } + if idx > 0 { + slog.Info("IDP fallback succeeded", "operation", "SignIn", "provider", p.Name()) + } + return info, nil + } + if len(errs) == 0 { + return nil, fmt.Errorf("no IDP providers available for SignIn") + } + return nil, fmt.Errorf("all IDP providers failed for SignIn: %w", errors.Join(errs...)) +} + +func (c *chainedProvider) InitiatePasswordReset(loginID, redirectUrl string) error { + return c.tryProviders("InitiatePasswordReset", func(p domain.IdentityProvider) error { + return p.InitiatePasswordReset(loginID, redirectUrl) + }) +} + +func (c *chainedProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) { + var errs []error + for idx, p := range c.providers { + info, err := p.VerifyPasswordResetToken(token) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + slog.Warn("IDP VerifyPasswordResetToken failed", "provider", p.Name(), "error", err) + continue + } + if idx > 0 { + slog.Info("IDP fallback succeeded", "operation", "VerifyPasswordResetToken", "provider", p.Name()) + } + return info, nil + } + + if len(errs) == 0 { + return nil, fmt.Errorf("no IDP providers available for VerifyPasswordResetToken") + } + return nil, fmt.Errorf("all IDP providers failed for VerifyPasswordResetToken: %w", errors.Join(errs...)) +} + +func (c *chainedProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { + return c.tryProviders("UpdateUserPassword", func(p domain.IdentityProvider) error { + return p.UpdateUserPassword(loginID, newPassword, r) + }) +} + +func (c *chainedProvider) tryProviders(operation string, fn func(domain.IdentityProvider) error) error { + var errs []error + for idx, p := range c.providers { + if err := fn(p); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + slog.Warn("IDP provider failed", "provider", p.Name(), "operation", operation, "error", err) + continue + } + if idx > 0 { + slog.Info("IDP fallback succeeded", "operation", operation, "provider", p.Name()) + } + return nil + } + + if len(errs) == 0 { + return fmt.Errorf("no IDP providers available for %s", operation) + } + return fmt.Errorf("all IDP providers failed for %s: %w", operation, errors.Join(errs...)) } diff --git a/backend/internal/idp/factory_test.go b/backend/internal/idp/factory_test.go new file mode 100644 index 00000000..923c2083 --- /dev/null +++ b/backend/internal/idp/factory_test.go @@ -0,0 +1,115 @@ +package idp + +import ( + "baron-sso-backend/internal/domain" + "errors" + "net/http" + "reflect" + "strings" + "testing" +) + +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 +} + +func (s *stubProvider) Name() string { return s.name } + +func (s *stubProvider) GetMetadata() (*domain.IDPMetadata, error) { + return &domain.IDPMetadata{SupportedFields: s.metadata}, nil +} + +func (s *stubProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { + s.createCalls++ + if s.createErr != nil { + return "", s.createErr + } + return "created-id", nil +} + +func (s *stubProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { + s.signInCalls++ + if s.signInErr != nil { + return nil, s.signInErr + } + return &domain.AuthInfo{Subject: "subject-123"}, nil +} + +func (s *stubProvider) InitiatePasswordReset(loginID, redirectUrl string) error { + s.initiateCalls++ + return s.initiateErr +} + +func (s *stubProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) { + s.verifyCalls++ + if s.verifyErr != nil { + return nil, s.verifyErr + } + if s.verifyResponse != nil { + return s.verifyResponse, nil + } + return &domain.AuthInfo{}, nil +} + +func (s *stubProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { + s.updateCalls++ + return s.updateErr +} + +func TestChainedProviderMetadataUnion(t *testing.T) { + p1 := &stubProvider{name: "primary", metadata: []string{"id", "email"}} + p2 := &stubProvider{name: "backup", metadata: []string{"email", "phone_number", "grade"}} + + chain := newChainedProvider([]domain.IdentityProvider{p1, p2}) + meta, err := chain.GetMetadata() + if err != nil { + t.Fatalf("GetMetadata returned error: %v", err) + } + + expected := []string{"id", "email", "phone_number", "grade"} + if !reflect.DeepEqual(meta.SupportedFields, expected) { + t.Fatalf("metadata mismatch: got %v, want %v", meta.SupportedFields, expected) + } +} + +func TestChainedProviderUpdateUserPasswordFallback(t *testing.T) { + p1 := &stubProvider{name: "primary", metadata: []string{"id"}, updateErr: errors.New("boom")} + p2 := &stubProvider{name: "backup", metadata: []string{"id"}} + + chain := newChainedProvider([]domain.IdentityProvider{p1, p2}) + if err := chain.UpdateUserPassword("user@example.com", "Sup3r!Pass123", nil); err != nil { + t.Fatalf("expected fallback to succeed, got error: %v", err) + } + if p1.updateCalls != 1 || p2.updateCalls != 1 { + t.Fatalf("unexpected call counts: p1=%d p2=%d", p1.updateCalls, p2.updateCalls) + } +} + +func TestChainedProviderUpdateUserPasswordAllFail(t *testing.T) { + p1 := &stubProvider{name: "primary", metadata: []string{"id"}, updateErr: errors.New("fail1")} + p2 := &stubProvider{name: "backup", metadata: []string{"id"}, updateErr: errors.New("fail2")} + + chain := newChainedProvider([]domain.IdentityProvider{p1, p2}) + err := chain.UpdateUserPassword("user@example.com", "Sup3r!Pass123", nil) + if err == nil { + t.Fatalf("expected error when all providers fail") + } + if !strings.Contains(err.Error(), "all IDP providers failed") { + t.Fatalf("unexpected error: %v", err) + } + if p1.updateCalls != 1 || p2.updateCalls != 1 { + t.Fatalf("unexpected call counts: p1=%d p2=%d", p1.updateCalls, p2.updateCalls) + } +} diff --git a/backend/internal/service/descope_service.go b/backend/internal/service/descope_service.go index 1dca9231..e8869ae9 100644 --- a/backend/internal/service/descope_service.go +++ b/backend/internal/service/descope_service.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "os" + "strings" "time" "github.com/descope/go-sdk/descope" @@ -69,6 +70,81 @@ func (d *DescopeProvider) GetMetadata() (*domain.IDPMetadata, error) { }, nil } +// CreateUser는 Descope Management API를 사용해 사용자를 생성합니다. +func (d *DescopeProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { + if d.Client == nil { + return "", fmt.Errorf("descope provider: client is nil") + } + if user == nil { + return "", fmt.Errorf("descope provider: user payload is nil") + } + if user.Email == "" || password == "" { + return "", fmt.Errorf("descope provider: email and password are required") + } + + normalizedPhone := user.PhoneNumber + normalizedPhone = strings.ReplaceAll(normalizedPhone, "-", "") + normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "") + if strings.HasPrefix(normalizedPhone, "010") { + normalizedPhone = "+82" + normalizedPhone[1:] + } else if strings.HasPrefix(normalizedPhone, "82") { + normalizedPhone = "+" + normalizedPhone + } + + // 존재 여부 확인 + exists, _ := d.Client.Management.User().Load(context.Background(), user.Email) + if exists != nil { + return "", fmt.Errorf("descope provider: user already exists") + } + + descopeUser := &descope.UserRequest{} + descopeUser.Email = user.Email + descopeUser.Phone = normalizedPhone + descopeUser.Name = user.Name + descopeUser.CustomAttributes = map[string]any{} + for k, v := range user.Attributes { + descopeUser.CustomAttributes[k] = v + } + descopeUser.CustomAttributes["createdAt"] = time.Now().Format(time.RFC3339) + + if _, err := d.Client.Management.User().Create(context.Background(), user.Email, descopeUser); err != nil { + return "", fmt.Errorf("descope provider: create user failed: %w", err) + } + if err := d.Client.Management.User().SetPassword(context.Background(), user.Email, password); err != nil { + _ = d.Client.Management.User().Delete(context.Background(), user.Email) + return "", fmt.Errorf("descope provider: set password failed: %w", err) + } + + slog.Info("Descope user created", "email", user.Email) + return user.Email, nil +} + +// SignIn은 Descope Password 로그인 후 세션 토큰을 반환합니다. +func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { + if d.Client == nil { + return nil, fmt.Errorf("descope provider: client is nil") + } + authInfo, err := d.Client.Auth.Password().SignIn(context.Background(), loginID, password, nil) + if err != nil { + return nil, 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) InitiatePasswordReset(loginID, redirectUrl string) error { ctx := context.Background() err := d.Client.Auth.Password().SendPasswordReset(ctx, loginID, redirectUrl, nil) diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go new file mode 100644 index 00000000..bdbc6afe --- /dev/null +++ b/backend/internal/service/ory_service.go @@ -0,0 +1,331 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "time" +) + +// OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다. +// 지금은 스키마 메타데이터만 반환하며, 나머지 동작은 후속 작업에서 구현합니다. +type OryProvider struct { + KratosAdminURL string + KratosPublicURL string + HydraAdminURL string + HTTPClient *http.Client +} + +func NewOryProvider() *OryProvider { + return &OryProvider{ + KratosAdminURL: getenv("KRATOS_ADMIN_URL", "http://kratos:4434"), + KratosPublicURL: getenv("KRATOS_PUBLIC_URL", "http://kratos:4433"), + HydraAdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"), + } +} + +func (o *OryProvider) Name() string { + return "Ory (Kratos/Hydra)" +} + +// GetMetadata는 BrokerUser가 요구하는 필드를 Kratos traits에 매핑 가능하다는 가정으로 반환합니다. +func (o *OryProvider) GetMetadata() (*domain.IDPMetadata, error) { + return &domain.IDPMetadata{ + SupportedFields: []string{ + "id", "email", "name", "phone_number", + "grade", "department", "affiliationType", "companyCode", + }, + }, nil +} + +// CreateUser는 Kratos Admin API를 통해 identity를 생성합니다. +func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { + if user == nil { + return "", fmt.Errorf("ory provider: user payload is nil") + } + if user.Email == "" || password == "" { + return "", fmt.Errorf("ory provider: email and password are required") + } + + // 중복 확인 + existingID, err := o.findIdentityID(user.Email) + if err != nil { + return "", fmt.Errorf("ory provider: search identity failed: %w", err) + } + if existingID != "" { + return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email) + } + + traits := map[string]interface{}{ + "email": user.Email, + "name": user.Name, + "phone_number": user.PhoneNumber, + } + for k, v := range user.Attributes { + traits[k] = v + } + + payload := map[string]interface{}{ + "schema_id": "default", + "traits": traits, + "credentials": map[string]interface{}{ + "password": map[string]interface{}{ + "config": map[string]string{ + "password": password, + }, + }, + }, + } + + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, fmt.Sprintf("%s/admin/identities", o.KratosAdminURL), bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("ory provider: build create request failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := o.httpClient().Do(req) + if err != nil { + return "", fmt.Errorf("ory provider: create identity request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return "", fmt.Errorf("ory provider: create identity failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + var created struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return "", fmt.Errorf("ory provider: decode create identity response failed: %w", err) + } + + slog.Info("Ory identity created", "identity_id", created.ID, "email", user.Email) + return created.ID, nil +} + +// SignIn은 Kratos Public API의 login API 플로우를 사용해 세션 토큰을 발급합니다. +func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { + if loginID == "" || password == "" { + return nil, fmt.Errorf("ory provider: loginID and password are required") + } + + flowID, err := o.startLoginFlow() + if err != nil { + return nil, err + } + + body, _ := json.Marshal(map[string]string{ + "identifier": loginID, + "password": password, + "method": "password", + }) + 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 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 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 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 response failed: %w", err) + } + if result.SessionToken == "" { + return nil, fmt.Errorf("ory provider: empty session token returned") + } + + slog.Info("Ory login 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 +} + +// 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) + return nil +} + +// VerifyPasswordResetToken는 내부 토큰 검증 흐름을 사용하므로 아직 구현하지 않습니다. +func (o *OryProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) { + return nil, fmt.Errorf("ory provider: VerifyPasswordResetToken not implemented (internal token flow expected)") +} + +// UpdateUserPassword: Kratos Admin API를 통해 비밀번호를 갱신합니다. +func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { + if loginID == "" || newPassword == "" { + return fmt.Errorf("ory provider: loginID or new password missing") + } + + 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) + } + + payload := map[string]interface{}{ + "credentials": map[string]interface{}{ + "password": map[string]interface{}{ + "config": map[string]string{ + "password": newPassword, + }, + }, + }, + } + + body, _ := json.Marshal(payload) + 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 request failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := o.httpClient().Do(req) + if err != nil { + return fmt.Errorf("ory provider: request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("ory provider: password update failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + slog.Info("Ory password updated via Kratos admin", "identity_id", identityID, "loginID", loginID) + return nil +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환 +func (o *OryProvider) findIdentityID(loginID string) (string, error) { + u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL)) + if err != nil { + return "", err + } + + query := u.Query() + query.Set("credentials_identifier", loginID) + u.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil) + if err != nil { + return "", err + } + + resp, err := o.httpClient().Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return "", nil + } + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return "", fmt.Errorf("kratos admin search failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var identities []struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil { + return "", fmt.Errorf("decode response failed: %w", err) + } + if len(identities) == 0 { + return "", nil + } + return identities[0].ID, nil +} + +func (o *OryProvider) httpClient() *http.Client { + if o.HTTPClient != nil { + return o.HTTPClient + } + return &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 5 * time.Second, + }, + } +} + +// 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) + if err != nil { + return "", fmt.Errorf("ory provider: build login flow request failed: %w", err) + } + + resp, err := o.httpClient().Do(req) + if err != nil { + return "", fmt.Errorf("ory provider: login flow request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return "", fmt.Errorf("ory provider: login flow failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var result struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("ory provider: decode login flow failed: %w", err) + } + if result.ID == "" { + return "", fmt.Errorf("ory provider: empty login flow id") + } + return result.ID, nil +} diff --git a/backend/internal/service/ory_service_test.go b/backend/internal/service/ory_service_test.go new file mode 100644 index 00000000..314f9eb0 --- /dev/null +++ b/backend/internal/service/ory_service_test.go @@ -0,0 +1,149 @@ +package service + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// clientForHandler returns an http.Client that routes requests to the given handler +// without real network sockets. +func clientForHandler(h http.Handler) *http.Client { + return &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + // Clone request body for handler + var bodyBytes []byte + if req.Body != nil { + bodyBytes, _ = io.ReadAll(req.Body) + } + r := httptest.NewRequest(req.Method, req.URL.String(), bytes.NewReader(bodyBytes)) + r.Header = req.Header.Clone() + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Result(), nil + }), + } +} + +type roundTripperFunc func(req *http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestUpdateUserPassword_Success(t *testing.T) { + const ( + loginID = "user@example.com" + identityID = "7f0dc8c3-9d5d-4f57-b3d1-123456789abc" + newPassword = "Sup3rStr0ng!Pass#2026" + ) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet: + q := r.URL.Query() + if got := q.Get("credentials_identifier"); got != loginID { + t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got) + } + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"id": identityID}, + }) + return + case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPatch: + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), newPassword) { + t.Fatalf("payload missing new password, body=%s", string(body)) + } + w.WriteHeader(http.StatusOK) + return + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + }) + + provider := &OryProvider{ + KratosAdminURL: "http://kratos-admin.local", + HTTPClient: clientForHandler(handler), + } + + if err := provider.UpdateUserPassword(loginID, newPassword, nil); err != nil { + t.Fatalf("UpdateUserPassword returned error: %v", err) + } +} + +func TestUpdateUserPassword_NotFound(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet { + http.NotFound(w, r) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + }) + + provider := &OryProvider{ + KratosAdminURL: "http://kratos-admin.local", + HTTPClient: clientForHandler(handler), + } + + err := provider.UpdateUserPassword("user@example.com", "Sup3rStr0ng!Pass#2026", nil) + if err == nil || !strings.Contains(err.Error(), "identity not found") { + t.Fatalf("expected identity not found error, got: %v", err) + } +} + +func TestUpdateUserPassword_ServerError(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"id": "abc"}, + }) + return + case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPatch: + http.Error(w, "boom", http.StatusInternalServerError) + return + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + }) + + provider := &OryProvider{ + KratosAdminURL: "http://kratos-admin.local", + HTTPClient: clientForHandler(handler), + } + + err := provider.UpdateUserPassword("user@example.com", "Sup3rStr0ng!Pass#2026", nil) + if err == nil || !strings.Contains(err.Error(), "password update failed") { + t.Fatalf("expected server error, got: %v", err) + } +} + +func TestFindIdentityID_QueryEncoding(t *testing.T) { + loginID := "user+alias@example.com" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + values, _ := url.ParseQuery(r.URL.RawQuery) + if values.Get("credentials_identifier") != loginID { + t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, values.Get("credentials_identifier")) + } + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"id": "id-123"}, + }) + }) + + provider := &OryProvider{ + KratosAdminURL: "http://kratos-admin.local", + HTTPClient: clientForHandler(handler), + } + + id, err := provider.findIdentityID(loginID) + if err != nil { + t.Fatalf("findIdentityID returned error: %v", err) + } + if id != "id-123" { + t.Fatalf("expected id-123, got %s", id) + } +} diff --git a/backend/internal/validator/schema_validator_test.go b/backend/internal/validator/schema_validator_test.go index fcccf780..75ebcbc7 100644 --- a/backend/internal/validator/schema_validator_test.go +++ b/backend/internal/validator/schema_validator_test.go @@ -21,6 +21,14 @@ func (m *MockProvider) GetMetadata() (*domain.IDPMetadata, error) { }, nil } +func (m *MockProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { + return "", nil +} + +func (m *MockProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { + return &domain.AuthInfo{}, nil +} + // Stub implementations to satisfy the IdentityProvider interface for this unit test. func (m *MockProvider) InitiatePasswordReset(loginID, redirectUrl string) error { return nil diff --git a/backend/server b/backend/server new file mode 100755 index 00000000..9ae19587 Binary files /dev/null and b/backend/server differ diff --git a/compose.ory.yaml b/compose.ory.yaml new file mode 100644 index 00000000..5c18aa9a --- /dev/null +++ b/compose.ory.yaml @@ -0,0 +1,200 @@ +services: + postgres_ory: + image: postgres:${ORY_POSTGRES_TAG:-17-alpine} + container_name: ory_postgres + environment: + - POSTGRES_USER=${ORY_POSTGRES_USER:-ory} + - POSTGRES_PASSWORD=${ORY_POSTGRES_PASSWORD:-secret} + - POSTGRES_DB=${ORY_POSTGRES_DB:-ory} + volumes: + - ./docker/ory/init-db:/docker-entrypoint-initdb.d + - ory_postgres_data:/var/lib/postgresql/data + networks: + - ory-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}"] + interval: 5s + timeout: 5s + retries: 5 + + # --- Kratos --- + kratos-migrate: + image: oryd/kratos:${KRATOS_VERSION:-v25.4.0} + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB}?sslmode=disable&max_conns=20 + volumes: + - ./docker/ory/kratos:/etc/config/kratos + command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes + depends_on: + postgres_ory: + condition: service_healthy + networks: + - ory-net + + kratos: + image: oryd/kratos:${KRATOS_VERSION:-v25.4.0} + container_name: ory_kratos + ports: + - "${KRATOS_PUBLIC_PORT:-4433}:4433" + - "${KRATOS_ADMIN_PORT:-4434}:4434" + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB}?sslmode=disable&max_conns=20 + - COOKIE_SECRET=${COOKIE_SECRET:-localcookie123} + volumes: + - ./docker/ory/kratos:/etc/config/kratos + command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier + depends_on: + kratos-migrate: + condition: service_completed_successfully + networks: + - ory-net + - kratosnet + + 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_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434/} + - NODE_ENV=development + - PORT=${KRATOS_UI_PORT:-4455} + - COOKIE_SECRET=${COOKIE_SECRET} + - CSRF_COOKIE_NAME=${CSRF_COOKIE_NAME} + - CSRF_COOKIE_SECRET=${CSRF_COOKIE_SECRET} + networks: + - ory-net + + # --- Hydra --- + hydra-migrate: + image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20 + command: migrate sql -e --yes + depends_on: + postgres_ory: + condition: service_healthy + networks: + - ory-net + + hydra: + image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} + container_name: ory_hydra + ports: + - "${HYDRA_PUBLIC_PORT:-4441}:4444" + - "${HYDRA_ADMIN_PORT:-4445}:4445" + 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} + - URLS_LOGIN=${BACKEND_URL:-http://127.0.0.1:3000}/login + - URLS_CONSENT=${BACKEND_URL:-http://127.0.0.1:3000}/consent + - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} + volumes: + - ./docker/ory/hydra:/etc/config/hydra + command: serve -c /etc/config/hydra/hydra.yml all --dev + depends_on: + hydra-migrate: + condition: service_completed_successfully + networks: + - ory-net + - hydranet + + # --- Keto --- + keto-migrate: + image: oryd/keto:${KETO_VERSION:-v25.4.0} + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20 + volumes: + - ./docker/ory/keto:/etc/config/keto + command: migrate sql -e --yes + depends_on: + postgres_ory: + condition: service_healthy + networks: + - ory-net + + 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: + - ./docker/ory/keto:/etc/config/keto + command: serve -c /etc/config/keto/keto.yml + depends_on: + keto-migrate: + condition: service_completed_successfully + networks: + - ory-net + + # --- Oathkeeper --- + oathkeeper: + image: oryd/oathkeeper:v0.40.6 + container_name: ory_oathkeeper + ports: + - "4456:4456" # API + - "4457:4455" # Proxy + environment: + - LOG_LEVEL=debug + volumes: + - ./docker/ory/oathkeeper:/etc/config/oathkeeper + command: serve proxy -c /etc/config/oathkeeper/oathkeeper.yml + networks: + - ory-net + + # --- 초기화 & 헬스체크 --- + ory_stack_check: + image: alpine:latest + container_name: ory_stack_check + command: > + /bin/sh -c " + apk add --no-cache curl; + echo 'Wait for services...'; + until curl -s http://kratos:4433/health/ready; do sleep 1; done; + until curl -s http://hydra:4444/health/ready; do sleep 1; done; + until curl -s http://keto:4466/health/ready; do sleep 1; done; + echo 'Ory Stack is fully operational!';" + depends_on: + - kratos + - hydra + - keto + networks: + - ory-net + + # 기본 RP (Admin Front 등) 자동 등록 컨테이너 + init-rp: + image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} + environment: + - HYDRA_ADMIN_URL=http://hydra:4445 + command: > + clients create + --endpoint http://hydra:4445 + --id admin-front + --secret admin-secret + --grant-types authorization_code,refresh_token + --response-types code + --scope openid,offline_access,profile,email + --callbacks http://localhost:5000/callback + depends_on: + ory_stack_check: + condition: service_completed_successfully + networks: + - hydranet + +volumes: + ory_postgres_data: + +networks: + ory-net: + external: true + name: ory-net + hydranet: + external: true + name: hydranet + kratosnet: + external: true + name: kratosnet diff --git a/docker-compose.yaml b/docker-compose.yaml index 68328c1c..3fec1ba2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -18,6 +18,9 @@ services: - NAVER_CLOUD_SERVICE_ID=${NAVER_CLOUD_SERVICE_ID} - NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER} - FRONTEND_URL=${FRONTEND_URL} + - IDP_PROVIDER=${IDP_PROVIDER:-ory,descope} + - KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} + - HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445} - DB_HOST=postgres - CLICKHOUSE_HOST=clickhouse - CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000} @@ -29,6 +32,7 @@ services: - infra_check networks: - baron_net + - ory-net volumes: - ./backend:/app command: ["go", "run", "./cmd/server/main.go"] @@ -82,3 +86,6 @@ networks: baron_net: external: true name: baron_network + ory-net: + external: true + name: ory-net diff --git a/docker/ory/hydra/hydra.yml b/docker/ory/hydra/hydra.yml new file mode 100644 index 00000000..b2a9d75e --- /dev/null +++ b/docker/ory/hydra/hydra.yml @@ -0,0 +1,41 @@ +dsn: memory + +serve: + cookies: + same_site_mode: Lax + public: + cors: + enabled: true + allowed_origins: + - "*" + allowed_methods: + - POST + - GET + - PUT + - PATCH + - DELETE + allowed_headers: + - Authorization + - Content-Type + exposed_headers: + - Content-Type + allow_credentials: true + +urls: + self: + issuer: http://127.0.0.1:4444 + consent: http://127.0.0.1:3000/consent + login: http://127.0.0.1:3000/login + logout: http://127.0.0.1:3000/logout + +secrets: + system: + - youReallyNeedToChangeThis + +oidc: + subject_identifiers: + supported_types: + - pairwise + - public + pairwise: + salt: youReallyNeedToChangeThis diff --git a/docker/ory/init-db/01_create_dbs.sh b/docker/ory/init-db/01_create_dbs.sh new file mode 100755 index 00000000..d37e672d --- /dev/null +++ b/docker/ory/init-db/01_create_dbs.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e + +# 환경 변수에서 DB 이름 가져오기 (기본값 설정) +KRATOS_DB=${KRATOS_DB:-ory_kratos} +HYDRA_DB=${HYDRA_DB:-ory_hydra} +KETO_DB=${KETO_DB:-ory_keto} + +# 함수 정의: DB가 없으면 생성 +create_db_if_not_exists() { + local dbname=$1 + if ! psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -lqt | cut -d \| -f 1 | grep -qw "$dbname"; then + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE DATABASE $dbname; +EOSQL + echo "Database '$dbname' created." + else + echo "Database '$dbname' already exists." + fi +} + +create_db_if_not_exists "$KRATOS_DB" +create_db_if_not_exists "$HYDRA_DB" +create_db_if_not_exists "$KETO_DB" diff --git a/docker/ory/keto/keto.yml b/docker/ory/keto/keto.yml new file mode 100644 index 00000000..4a36efc3 --- /dev/null +++ b/docker/ory/keto/keto.yml @@ -0,0 +1,13 @@ +version: v0.11.0 +dsn: memory +serve: + read: + host: 0.0.0.0 + port: 4466 + write: + host: 0.0.0.0 + port: 4467 +namespaces: + location: file:///etc/config/keto/namespaces.yml +log: + level: debug diff --git a/docker/ory/keto/namespaces.yml b/docker/ory/keto/namespaces.yml new file mode 100644 index 00000000..3b11865b --- /dev/null +++ b/docker/ory/keto/namespaces.yml @@ -0,0 +1,7 @@ +namespaces: + - id: 0 + name: default + - id: 1 + name: roles + - id: 2 + name: permissions diff --git a/docker/ory/kratos/identity.schema.json b/docker/ory/kratos/identity.schema.json new file mode 100644 index 00000000..16bcbadf --- /dev/null +++ b/docker/ory/kratos/identity.schema.json @@ -0,0 +1,49 @@ +{ + "$id": "https://schemas.ory.sh/presets/kratos/identity.email.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "E-Mail", + "minLength": 3, + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + } + }, + "recovery": { + "via": "email" + }, + "verification": { + "via": "email" + } + } + }, + "name": { + "type": "object", + "properties": { + "first": { + "type": "string", + "title": "First Name" + }, + "last": { + "type": "string", + "title": "Last Name" + } + } + } + }, + "required": [ + "email" + ], + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/docker/ory/kratos/kratos.yml b/docker/ory/kratos/kratos.yml new file mode 100644 index 00000000..3ee3b617 --- /dev/null +++ b/docker/ory/kratos/kratos.yml @@ -0,0 +1,76 @@ +version: v1.3.0 + +dsn: memory + +serve: + public: + base_url: http://127.0.0.1:4433/ + cors: + enabled: true + admin: + base_url: http://127.0.0.1:4434/ + +selfservice: + default_browser_return_url: http://127.0.0.1:4455/ + allowed_return_urls: + - http://127.0.0.1:4455 + - http://localhost:5000 + + methods: + password: + enabled: true + link: + enabled: true + code: + enabled: true + + flows: + error: + ui_url: http://127.0.0.1:4455/error + settings: + ui_url: http://127.0.0.1:4455/settings + privileged_session_max_age: 15m + recovery: + ui_url: http://127.0.0.1:4455/recovery + use: code + verification: + ui_url: http://127.0.0.1:4455/verification + use: code + logout: + after: + default_browser_return_url: http://127.0.0.1:4455/login + login: + ui_url: http://127.0.0.1:4455/login + lifespan: 10m + registration: + ui_url: http://127.0.0.1:4455/registration + lifespan: 10m + +log: + 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 + +ciphers: + algorithm: xchacha20-poly1305 + +hashers: + algorithm: bcrypt + bcrypt: + cost: 8 + +identity: + 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 diff --git a/docker/ory/oathkeeper/oathkeeper.yml b/docker/ory/oathkeeper/oathkeeper.yml new file mode 100644 index 00000000..044d7f21 --- /dev/null +++ b/docker/ory/oathkeeper/oathkeeper.yml @@ -0,0 +1,41 @@ +serve: + proxy: + port: 4455 + api: + port: 4456 + +errors: + fallback: + - json + +access_rules: + repositories: + - file:///etc/config/oathkeeper/rules.json + +authenticators: + noop: + enabled: true + cookie_session: + enabled: true + config: + check_session_url: http://kratos:4433/sessions/whoami + preserve_path: true + extra_from: "@this" + subject_from: "identity.id" + +authorizers: + allow: + enabled: true + remote_json: + enabled: true + config: + remote: http://keto:4466/check + +mutators: + noop: + enabled: true + id_token: + enabled: true + config: + issuer_url: http://127.0.0.1:4456/ + jwks_url: file:///etc/config/oathkeeper/jwks.json diff --git a/docker/ory/oathkeeper/rules.json b/docker/ory/oathkeeper/rules.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/docker/ory/oathkeeper/rules.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docs/complete-password-reset-refactor.md b/docs/complete-password-reset-refactor.md new file mode 100644 index 00000000..885f64e0 --- /dev/null +++ b/docs/complete-password-reset-refactor.md @@ -0,0 +1,48 @@ ++# CompletePasswordReset 리팩터 전략 (IDP 추상화 전환) + 2 + + 3 +## 현 상황 + 4 +- `AuthHandler.CompletePasswordReset`가 Descope 전용 흐름을 포함: + 5 + - Descope 비밀번호 정책 검사 + 6 + - Descope Management API `SetActivePassword` 호출 + 7 + - IDP Provider 추상화는 마지막에만 호출 + 8 +- 목표: IDP Provider 기반으로 통일하고, Descope 정책 검사는 Descope 선택 시에만 적용. + 9 + + 10 +## 단계별 패치 전략 (쿼터/앵커 분할) + 11 +1) **전역 준비**: 함수 시작부에 `providerName := "unknown"; if h.IdpProvider != nil +{ providerName = h.I + dpProvider.Name() }` 이미 추가됨. + 12 +2) **비밀번호 정책 블록 분리** + 13 + - 현재 Descope 정책 블록을 그대로 두고, 먼저 작은 단위로 변환: + 14 + - 블록 상단 주석을 `// Validate password complexity (Descope only)`로 교체. + 15 + - 블록 전체를 `if providerName == "Descope" && h.DescopeClient != nil { ... }`로 감싸기. + 16 + - 특수문자 `[\W_]` 등 이스케이프가 많으므로, 치환 시 작은 범위의 문장/줄 단위로 교체. + 17 +3) **Descope SetActivePassword 제거 및 공통 UpdateUserPassword 호출** + 18 + - `Attempting to update password via Descope Auth API` 주석부터 Descope 전용 호출을 삭제. + 19 + - 그 위치에 공통 경로 추가: + 20 + ```go + 21 + ale.Log(..., slog.String("idp", providerName)) + 22 + if h.IdpProvider == nil { ... 500 ... } + 23 + if err := h.IdpProvider.UpdateUserPassword(...); err != nil { ... 500 ... } + 24 + ``` + 25 +4) **앵커 선택** + 26 + - 삭제 앵커: `"Attempting to update password via Descope Auth API"` 줄과 그 아래 Descope 클 +라이언트 nil 체 + 크 ~ SetActivePassword 오류 처리 블록. + 27 + - 유지 앵커: 상단의 Redis 토큰 삭제/성공 응답 부분은 그대로. + 28 +5) **검증** + 29 + - `gofmt -w backend/internal/handler/auth_handler.go` + 30 + - `rg "Descope Client is nil" backend/internal/handler/auth_handler.go` → 없어야 함. + 31 + - `rg "UpdateUserPassword" backend/internal/handler/auth_handler.go` → 공통 경로만 남는지 +확인. + 32 + + 33 +## 테스트 계획 + 34 +- 단위 테스트: IDP Provider를 모킹해 `CompletePasswordReset`를 직접 호출하는 테스트 추가 권장 +(추후 작업). 현재는 수동 검증 예정. + 35 +- 수동 확인: 빌드(go test ./... 불가하면 최소 gofmt 및 `go test ./internal/service -run +TestUpdateUserPassw + ord` 재확인). + 36 + + 37 +## 적용 순서 (패치 실행용) + 38 +1) 정책 블록 if 래핑 + 주석 변경. + 39 +2) Descope API 호출 블록 제거 후 공통 IDP 호출 삽입. + 40 +3) gofmt 및 로그/앵커 검사. diff --git a/docs/descope-federated-poc.md b/docs/descope-federated-poc.md new file mode 100644 index 00000000..4e4f1239 --- /dev/null +++ b/docs/descope-federated-poc.md @@ -0,0 +1,82 @@ +# Descope Federated Apps 연동 PoC (Hydra 1st party 보조) + +목표: Hydra를 1st party OIDC 엔진으로 유지하면서 Descope Federated Apps를 통해 외부 IDP(SAML/OIDC)를 빠르게 붙여 BYOID/소셜을 지원한다. Kratos/DB가 SoT가 되고, Hydra 토큰의 subject는 Kratos identity.id로 맞춘다. + +## 플로우(로그인) +```mermaid +sequenceDiagram + participant RP as RP App + participant HY as Hydra + participant UI as Baron Login UI + participant DS as Descope Federated App + participant IDP as Ext. IDP (SAML/OIDC) + participant KR as Kratos + RP->>HY: /oauth2/auth (login_challenge) + HY->>UI: redirect to login UI + UI-->>UI: 사용자 선택: "Descope Federated App" + UI->>DS: redirect to Descope Federated App (idp=azuread 등) + DS->>IDP: AuthN (SAML/OIDC) + IDP-->>DS: Assertion/Token + DS-->>UI: redirect back with session/authorization data + UI->>KR: upsert identity (traits from DS) + UI->>KR: CreateSession (kratos admin) + UI->>HY: AcceptLogin(subject=kratos identity.id, amr=descope-federated-{idp}) + HY-->>RP: ID/Access Token 발급 (subject=kratos identity.id) +``` + +## 환경 변수/구성 요소 +- Hydra: `HYDRA_ADMIN_URL`, `HYDRA_PUBLIC_URL`, 등록된 RP 클라이언트(redirect URI 포함). +- Kratos: `KRATOS_ADMIN_URL`, `KRATOS_PUBLIC_URL`, identity schema에 federated trait 필드 포함. +- Descope: `DESCOPE_PROJECT_ID`, `DESCOPE_MANAGEMENT_KEY`, `DESCOPE_FEDERATED_APP_ID`, (옵션) `DESCOPE_TENANT`, `DESCOPE_BASE_URL`(self-host 시). +- Baron UI/백엔드: Federated 버튼 노출 플래그, callback URL (`/auth/federated/descope/callback` 등), Hydra login_challenge 전달 경로. + +## 데이터 매핑/저장 +- Kratos identity traits 예: `email`, `email_verified`, `name`, `phone_number`, `department`, `grade`, `federated_identities` 배열. +- federated_identities 제안 스키마 + - `provider` (예: `descope-federated-azuread`) + - `idp_sub` + - `idp_email` + - `idp_email_verified` + - `raw_claims` (JSON, 최소 보관) + - `last_login_at` +- 최초 로그인: email match로 기존 identity 찾기 → 없으면 생성 → federated slot 추가. +- 재로그인: provider+sub 매칭 후 Kratos identity.id 사용. + +## 구현 단계 (PoC) +1) **Hydra 클라이언트 준비** + - RP별 redirect URI 등록, `skip_consent` 옵션은 false 권장(동의 화면 제공 시). + - `ory-net` 네트워크에서 Hydra Admin 접근 허용. +2) **Descope Federated App 설정** + - Federated App 생성 후 외부 IDP(SAML/OIDC) 연결. + - Callback을 Baron Login UI로 설정(`/auth/federated/descope/callback?login_challenge=...`). + - 전달 클레임: `sub`, `email`, `email_verified`, `name`, `groups`(option), `phone_number`. +3) **Login UI/Backend 연결** + - 옵션 버튼 “Sign in with (via Descope)” 추가. + - Start 엔드포인트: Hydra login_challenge를 받고 Descope Federated App redirect URL 생성 후 302. + - Callback 핸들러: + - Descope session/token 검증 (관리 키 또는 JWKS). + - Kratos identity upsert (traits + federated slot). + - Kratos `CreateSession` 호출 → 세션 쿠키 설정. + - Hydra `AcceptLogin` 호출(subject=kratos identity.id, amr=`federated:descope-{idp}`) → Hydra redirect. +4) **로그/감사** + - Hydra login_challenge, provider, sub, email, amr 기록. + - 실패 시 Hydra `RejectLogin`로 일관된 에러 제공. + +## 보안/운영 체크 +- `email_verified` 필수 검증, 미확인 이메일은 거절 또는 별도 플로우. +- 토큰/세션 검증 시 JWKS 캐시 및 만료 확인. +- Rate limit: Federated callback, Hydra login_challenge 재사용 방지. +- PII 최소 저장: raw_claims는 단기 TTL 또는 축약 저장. +- 장애 시 폴백: `IDP_PROVIDER=ory,descope` 설정으로 Descope 기본 로컬 로그인 경로 유지. + +## 테스트 시나리오 (PoC) +- 성공: Federated 버튼 → 외부 IDP 로그인 → Hydra 토큰 발급, subject=kratos identity.id 확인. +- 이메일 검증 실패: email_verified=false인 경우 거절 메시지. +- 재로그인: 기존 federated_identities 매칭 후 동일 subject 유지. +- 오류: 잘못된 login_challenge, 만료된 Descope 토큰, Hydra RejectLogin 동작 확인. + +## 후속 구현(코드) +- Ory IDP 어댑터에 Kratos Admin/Hydra Admin 연동 구현 (`InitiatePasswordReset`, `VerifyPasswordResetToken`, `UpdateUserPassword` 포함). +- AuthHandler에서 Descope 종속 로직을 IDP 추상화 기반으로 재구성(비밀번호 재설정/가입/로그인 모두). +- Login UI에 Federated 버튼 및 상태 처리 추가. +- CI에서 ory-stack 기동 + federated mock IDP로 통합 테스트 추가. diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 03d619cf..00000000 --- a/frontend/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# frontend - -A new Flutter project. - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference.