forked from baron/baron-sso
ory-hosting 기본구동
This commit is contained in:
51
.env.sample
51
.env.sample
@@ -3,7 +3,7 @@
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
# --- General System ---
|
# --- General System ---
|
||||||
APP_ENV=dev # 애플리케이션 실행 환경 (dev, stage, production)
|
APP_ENV=stage # 애플리케이션 실행 환경 (dev, stage, production)
|
||||||
TZ=Asia/Seoul
|
TZ=Asia/Seoul
|
||||||
|
|
||||||
# --- Infrastructure Ports ---
|
# --- Infrastructure Ports ---
|
||||||
@@ -21,6 +21,7 @@ DB_NAME=baron_sso
|
|||||||
# --- Backend Configuration ---
|
# --- Backend Configuration ---
|
||||||
# Must be 32 bytes. Generate with `openssl rand -hex 32`
|
# Must be 32 bytes. Generate with `openssl rand -hex 32`
|
||||||
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
|
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 포트(컨테이너 내부 기준)
|
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
|
||||||
|
|
||||||
# Descope Project ID (Required for Auth)
|
# Descope Project ID (Required for Auth)
|
||||||
@@ -42,10 +43,54 @@ AWS_SECRET_ACCESS_KEY=...
|
|||||||
AWS_SES_SENDER=no-reply@baron.co.kr
|
AWS_SES_SENDER=no-reply@baron.co.kr
|
||||||
|
|
||||||
# --- 관리자 page pw ---
|
# --- 관리자 page pw ---
|
||||||
ADMIN_PASSWORD=admin
|
ADMIN_EMAIL=admin@baron.co.kr
|
||||||
|
ADMIN_PASSWORD=adminPasswordIsNotSimple
|
||||||
|
|
||||||
# --- URLs for Proxy/Handoff ---
|
# --- URLs for Proxy/Handoff ---
|
||||||
FRONTEND_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용)
|
FRONTEND_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용)
|
||||||
BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소
|
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
|
```env
|
||||||
DESCOPE_PROJECT_ID=P2t...
|
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)
|
### 전체 스택 실행 (Running the Stack)
|
||||||
|
|
||||||
#### 1. 인프라 실행 (데이터베이스)
|
#### 1. 네트워크 생성 (최초 1회)
|
||||||
데이터 레이어를 먼저 실행합니다.
|
Ory Stack과 애플리케이션 간 통신을 위한 도커 네트워크를 생성합니다.
|
||||||
```bash
|
```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 서비스를 실행합니다.
|
Frontend와 Backend 서비스를 실행합니다.
|
||||||
```bash
|
```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 접속
|
- **Frontend**: http://localhost:5000 접속
|
||||||
- **Backend**: http://localhost:3000 (API)
|
- **Backend**: http://localhost:3000 (API)
|
||||||
- **ClickHouse**: http://localhost:8123
|
- **ClickHouse**: http://localhost:8123
|
||||||
|
- **Kratos Public**: http://localhost:4433
|
||||||
|
- **Hydra Public**: http://localhost:4444
|
||||||
|
- **Kratos UI**: http://localhost:4455
|
||||||
|
|
||||||
### 로컬 개발 (Manual)
|
### 로컬 개발 (Manual)
|
||||||
Docker 없이 코드를 수정하며 개발하려면:
|
Docker 없이 코드를 수정하며 개발하려면:
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
|
|||||||
```env
|
```env
|
||||||
DESCOPE_PROJECT_ID=P2t...
|
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
|
### Running the Stack
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/validator"
|
"baron-sso-backend/internal/validator"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -22,6 +23,11 @@ import (
|
|||||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||||
"github.com/gofiber/fiber/v2/middleware/requestid"
|
"github.com/gofiber/fiber/v2/middleware/requestid"
|
||||||
"github.com/joho/godotenv"
|
"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 {
|
func getEnv(key, fallback string) string {
|
||||||
@@ -93,6 +99,7 @@ func main() {
|
|||||||
// -----------------------------------
|
// -----------------------------------
|
||||||
|
|
||||||
// 2. Initialize DB Connections
|
// 2. Initialize DB Connections
|
||||||
|
// ClickHouse
|
||||||
chHost := getEnv("CLICKHOUSE_HOST", "localhost")
|
chHost := getEnv("CLICKHOUSE_HOST", "localhost")
|
||||||
chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000"))
|
chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000"))
|
||||||
chUser := getEnv("CLICKHOUSE_USER", "default")
|
chUser := getEnv("CLICKHOUSE_USER", "default")
|
||||||
@@ -104,6 +111,46 @@ func main() {
|
|||||||
slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err)
|
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()
|
redisService, err := service.NewRedisService()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err)
|
slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err)
|
||||||
@@ -111,7 +158,7 @@ func main() {
|
|||||||
|
|
||||||
// 2. Initialize Handlers
|
// 2. Initialize Handlers
|
||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
authHandler := handler.NewAuthHandler(redisService)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider)
|
||||||
adminHandler := handler.NewAdminHandler()
|
adminHandler := handler.NewAdminHandler()
|
||||||
|
|
||||||
// 3. Initialize Fiber
|
// 3. Initialize Fiber
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ require (
|
|||||||
github.com/go-redis/redis/v8 v8.11.5
|
github.com/go-redis/redis/v8 v8.11.5
|
||||||
github.com/gofiber/fiber/v2 v2.52.10
|
github.com/gofiber/fiber/v2 v2.52.10
|
||||||
github.com/google/uuid v1.6.0
|
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 (
|
require (
|
||||||
@@ -35,8 +39,12 @@ require (
|
|||||||
github.com/go-faster/city v1.0.1 // indirect
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
github.com/go-faster/errors v0.7.1 // indirect
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.4 // indirect
|
github.com/goccy/go-json v0.10.4 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/joho/godotenv v1.5.1 // 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/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||||
github.com/lestrrat-go/httpcc v1.0.1 // 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/paulmach/orb v0.12.0 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||||
github.com/rivo/uniseg v0.2.0 // indirect
|
github.com/rivo/uniseg v0.2.0 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/segmentio/asm v1.2.1 // indirect
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.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 v1.39.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // 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/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/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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
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.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 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
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.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/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.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
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/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 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
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 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
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 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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-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-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.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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
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 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-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 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 {
|
type AuthInfo struct {
|
||||||
SessionToken *Token
|
SessionToken *Token
|
||||||
RefreshToken *Token
|
RefreshToken *Token
|
||||||
|
// Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다.
|
||||||
|
Subject string
|
||||||
}
|
}
|
||||||
|
|
||||||
// IdentityProvider is the interface that all IDP adapters must implement.
|
// 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.
|
// GetMetadata returns the schema support information for this IDP.
|
||||||
// This is used for startup-time validation.
|
// This is used for startup-time validation.
|
||||||
GetMetadata() (*IDPMetadata, error)
|
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
|
InitiatePasswordReset(loginID, redirectUrl string) error
|
||||||
VerifyPasswordResetToken(token string) (*AuthInfo, error)
|
VerifyPasswordResetToken(token string) (*AuthInfo, error)
|
||||||
UpdateUserPassword(loginID, newPassword string, r *http.Request) 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 (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/idp"
|
|
||||||
"baron-sso-backend/internal/logger"
|
"baron-sso-backend/internal/logger"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"context"
|
"context"
|
||||||
@@ -14,7 +13,6 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -70,7 +68,7 @@ func GenerateSecureToken(length int) string {
|
|||||||
return hex.EncodeToString(b)
|
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")
|
projectID := os.Getenv("DESCOPE_PROJECT_ID")
|
||||||
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
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{
|
return &AuthHandler{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
SmsService: service.NewSmsService(),
|
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"})
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Phone not verified"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Create User in Descope
|
if h.IdpProvider == nil {
|
||||||
if h.DescopeClient == nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
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(req.Phone, "-", "")
|
||||||
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
|
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
|
||||||
if strings.HasPrefix(normalizedPhone, "010") {
|
if strings.HasPrefix(normalizedPhone, "010") {
|
||||||
@@ -356,54 +346,42 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|||||||
normalizedPhone = "+" + normalizedPhone
|
normalizedPhone = "+" + normalizedPhone
|
||||||
}
|
}
|
||||||
|
|
||||||
descopeUser := &descope.UserRequest{}
|
// IDP에 전달할 BrokerUser 스키마 구성
|
||||||
descopeUser.Email = req.Email
|
attributes := map[string]interface{}{
|
||||||
descopeUser.Phone = normalizedPhone
|
"department": req.Department,
|
||||||
descopeUser.Name = req.Name
|
|
||||||
descopeUser.CustomAttributes = map[string]any{
|
|
||||||
"affiliationType": req.AffiliationType,
|
"affiliationType": req.AffiliationType,
|
||||||
"companyCode": req.CompanyCode,
|
"companyCode": req.CompanyCode,
|
||||||
"department": req.Department,
|
// grade는 기존 스키마 필수 키이므로 기본값을 설정
|
||||||
"termsAccepted": req.TermsAccepted,
|
"grade": "member",
|
||||||
"createdAt": time.Now().Format(time.RFC3339),
|
}
|
||||||
|
brokerUser := &domain.BrokerUser{
|
||||||
|
Email: req.Email,
|
||||||
|
Name: req.Name,
|
||||||
|
PhoneNumber: normalizedPhone,
|
||||||
|
Attributes: attributes,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user
|
providerID, err := h.IdpProvider.CreateUser(brokerUser, req.Password)
|
||||||
// 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)
|
|
||||||
if err != nil {
|
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"})
|
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
|
// 4. Cleanup Redis
|
||||||
h.RedisService.Delete(emailKey)
|
h.RedisService.Delete(emailKey)
|
||||||
h.RedisService.Delete(phoneKey)
|
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 ---
|
// --- Helpers ---
|
||||||
@@ -750,38 +728,43 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
ale.Log(slog.LevelInfo, "Attempting to login")
|
ale.Log(slog.LevelInfo, "Attempting to login")
|
||||||
|
|
||||||
if h.DescopeClient == nil {
|
if h.IdpProvider == nil {
|
||||||
ale.Status = fiber.StatusInternalServerError
|
ale.Status = fiber.StatusInternalServerError
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.DescopeError = "Descope Client is nil!"
|
ale.DescopeError = "IDP Provider is nil"
|
||||||
ale.Log(slog.LevelError, "Descope Client is nil")
|
ale.Log(slog.LevelError, "IDP Provider is nil")
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign in using Descope
|
authInfo, err := h.IdpProvider.SignIn(loginID, req.Password)
|
||||||
authInfo, err := h.DescopeClient.Auth.Password().SignIn(context.Background(), req.LoginID, req.Password, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ale.Status = fiber.StatusUnauthorized
|
ale.Status = fiber.StatusUnauthorized
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.DescopeError = err.Error()
|
ale.DescopeError = err.Error()
|
||||||
ale.Log(slog.LevelWarn, "Descope sign-in failed")
|
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") {
|
||||||
// [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") {
|
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"})
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
|
||||||
}
|
}
|
||||||
|
|
||||||
ale.Status = fiber.StatusOK
|
ale.Status = fiber.StatusOK
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.SessionJwt = authInfo.SessionToken.JWT
|
ale.SessionJwt = authInfo.SessionToken.JWT
|
||||||
ale.Log(slog.LevelInfo, "Login successful")
|
ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject))
|
||||||
return c.JSON(fiber.Map{
|
|
||||||
|
resp := fiber.Map{
|
||||||
"sessionJwt": authInfo.SessionToken.JWT,
|
"sessionJwt": authInfo.SessionToken.JWT,
|
||||||
"status": "ok",
|
"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를 보냅니다.
|
// InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 Descope를 통해 이메일 또는 SMS를 보냅니다.
|
||||||
@@ -997,6 +980,12 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
|||||||
ale := logger.NewAuditLogEntry(c, "complete")
|
ale := logger.NewAuditLogEntry(c, "complete")
|
||||||
ale.Operation = "UpdateUserPassword"
|
ale.Operation = "UpdateUserPassword"
|
||||||
|
|
||||||
|
providerName := "unknown"
|
||||||
|
if h.IdpProvider != nil {
|
||||||
|
providerName = h.IdpProvider.Name()
|
||||||
|
}
|
||||||
|
isDescopeProvider := strings.Contains(strings.ToLower(providerName), "descope")
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
NewPassword string `json:"newPassword"`
|
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")
|
ale.Log(slog.LevelInfo, "Received new password for reset")
|
||||||
|
|
||||||
// Validate password complexity dynamically based on Descope policy
|
if len(req.NewPassword) < 8 {
|
||||||
policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background())
|
ale.Status = fiber.StatusBadRequest
|
||||||
if err != nil {
|
ale.LatencyMs = time.Since(startTime)
|
||||||
// If policy fetch fails, log warning and proceed (or fallback to basic check)
|
ale.DescopeError = "Password must be at least 8 characters long"
|
||||||
ale.Log(slog.LevelWarn, "Failed to fetch password policy, skipping dynamic validation: "+err.Error())
|
ale.Log(slog.LevelWarn, "Validation failed: password too short (fallback policy)")
|
||||||
} else {
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError})
|
||||||
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"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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를 통해 비밀번호 업데이트
|
ale.Log(slog.LevelInfo, "Attempting to update password via IDP", slog.String("idp", providerName))
|
||||||
if h.DescopeClient == nil {
|
|
||||||
|
if h.IdpProvider == nil {
|
||||||
ale.Status = fiber.StatusInternalServerError
|
ale.Status = fiber.StatusInternalServerError
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.DescopeError = "Descope Client is nil!"
|
ale.DescopeError = "IDP Provider is nil"
|
||||||
ale.Log(slog.LevelError, "Descope Client is nil")
|
ale.Log(slog.LevelError, "IDP Provider is nil")
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
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 {
|
if err := h.IdpProvider.UpdateUserPassword(loginID, req.NewPassword, nil); 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
|
|
||||||
}
|
|
||||||
ale.Status = fiber.StatusInternalServerError
|
ale.Status = fiber.StatusInternalServerError
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.DescopeError = err.Error()
|
ale.DescopeError = err.Error()
|
||||||
|
|||||||
@@ -3,12 +3,26 @@ package idp
|
|||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"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는 환경 변수를 읽거나 대체 값을 반환하는 헬퍼 함수입니다.
|
// getEnv는 환경 변수를 읽거나 대체 값을 반환하는 헬퍼 함수입니다.
|
||||||
func getEnv(key, fallback string) string {
|
func getEnv(key, fallback string) string {
|
||||||
if value, ok := os.LookupEnv(key); ok {
|
if value, ok := os.LookupEnv(key); ok {
|
||||||
@@ -22,42 +36,182 @@ func getEnv(key, fallback string) string {
|
|||||||
func InitializeProvider() (domain.IdentityProvider, error) {
|
func InitializeProvider() (domain.IdentityProvider, error) {
|
||||||
rawProviders := getEnv("IDP_PROVIDER", "descope") // 기본값은 descope입니다.
|
rawProviders := getEnv("IDP_PROVIDER", "descope") // 기본값은 descope입니다.
|
||||||
providers := strings.Split(rawProviders, ",")
|
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 {
|
for _, p := range providers {
|
||||||
providerName := strings.TrimSpace(strings.ToLower(p))
|
providerName := strings.TrimSpace(strings.ToLower(p))
|
||||||
|
if canonical, ok := providerAliases[providerName]; ok {
|
||||||
|
providerName = canonical
|
||||||
|
}
|
||||||
|
|
||||||
switch providerName {
|
switch providerName {
|
||||||
|
case "ory":
|
||||||
|
// Kratos/Hydra 주 공급자
|
||||||
|
oryProvider := service.NewOryProvider()
|
||||||
|
initialized = append(initialized, oryProvider)
|
||||||
|
|
||||||
case "descope":
|
case "descope":
|
||||||
descopeProjectID := getEnv("DESCOPE_PROJECT_ID", "")
|
descopeProjectID := getEnv("DESCOPE_PROJECT_ID", "")
|
||||||
descopeManagementKey := getEnv("DESCOPE_MANAGEMENT_KEY", "")
|
descopeManagementKey := getEnv("DESCOPE_MANAGEMENT_KEY", "")
|
||||||
// 선택된 공급자에 대한 키가 설정되었는지 확인하기 위한 기본 유효성 검사
|
// 선택된 공급자에 대한 키가 설정되었는지 확인하기 위한 기본 유효성 검사
|
||||||
if descopeProjectID == "" || descopeManagementKey == "" {
|
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:
|
default:
|
||||||
// 알 수 없는 공급자는 건너뛰고 다음 후보를 시도
|
// 알 수 없는 공급자는 건너뛰고 다음 후보를 시도
|
||||||
slog.Warn("Skipping unsupported IDP provider entry", "provider", providerName)
|
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"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/descope/go-sdk/descope"
|
"github.com/descope/go-sdk/descope"
|
||||||
@@ -69,6 +70,81 @@ func (d *DescopeProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
|||||||
}, nil
|
}, 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 {
|
func (d *DescopeProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := d.Client.Auth.Password().SendPasswordReset(ctx, loginID, redirectUrl, nil)
|
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
|
}, 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.
|
// Stub implementations to satisfy the IdentityProvider interface for this unit test.
|
||||||
func (m *MockProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
|
func (m *MockProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
|
||||||
return nil
|
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_CLOUD_SERVICE_ID=${NAVER_CLOUD_SERVICE_ID}
|
||||||
- NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER}
|
- NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER}
|
||||||
- FRONTEND_URL=${FRONTEND_URL}
|
- 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
|
- DB_HOST=postgres
|
||||||
- CLICKHOUSE_HOST=clickhouse
|
- CLICKHOUSE_HOST=clickhouse
|
||||||
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
||||||
@@ -29,6 +32,7 @@ services:
|
|||||||
- infra_check
|
- infra_check
|
||||||
networks:
|
networks:
|
||||||
- baron_net
|
- baron_net
|
||||||
|
- ory-net
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
command: ["go", "run", "./cmd/server/main.go"]
|
command: ["go", "run", "./cmd/server/main.go"]
|
||||||
@@ -82,3 +86,6 @@ networks:
|
|||||||
baron_net:
|
baron_net:
|
||||||
external: true
|
external: true
|
||||||
name: baron_network
|
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