From 98ba4c5b307475379d8fb597eef639a036f598b8 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Fri, 23 Jan 2026 16:27:30 +0900 Subject: [PATCH] =?UTF-8?q?multi=20IDP=20=EB=AA=A8=EB=8D=B8=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20scaffolding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 6 +- AGENTS.md | 22 +++++ backend/cmd/server/main.go | 23 +++++ backend/internal/domain/idp_models.go | 28 ++++++ backend/internal/service/descope_service.go | 62 +++++++++++++ .../internal/validator/schema_validator.go | 62 +++++++++++++ .../validator/schema_validator_test.go | 78 ++++++++++++++++ compose.infra.yaml | 6 ++ docker/init-metadata/01_init_metadata.sql | 42 +++++++++ frontend/.env.sample | 34 +++++++ page_content.txt | 93 +++++++++++++++++++ 11 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md create mode 100644 backend/internal/domain/idp_models.go create mode 100644 backend/internal/service/descope_service.go create mode 100644 backend/internal/validator/schema_validator.go create mode 100644 backend/internal/validator/schema_validator_test.go create mode 100644 docker/init-metadata/01_init_metadata.sql create mode 100644 frontend/.env.sample create mode 100644 page_content.txt diff --git a/.env.sample b/.env.sample index 3f12797e..3a9baa7d 100644 --- a/.env.sample +++ b/.env.sample @@ -3,7 +3,7 @@ # ========================================== # --- General System --- -APP_ENV=dev # 애플리케이션 실행 환경 (deve, production) +APP_ENV=dev # 애플리케이션 실행 환경 (dev, stage, production) TZ=Asia/Seoul # --- Infrastructure Ports --- @@ -43,5 +43,5 @@ AWS_SES_SENDER=no-reply@baron.co.kr ADMIN_PASSWORD=admin # --- URLs for Proxy/Handoff --- -FRONTEND_URL=https://ssologin.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용) -BACKEND_URL=https://ssologin.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소 \ No newline at end of file +FRONTEND_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용) +BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..f621b3cb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# AGENTS 가이드 (Baron SSO) + +## 목적 +- Inbound Auth/Launcher와 관리(Admin) 기능을 하나의 백엔드에서 운영하되, 네임스페이스·도메인·권한으로 강하게 분리한다. +- 사용자 플로우(가입/로그인)와 관리 플로우(Descope Management Key 사용)를 명확히 구분해 보안 사고면을 축소한다. + +## 현재 원칙 +- **경계 분리**: `/admin/*` + admin 서브도메인에서만 관리 기능 노출. 일반 사용자 번들과 관리자 번들(또는 라우트)을 분리. +- **관리 키 취급**: Descope Management Key는 서버 내부에서만 사용, 비동기 잡/관리 API에서 래핑. 모든 관리 액션을 감사 로그/알람/레이트리밋으로 보호. +- **권한/가드**: role/permission 기반 접근 제어. 관리자 세션 TTL은 짧게, step-up MFA 고려. + +## 인증 플로우 핵심 +- **최초 회원가입**: SMS 인증(Enchanted Link/OTP) 필수 → 인증 성공 후 계정 생성 및 초기 세션 발급. +- **재로그인 분기 (앱 세션 보유 + 사용자 선택)**: + - 앱 로그인 상태 + 사용자가 “앱 승인” 선택: 앱을 MFA/IDPW 대체 수단으로 사용(푸시/딥링크 승인) → 승인 시 웹 세션 발급. + - 앱 세션이 없거나, 사용자가 이번 로그인에서 앱을 사용하지 않기로 선택: SMS 또는 이메일/비밀번호 경로로 진행. +- **세션 TTL**: 앱 기반 세션 유지시간을 `APP_SESSION_TTL_MINUTES` 환경 변수로 관리(기본 예: 30분). + +## 작업 시 체크리스트 +- 관리 기능 개발 시 admin 네임스페이스, 권한 체크, 감사 로깅, 레이트리밋을 기본 포함. +- 인증/로그인 변경 시 “폴백은 사용자 선택일 때만” 규칙을 준수하고, UI에도 선택 흐름을 노출. +- 새 설정/비밀값은 .env.sample에 반영하고 서버에서만 소비하게 설계한다. diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 00e41c9e..606545a0 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1,10 +1,12 @@ package main import ( + "baron-sso-backend/internal/domain" "baron-sso-backend/internal/handler" "baron-sso-backend/internal/logger" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" + "baron-sso-backend/internal/validator" "fmt" "log/slog" "os" @@ -59,6 +61,27 @@ func main() { "redis_addr", getEnv("REDIS_ADDR", "redis:6379"), ) + // --- Fail-Fast Schema Validation --- + // Initialize the IDP Provider (Descope) + descopeProjectID := getEnv("DESCOPE_PROJECT_ID", "") + descopeManagementKey := getEnv("DESCOPE_MANAGEMENT_KEY", "") + + // We create a provider instance to check schema compatibility. + // This ensures that our BrokerUser model requirements (e.g. custom attributes) + // are supported by the configured IDP. + idpProvider := service.NewDescopeProvider(descopeProjectID, descopeManagementKey) + + if err := validator.ValidateIDPCompatibility(domain.BrokerUser{}, idpProvider); err != nil { + slog.Error("❌ [CRITICAL] Broker Schema Mismatch", + "idp", idpProvider.Name(), + "error", err, + ) + fmt.Printf("\n!!! CRITICAL ERROR: IDP Schema Mismatch !!!\n%v\n\n", err) + os.Exit(1) // Break the build/deployment + } + slog.Info("✅ IDP Schema Validation Passed", "idp", idpProvider.Name()) + // ----------------------------------- + // 2. Initialize DB Connections chHost := getEnv("CLICKHOUSE_HOST", "localhost") chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000")) diff --git a/backend/internal/domain/idp_models.go b/backend/internal/domain/idp_models.go new file mode 100644 index 00000000..5400ce36 --- /dev/null +++ b/backend/internal/domain/idp_models.go @@ -0,0 +1,28 @@ +package domain + +// BrokerUser is the standard user model used within Baron SSO business logic. +// It defines the canonical set of fields that must be supported by any underlying IDP. +type BrokerUser struct { + ID string `json:"id" required:"true"` + Email string `json:"email" required:"true"` + Name string `json:"name"` + PhoneNumber string `json:"phone_number"` + // Attributes stores custom user attributes. + // The "required_keys" tag specifies which keys MUST be present in the IDP's schema support. + Attributes map[string]interface{} `json:"attributes" required_keys:"grade,department"` +} + +// IDPMetadata represents the schema capabilities of an Identity Provider. +type IDPMetadata struct { + // SupportedFields lists the BrokerUser fields (json tag names) that the IDP supports. + // For custom attributes, use the key name directly (e.g., "grade"). + SupportedFields []string +} + +// IdentityProvider is the interface that all IDP adapters must implement. +type IdentityProvider interface { + Name() string + // GetMetadata returns the schema support information for this IDP. + // This is used for startup-time validation. + GetMetadata() (*IDPMetadata, error) +} diff --git a/backend/internal/service/descope_service.go b/backend/internal/service/descope_service.go new file mode 100644 index 00000000..9dd84d58 --- /dev/null +++ b/backend/internal/service/descope_service.go @@ -0,0 +1,62 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "log/slog" + + "github.com/descope/go-sdk/descope/client" +) + +type DescopeProvider struct { + Client *client.DescopeClient + fieldMapping map[string]string // Key: Broker Field Name, Value: Descope Attribute Key +} + +func NewDescopeProvider(projectID, managementKey string) *DescopeProvider { + var descopeClient *client.DescopeClient + var err error + if projectID != "" { + descopeClient, err = client.NewWithConfig(&client.Config{ + ProjectID: projectID, + ManagementKey: managementKey, + }) + if err != nil { + slog.Warn("Failed to initialize Descope Client in Provider", "error", err) + } + } + + // Define the mapping between BrokerUser fields and Descope attributes. + // In a real scenario, this could be loaded from a config file. + // For this implementation, we hardcode the support to demonstrate the validation. + // We map the Broker's required custom attributes to Descope's keys. + mapping := map[string]string{ + "grade": "customAttributes.userRank", // Broker 'grade' maps to Descope 'userRank' + "department": "customAttributes.dept", // Broker 'department' maps to Descope 'dept' + } + + return &DescopeProvider{ + Client: descopeClient, + fieldMapping: mapping, + } +} + +func (d *DescopeProvider) Name() string { + return "Descope" +} + +// GetMetadata returns the schema support information. +// Currently, it returns the standard fields Descope supports + the mapped custom attributes. +func (d *DescopeProvider) GetMetadata() (*domain.IDPMetadata, error) { + // 1. Standard Fields supported by Descope + supported := []string{"id", "email", "name", "phone_number"} + + // 2. Add mapped custom attributes + // The Validator checks if the Broker's required keys (e.g., "grade") are present in this list. + for brokerKey := range d.fieldMapping { + supported = append(supported, brokerKey) + } + + return &domain.IDPMetadata{ + SupportedFields: supported, + }, nil +} diff --git a/backend/internal/validator/schema_validator.go b/backend/internal/validator/schema_validator.go new file mode 100644 index 00000000..24e14e0b --- /dev/null +++ b/backend/internal/validator/schema_validator.go @@ -0,0 +1,62 @@ +package validator + +import ( + "baron-sso-backend/internal/domain" + "fmt" + "reflect" + "strings" +) + +// ValidateIDPCompatibility checks if the provided IDP supports all required fields defined in the BrokerUser model. +func ValidateIDPCompatibility(brokerModel interface{}, idp domain.IdentityProvider) error { + metadata, err := idp.GetMetadata() + if err != nil { + return fmt.Errorf("failed to fetch metadata from IDP %s: %w", idp.Name(), err) + } + + supportedMap := make(map[string]bool) + for _, f := range metadata.SupportedFields { + supportedMap[f] = true + } + + t := reflect.TypeOf(brokerModel) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + // Check "required" tag + isRequired := field.Tag.Get("required") == "true" + jsonTag := field.Tag.Get("json") + fieldName := strings.Split(jsonTag, ",")[0] + + // Skip if fieldName is empty or if it's the Attributes map (handled separately) + if fieldName == "" { + continue + } + + if fieldName != "attributes" { + if isRequired && !supportedMap[fieldName] { + return fmt.Errorf("IDP %s does not support required field: %s", idp.Name(), fieldName) + } + } + + // Check "required_keys" tag for map types (Custom Attributes) + if fieldName == "attributes" { + reqKeys := field.Tag.Get("required_keys") + if reqKeys != "" { + keys := strings.Split(reqKeys, ",") + for _, key := range keys { + key = strings.TrimSpace(key) + if !supportedMap[key] { + return fmt.Errorf("IDP %s does not support required custom attribute: %s", idp.Name(), key) + } + } + } + } + } + + return nil +} diff --git a/backend/internal/validator/schema_validator_test.go b/backend/internal/validator/schema_validator_test.go new file mode 100644 index 00000000..a79a151d --- /dev/null +++ b/backend/internal/validator/schema_validator_test.go @@ -0,0 +1,78 @@ +package validator + +import ( + "baron-sso-backend/internal/domain" + "testing" +) + +// MockProvider는 IdentityProvider 인터페이스를 구현하는 테스트용 구조체입니다. +type MockProvider struct { + Supported []string +} + +func (m *MockProvider) Name() string { + return "MockIDP" +} + +func (m *MockProvider) GetMetadata() (*domain.IDPMetadata, error) { + return &domain.IDPMetadata{ + SupportedFields: m.Supported, + }, nil +} + +func TestValidateIDPCompatibility(t *testing.T) { + // BrokerUser 모델은 다음과 같이 정의되어 있다고 가정합니다 (idp_models.go 참조): + // ID (required), Email (required), Name, PhoneNumber + // Attributes (required_keys: "grade", "department") + + tests := []struct { + name string + supported []string + wantErr bool + }{ + { + name: "성공: 모든 필수 필드 지원", + supported: []string{"id", "email", "name", "grade", "department"}, + wantErr: false, + }, + { + name: "성공: 선택 필드(name) 누락이어도 성공", + supported: []string{"id", "email", "grade", "department"}, + wantErr: false, + }, + { + name: "실패: 필수 필드(id) 누락", + supported: []string{"email", "grade", "department"}, + wantErr: true, + }, + { + name: "실패: 필수 필드(email) 누락", + supported: []string{"id", "grade", "department"}, + wantErr: true, + }, + { + name: "실패: 커스텀 속성(grade) 누락", + supported: []string{"id", "email", "department"}, + wantErr: true, + }, + { + name: "실패: 커스텀 속성(department) 누락", + supported: []string{"id", "email", "grade"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 테스트용 IDP Provider 생성 + mockIDP := &MockProvider{Supported: tt.supported} + + // 검증 수행 + err := ValidateIDPCompatibility(domain.BrokerUser{}, mockIDP) + + if (err != nil) != tt.wantErr { + t.Errorf("ValidateIDPCompatibility() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/compose.infra.yaml b/compose.infra.yaml index 47ab5d22..e2c6b184 100644 --- a/compose.infra.yaml +++ b/compose.infra.yaml @@ -12,8 +12,14 @@ services: - "${DB_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data + - ./docker/init-metadata:/docker-entrypoint-initdb.d networks: - baron_net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-baron} -d ${DB_NAME:-baron_sso}"] + interval: 5s + timeout: 5s + retries: 5 restart: always clickhouse: diff --git a/docker/init-metadata/01_init_metadata.sql b/docker/init-metadata/01_init_metadata.sql new file mode 100644 index 00000000..73786161 --- /dev/null +++ b/docker/init-metadata/01_init_metadata.sql @@ -0,0 +1,42 @@ +-- Metadata DB Initialization for Baron SSO +-- Purpose: Manage Relying Parties (RP) and User Consent + +-- 1. Relying Parties (RP) Table +CREATE TABLE IF NOT EXISTS relying_parties ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id VARCHAR(255) NOT NULL UNIQUE, + client_secret VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + redirect_uris TEXT[] NOT NULL, + description TEXT, + logo_url VARCHAR(2048), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- 2. User Consents Table +-- Tracks which scopes/permissions a user has granted to an RP +CREATE TABLE IF NOT EXISTS user_consents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(255) NOT NULL, -- Subject ID from IDP + rp_id UUID NOT NULL REFERENCES relying_parties(id), + scopes TEXT[] NOT NULL, + granted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + revoked_at TIMESTAMP WITH TIME ZONE, + UNIQUE(user_id, rp_id) +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_rp_client_id ON relying_parties(client_id); +CREATE INDEX IF NOT EXISTS idx_consent_user ON user_consents(user_id); + +-- 3. Seed Data (Optional) +-- Initial RP for testing purposes +INSERT INTO relying_parties (client_id, client_secret, name, redirect_uris, description) +VALUES ( + 'baron-admin-client', + 'secret-key-12345', + 'Baron Admin Console', + ARRAY['http://localhost:5000/callback', 'https://sso.hmac.kr/callback'], + 'Official Admin Console for Baron SSO' +) ON CONFLICT (client_id) DO NOTHING; \ No newline at end of file diff --git a/frontend/.env.sample b/frontend/.env.sample new file mode 100644 index 00000000..1a40fcee --- /dev/null +++ b/frontend/.env.sample @@ -0,0 +1,34 @@ +# ========================================== +# Baron SSO - Unified Environment Configuration +# ========================================== + +# --- General System --- +APP_ENV=development +TZ=Asia/Seoul + +# --- Infrastructure Ports --- +DB_PORT=5432 +CLICKHOUSE_PORT_HTTP=8123 +CLICKHOUSE_PORT_NATIVE=9000 +BACKEND_PORT=3000 +FRONTEND_PORT=5000 + +# --- Database Credentials (PostgreSQL) --- +DB_USER=baron +DB_PASSWORD=password +DB_NAME=baron_sso + +# --- Backend Configuration --- +# Must be 32 bytes. Generate with `openssl rand -hex 32` +COOKIE_SECRET=super-secret-key-must-be-32-bytes! +REDIS_ADDR=redis:6379 + +# --- Frontend Configuration --- +# Descope Project ID (Required for Auth) +DESCOPE_PROJECT_ID=P2t...your_descope_project_id + +# --- Naver Cloud Services --- +NAVER_CLOUD_ACCESS_KEY=ncp_iam_... +NAVER_CLOUD_SECRET_KEY=ncp_iam_... +NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:...:... +NAVER_SENDER_PHONE_NUMBER=... diff --git a/page_content.txt b/page_content.txt new file mode 100644 index 00000000..78fa9309 --- /dev/null +++ b/page_content.txt @@ -0,0 +1,93 @@ +# 멀티 IDP 아키텍처 및 마이그레이션 전략 + +본 문서는 Primary IDP 변경(예: Descope → Hydra/Authentik) 시 시스템의 유연성을 보장하기 위한 아키텍처 설계 전략을 기술합니다. + +## 핵심 전략 + +1. **Broker 패턴 고도화**: 백엔드가 모든 인증 요청을 중계하며, 클라이언트는 특정 IDP에 종속되지 않습니다. +2. **추상화 계층 (Abstraction Layer)**: 비즈니스 로직은 `IdentityProvider` 인터페이스에만 의존하며, 구체적인 구현체(Descope, Hydra 등)는 알 필요가 없습니다. +3. **식별자 분리 (Identifier Decoupling)**: 외부 IDP의 `sub` 값과 내부 시스템의 `user_id`를 분리하여 매핑합니다. + +## 상세 설계 + +### 1. IdentityProvider 인터페이스 확장 +단순 메타데이터 조회를 넘어, 인증의 핵심 라이프사이클을 모두 포함하도록 인터페이스를 확장해야 합니다. + +```go +type IdentityProvider interface { + // 메타데이터 및 스키마 검증 + Name() string + GetMetadata() (*IDPMetadata, error) + + // 핵심 인증/인가 기능 + SignUp(ctx context.Context, user *BrokerUser, password string) (*AuthResult, error) + Login(ctx context.Context, loginID, password string) (*AuthResult, error) + VerifyToken(ctx context.Context, token string) (*TokenInfo, error) + + // 사용자 관리 + GetUser(ctx context.Context, id string) (*BrokerUser, error) + UpdateUser(ctx context.Context, user *BrokerUser) error + DeleteUser(ctx context.Context, id string) error +} +``` + +### 2. Provider Factory 패턴 +설정(`config.yaml` 또는 환경변수)에 따라 사용할 IDP 구현체를 런타임에 결정합니다. + +```go +func NewIdentityProvider(config Config) (domain.IdentityProvider, error) { + switch config.IDPType { + case "descope": + return service.NewDescopeProvider(config.Descope) + case "hydra": + return service.NewHydraProvider(config.Hydra) + case "authentik": + return service.NewAuthentikProvider(config.Authentik) + default: + return nil, fmt.Errorf("unsupported IDP type: %s", config.IDPType) + } +} +``` + +### 3. 식별자 매핑 (ID Mapping) +IDP 변경 시 가장 큰 문제는 사용자 고유 ID(`sub`)가 바뀌는 것입니다. 이를 해결하기 위해 Baron SSO 내부 전용 UUID를 사용하고 매핑 테이블을 둡니다. + +* **User Table (Baron Internal)**: `id` (UUID), `email`, ... +* **IDP Link Table**: `baron_user_id`, `idp_provider` (e.g., descope), `idp_subject_id` (e.g., U12345) + +## 구현 예시 (Descope) + +현재 단계에서 즉시 사용 가능한 `DescopeProvider`의 구현 예시입니다. + +```go +// DescopeProvider는 IdentityProvider 인터페이스를 구현합니다. +type DescopeProvider struct { + Client *client.DescopeClient +} + +func (d *DescopeProvider) SignUp(ctx context.Context, user *domain.BrokerUser, password string) (*domain.AuthResult, error) { + // BrokerUser를 Descope User 객체로 변환 + descopeUser := &descope.User{ + Name: user.Name, + Email: user.Email, + Phone: user.PhoneNumber, + CustomAttributes: user.Attributes, + } + + // SDK 호출 + authInfo, err := d.Client.Auth.Password().SignUp(ctx, user.Email, descopeUser, password) + if err != nil { + return nil, err\n } + + // 결과 반환 + return &domain.AuthResult{ + UserID: authInfo.User.UserID, + Token: authInfo.SessionToken.JWT, + }, nil +} +``` + +## 향후 지원 후보 (Candidates) + +* **Ory Hydra**: 자체적인 OAuth2/OIDC 서버 구축이 필요할 때 적합. 높은 커스터마이징 가능. +* **Authentik**: 오픈소스 IDP로, 설치형(Self-hosted) 환경에서 강력한 기능을 제공.