forked from baron/baron-sso
ory-hosting 기본구동
This commit is contained in:
51
.env.sample
51
.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
|
||||
|
||||
67
Makefile
Normal file
67
Makefile
Normal file
@@ -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
|
||||
29
README.md
29
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 없이 코드를 수정하며 개발하려면:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
81
backend/internal/bootstrap/bootstrap.go
Normal file
81
backend/internal/bootstrap/bootstrap.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
33
backend/internal/domain/user.go
Normal file
33
backend/internal/domain/user.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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...))
|
||||
}
|
||||
|
||||
115
backend/internal/idp/factory_test.go
Normal file
115
backend/internal/idp/factory_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
331
backend/internal/service/ory_service.go
Normal file
331
backend/internal/service/ory_service.go
Normal file
@@ -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
|
||||
}
|
||||
149
backend/internal/service/ory_service_test.go
Normal file
149
backend/internal/service/ory_service_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
BIN
backend/server
Executable file
BIN
backend/server
Executable file
Binary file not shown.
200
compose.ory.yaml
Normal file
200
compose.ory.yaml
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
41
docker/ory/hydra/hydra.yml
Normal file
41
docker/ory/hydra/hydra.yml
Normal file
@@ -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
|
||||
24
docker/ory/init-db/01_create_dbs.sh
Executable file
24
docker/ory/init-db/01_create_dbs.sh
Executable file
@@ -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"
|
||||
13
docker/ory/keto/keto.yml
Normal file
13
docker/ory/keto/keto.yml
Normal file
@@ -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
|
||||
7
docker/ory/keto/namespaces.yml
Normal file
7
docker/ory/keto/namespaces.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
namespaces:
|
||||
- id: 0
|
||||
name: default
|
||||
- id: 1
|
||||
name: roles
|
||||
- id: 2
|
||||
name: permissions
|
||||
49
docker/ory/kratos/identity.schema.json
Normal file
49
docker/ory/kratos/identity.schema.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
76
docker/ory/kratos/kratos.yml
Normal file
76
docker/ory/kratos/kratos.yml
Normal file
@@ -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
|
||||
41
docker/ory/oathkeeper/oathkeeper.yml
Normal file
41
docker/ory/oathkeeper/oathkeeper.yml
Normal file
@@ -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
|
||||
1
docker/ory/oathkeeper/rules.json
Normal file
1
docker/ory/oathkeeper/rules.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
48
docs/complete-password-reset-refactor.md
Normal file
48
docs/complete-password-reset-refactor.md
Normal file
@@ -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 및 로그/앵커 검사.
|
||||
82
docs/descope-federated-poc.md
Normal file
82
docs/descope-federated-poc.md
Normal file
@@ -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 <IDP> (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로 통합 테스트 추가.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user