diff --git a/.env.sample b/.env.sample index 509bf2e0..b10cb5bf 100644 --- a/.env.sample +++ b/.env.sample @@ -6,6 +6,10 @@ APP_ENV=stage # 애플리케이션 실행 환경 (dev, stage, production) TZ=Asia/Seoul + +# IDP_PROVIDER는 우선순위 순으로 콤마 구분 (예: Kratos/Hydra 우선, Descope 백업) +IDP_PROVIDER=ory + # --- Infrastructure Ports --- DB_PORT=5432 CLICKHOUSE_PORT_HTTP=8123 @@ -25,11 +29,15 @@ DB_NAME=baron_sso COOKIE_SECRET=super-secret-key-must-be-32-bytes! JWT_SECRET=super-secret-key-must-be-32-bytes! REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준) +CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요 + +# Audit System Configuration +AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수 +AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기 # Descope Project ID (Required for Auth) DESCOPE_PROJECT_ID=P2t...your_descope_project_id DESCOPE_MANAGEMENT_KEY=your_descope_management_key_here -DESCOPE_TEST_ACCOUNT=dyddus1210@gmail.com # 테스트 자동화용 계정(loginId). 없으면 생성 후 비밀번호 변경 시나리오 실행 DESCOPE_TEST_ACCOUNT=tester@baroncs.co.kr # --- Naver Cloud Services --- @@ -52,8 +60,6 @@ ADMIN_PASSWORD=adminPasswordIsNotSimple USERFRONT_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용) BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소 -# IDP_PROVIDER는 우선순위 순으로 콤마 구분 (예: Kratos/Hydra 우선, Descope 백업) -IDP_PROVIDER=ory,descope # ory-stack 변수들 diff --git a/Makefile b/Makefile index 3bbfca16..de3e624d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# Makefile for Ory Stack +# Baron SSO용 Docker Compose 헬퍼 # 환경 변수 로드 ifneq (,$(wildcard ./.env)) @@ -6,62 +6,81 @@ ifneq (,$(wildcard ./.env)) export endif -# --- 기본 실행 (All Apps) --- -# DB 상태 체크 후 모든 App 서비스 실행 -up: check-db - @echo "Starting ALL Ory services (Profile: app)..." - docker compose --profile app up -d +# Compose 파일 경로 +COMPOSE_INFRA := compose.infra.yaml +COMPOSE_ORY := compose.ory.yaml +COMPOSE_APP := docker-compose.yaml -# --- 개별 서비스 실행 --- -# Kratos만 실행 -up-kratos: check-db - @echo "Starting Ory Kratos..." - docker compose --profile kratos up -d +# --- 기본 실행 --- +# 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음) +up-all: + @echo "Starting ALL stacks (infra + ory + app)..." + docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d -# Hydra만 실행 -up-hydra: check-db - @echo "Starting Ory Hydra..." - docker compose --profile hydra up -d - -# Keto만 실행 -up-keto: check-db - @echo "Starting Ory Keto..." - docker compose --profile keto up -d - -# --- 인프라 (DB) 실행 --- -# PostgreSQL 실행 +# --- 개별 스택 실행 --- up-infra: - @echo "Starting Infrastructure (PostgreSQL)..." - docker compose --profile infra up -d + @echo "Starting Infra stack (postgres/clickhouse/redis)..." + docker compose -f $(COMPOSE_INFRA) up -d + +up-ory: + @echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..." + docker compose -f $(COMPOSE_ORY) up -d + +up-app: + @echo "Starting App stack (backend/userfront/adminfront/devfront)..." + docker compose -f $(COMPOSE_APP) up -d + +up-backend: + @echo "Starting Backend only..." + docker compose -f $(COMPOSE_APP) up -d backend + +up-dev: up-infra up-ory + @echo "Dev stack is up (infra + ory)." + +up-front-dev: up-infra up-ory up-backend + @echo "Dev stack is up (infra + ory + backend)." # --- 종료 (Down) --- -# 모든 서비스 및 인프라 종료 -down: - @echo "Stopping ALL services (Infra + App)..." - docker compose --profile infra --profile app down +down-all: + @echo "Stopping ALL stacks (infra + ory + app)..." + docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down -# App 서비스만 종료 (DB는 유지) down-app: - @echo "Stopping App services..." - docker compose --profile app down + @echo "Stopping App stack..." + docker compose -f $(COMPOSE_APP) down + +down-backend: + @echo "Stopping Backend only..." + docker compose -f $(COMPOSE_APP) stop backend -# 인프라만 종료 (주의: App 서비스 에러 가능성 있음) down-infra: - @echo "Stopping Infrastructure..." - docker compose --profile infra down + @echo "Stopping Infra stack..." + docker compose -f $(COMPOSE_INFRA) down + +down-ory: + @echo "Stopping Ory stack..." + docker compose -f $(COMPOSE_ORY) down # --- 유틸리티 --- -# DB 상태 확인 로직 -check-db: - @echo "Checking database status..." - @if [ "$$(docker inspect -f '{{.State.Health.Status}}' ory-postgres 2>/dev/null)" != "healthy" ]; then \ - echo "Error: Database is not running or not healthy."; \ +# 인프라 상태 확인 +check-infra: + @echo "Checking infra status..." + @if [ "$$(docker inspect -f '{{.State.Health.Status}}' baron_postgres 2>/dev/null)" != "healthy" ]; then \ + echo "Error: PostgreSQL is not running or not healthy."; \ echo "Please run 'make up-infra' first."; \ exit 1; \ else \ - echo "Database is healthy."; \ + echo "PostgreSQL is healthy."; \ fi -# 로그 확인 -logs: - docker compose -f compose.ory.yaml logs -f +ps: + docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) ps + +logs-infra: + docker compose -f $(COMPOSE_INFRA) logs -f + +logs-ory: + docker compose -f $(COMPOSE_ORY) logs -f + +logs-app: + docker compose -f $(COMPOSE_APP) logs -f diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b52bc94d..4434aad2 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -267,10 +267,13 @@ func main() { }) app.Use(recover.New()) + allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000") + allowCredentials := allowedOrigins != "*" app.Use(cors.New(cors.Config{ - AllowOrigins: "*", // Adjust in production - AllowHeaders: "Origin, Content-Type, Accept, Authorization", - AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS", + AllowOrigins: allowedOrigins, + AllowHeaders: "Origin, Content-Type, Accept, Authorization", + AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS", + AllowCredentials: allowCredentials, })) // Ensure COOKIE_SECRET is exactly 32 bytes for AES-256 @@ -384,12 +387,19 @@ func main() { // API Group api := app.Group("/api/v1") - api.Use(middleware.RequireAudit(middleware.AuditRequiredConfig{ + + workerCount, _ := strconv.Atoi(getEnv("AUDIT_WORKER_COUNT", "5")) + queueSize, _ := strconv.Atoi(getEnv("AUDIT_QUEUE_SIZE", "2000")) + + api.Use(middleware.AuditMiddleware(middleware.AuditConfig{ Repo: auditRepo, ExcludePaths: map[string]struct{}{ "/api/v1/audit": {}, "/api/v1/client-log": {}, }, + BodyDump: true, + WorkerCount: workerCount, + QueueSize: queueSize, })) api.Post("/audit", auditHandler.CreateLog) api.Get("/audit", auditHandler.ListLogs) @@ -399,6 +409,7 @@ func main() { auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) + auth.Post("/login/code/verify", authHandler.VerifyLoginCode) auth.Post("/password/login", authHandler.PasswordLogin) auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) // [Changed] Use Interstitial Page for GET to prevent Scanner consumption @@ -431,6 +442,7 @@ func main() { // Admin Routes admin := api.Group("/admin") admin.Get("/check", adminHandler.CheckAuth) + admin.Get("/stats", adminHandler.GetSystemStats) admin.Get("/tenants", tenantHandler.ListTenants) admin.Post("/tenants", tenantHandler.CreateTenant) admin.Get("/tenants/:id", tenantHandler.GetTenant) @@ -454,6 +466,9 @@ func main() { // Webhook for Descope Generic Email Gateway (Fake Email Strategy) auth.Post("/webhooks/descope-email", authHandler.HandleDescopeEmailRelay) + // Webhook for Kratos courier (HTTP delivery) + auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay) + // Client Logging Route (Standardized & Flattened) api.Post("/client-log", func(c *fiber.Ctx) error { type LogReq struct { diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml index 82710529..e227460d 100644 --- a/backend/docs/openapi.yaml +++ b/backend/docs/openapi.yaml @@ -230,7 +230,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/MessageResponse" + $ref: "#/components/schemas/MagicLinkVerifyResponse" /api/v1/auth/sms: post: @@ -266,7 +266,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/MessageResponse" + $ref: "#/components/schemas/SmsVerifyResponse" /api/v1/auth/qr/init: post: @@ -908,18 +908,28 @@ components: type: boolean nonAlphanumeric: type: boolean + minCharacterTypes: + type: integer EnchantedLinkInitRequest: type: object properties: loginId: type: string + uri: + type: string + method: + type: string EnchantedLinkInitResponse: type: object properties: + linkId: + type: string pendingRef: type: string + maskedEmail: + type: string expiresIn: type: integer @@ -943,22 +953,36 @@ components: token: type: string + MagicLinkVerifyResponse: + type: object + properties: + token: + type: string + message: + type: string + SmsSendRequest: type: object properties: - phone: - type: string - message: + phoneNumber: type: string SmsVerifyRequest: type: object properties: - phone: + phoneNumber: type: string code: type: string + SmsVerifyResponse: + type: object + properties: + token: + type: string + message: + type: string + QrInitResponse: type: object properties: diff --git a/backend/go.mod b/backend/go.mod index b82896d0..fc9026fe 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -14,6 +14,7 @@ require ( github.com/gofiber/fiber/v2 v2.52.10 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.46.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 @@ -34,6 +35,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-faster/city v1.0.1 // indirect @@ -57,10 +59,12 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/paulmach/orb v0.12.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect @@ -71,4 +75,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 0614daa0..7eed1f6a 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -137,6 +137,8 @@ github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/backend/internal/domain/idp_models.go b/backend/internal/domain/idp_models.go index 694640d0..47b4db8e 100644 --- a/backend/internal/domain/idp_models.go +++ b/backend/internal/domain/idp_models.go @@ -1,10 +1,14 @@ package domain import ( + "errors" "net/http" "time" ) +// ErrNotSupported는 IDP가 특정 인증 흐름을 지원하지 않을 때 반환합니다. +var ErrNotSupported = errors.New("idp: not supported") + // BrokerUser is the standard user model used within Baron SSO business logic. // It defines the canonical set of fields that must be supported by any underlying IDP. type BrokerUser struct { @@ -24,6 +28,16 @@ type IDPMetadata struct { SupportedFields []string } +// PasswordPolicy는 비밀번호 정책 정보를 표현합니다. +type PasswordPolicy struct { + MinLength int + Lowercase bool + Uppercase bool + Number bool + NonAlphanumeric bool + MinCharacterTypes int +} + // Token represents a session or refresh token. type Token struct { JWT string @@ -38,6 +52,14 @@ type AuthInfo struct { Subject string } +// LinkLoginInit는 링크 로그인 초기화 결과입니다. +type LinkLoginInit struct { + FlowID string + ExpiresAt time.Time + // Mode는 링크 로그인 완료 후 세션 처리 방식입니다. (예: "cookie") + Mode string +} + // IdentityProvider is the interface that all IDP adapters must implement. type IdentityProvider interface { Name() string @@ -48,6 +70,16 @@ type IdentityProvider interface { CreateUser(user *BrokerUser, password string) (string, error) // SignIn은 로그인 ID/비밀번호로 인증해 세션 정보를 반환합니다. SignIn(loginID, password string) (*AuthInfo, error) + // UserExists는 loginID 기준으로 사용자 존재 여부를 확인합니다. + UserExists(loginID string) (bool, error) + // IssueSession은 비밀번호 없이 세션을 발급해야 하는 흐름에서 사용합니다. + IssueSession(loginID string) (*AuthInfo, error) + // InitiateLinkLogin은 링크 기반 로그인 요청을 IDP에 전달합니다. + InitiateLinkLogin(loginID, returnTo string) (*LinkLoginInit, error) + // VerifyLoginCode는 링크/코드 기반 로그인에서 코드를 제출해 세션을 발급합니다. + VerifyLoginCode(loginID, flowID, code string) (*AuthInfo, error) + // GetPasswordPolicy는 IDP가 제공하는 비밀번호 정책을 반환합니다. + GetPasswordPolicy() (*PasswordPolicy, error) InitiatePasswordReset(loginID, redirectUrl string) error VerifyPasswordResetToken(token string) (*AuthInfo, error) UpdateUserPassword(loginID, newPassword string, r *http.Request) error diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index f283e556..5511b2b4 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -3,6 +3,8 @@ package handler import ( "log/slog" "os" + "runtime" + "time" "github.com/descope/go-sdk/descope/client" "github.com/gofiber/fiber/v2" @@ -39,3 +41,23 @@ func NewAdminHandler() *AdminHandler { func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) } + +// GetSystemStats returns runtime statistics for monitoring +func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + stats := fiber.Map{ + "goroutines": runtime.NumGoroutine(), + "cpus": runtime.NumCPU(), + "memory": fiber.Map{ + "alloc": m.Alloc, + "totalAlign": m.TotalAlloc, + "sys": m.Sys, + "numGC": m.NumGC, + }, + "timestamp": time.Now(), + } + + return c.Status(fiber.StatusOK).JSON(stats) +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index d049552c..745f263b 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4,19 +4,24 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/logger" "baron-sso-backend/internal/service" + "bytes" "context" crand "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" + "io" "log/slog" "math/rand" + "net/http" + "net/url" "os" "regexp" + "strconv" "strings" "time" - "github.com/descope/go-sdk/descope" "github.com/descope/go-sdk/descope/client" "github.com/gofiber/fiber/v2" ) @@ -25,6 +30,8 @@ const ( // Redis Key Prefixes prefixSession = "enchanted_session:" prefixToken = "enchanted_token:" + prefixLoginCode = "login_code_flow:" + prefixPollMeta = "poll_meta:" prefixSignupEmail = "signup:email:" prefixSignupPhone = "signup:phone:" @@ -41,6 +48,8 @@ const ( smsCodeTTL = 3 * time.Minute prefixPwdResetToken = "pwdreset_token:" pwdResetExpiration = 15 * time.Minute + minPollInterval = 2 * time.Second + loginCodeExpiration = 10 * time.Minute ) type AuthHandler struct { @@ -68,6 +77,30 @@ func GenerateSecureToken(length int) string { return hex.EncodeToString(b) } +func GenerateUserCode() string { + const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ" + return fmt.Sprintf("%c%c-%03d", + letters[rand.Intn(len(letters))], + letters[rand.Intn(len(letters))], + rand.Intn(1000), + ) +} + +func checkPollInterval(redis *service.RedisService, key string, interval time.Duration) (bool, int) { + now := time.Now().UnixMilli() + val, err := redis.Get(key) + if err == nil && val != "" { + if last, parseErr := strconv.ParseInt(val, 10, 64); parseErr == nil { + if now-last < interval.Milliseconds() { + _ = redis.Set(key, fmt.Sprintf("%d", now), defaultExpiration) + return true, int(interval.Seconds()) + 1 + } + } + } + _ = redis.Set(key, fmt.Sprintf("%d", now), defaultExpiration) + return false, int(interval.Seconds()) +} + func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider) *AuthHandler { projectID := os.Getenv("DESCOPE_PROJECT_ID") managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY") @@ -96,7 +129,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident // --- Signup Flow Handlers --- -// CheckEmail - Checks if email is available (not registered in Descope) +// CheckEmail - 이메일 사용 가능 여부를 확인합니다. func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error { var req domain.CheckEmailRequest if err := c.BodyParser(&req); err != nil { @@ -108,23 +141,17 @@ func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid email format"}) } - if h.DescopeClient == nil { + if h.IdpProvider == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) } - // Search in Descope - // Note: Descope doesn't have a direct "exists" check, we use Load or Search. - // Since we are checking availability for signup, we want "User not found". - exists, err := h.DescopeClient.Management.User().Load(context.Background(), req.Email) - - // If err is nil and exists is not nil, user exists. - if err == nil && exists != nil { + exists, err := h.IdpProvider.UserExists(req.Email) + if err != nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + } + if exists { return c.JSON(fiber.Map{"available": false, "message": "Email already registered"}) } - - // Check if specific error is "not found" or just assume if Load fails it might be free. - // Typically Descope Load returns error if not found? Let's assume so or check error message. - // Actually, strictly speaking, we should handle specific errors, but for MVP: return c.JSON(fiber.Map{"available": true}) } @@ -297,26 +324,10 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Terms must be accepted"}) } - // Password Validation - if len(req.Password) < 12 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must be at least 12 characters"}) - } - // Check complexity (at least 2 types: lower, upper, digit, special) - types := 0 - if strings.ContainsAny(req.Password, "abcdefghijklmnopqrstuvwxyz") { - types++ - } - if strings.ContainsAny(req.Password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") { - types++ - } - if strings.ContainsAny(req.Password, "0123456789") { - types++ - } - if strings.ContainsAny(req.Password, "!@#$%^&*()_+-=[]{}|;:,.<>?") { - types++ - } - if types < 2 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least 2 types of characters (letters, numbers, symbols)"}) + // 비밀번호 정책 검증 + policy := h.resolvePasswordPolicy() + if err := validatePasswordWithPolicy(policy, req.Password); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } // 2. Verify Auth Status (Redis) @@ -363,6 +374,9 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { providerID, err := h.IdpProvider.CreateUser(brokerUser, req.Password) if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Signup method not supported"}) + } 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"}) @@ -398,6 +412,19 @@ func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string { return parts[1] } +// normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다. +func normalizePhoneForLoginID(phone string) string { + normalized := strings.ReplaceAll(phone, "-", "") + normalized = strings.ReplaceAll(normalized, " ", "") + if strings.HasPrefix(normalized, "010") { + return "+82" + normalized[1:] + } + if strings.HasPrefix(normalized, "82") { + return "+" + normalized + } + return normalized +} + func (h *AuthHandler) getSignupState(key string) (*signupState, error) { val, err := h.RedisService.Get(key) if err != nil || val == "" { @@ -418,23 +445,82 @@ func (h *AuthHandler) saveSignupState(key string, state *signupState, ttl time.D return h.RedisService.Set(key, string(data), ttl) } -// GetPasswordPolicy exposes the current Descope password policy to the userfront for dynamic validation. -func (h *AuthHandler) GetPasswordPolicy(c *fiber.Ctx) error { - if h.DescopeClient == nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope client not configured"}) +// resolvePasswordPolicy는 IDP 정책을 우선 사용하고, 없으면 기본 정책을 반환합니다. +func (h *AuthHandler) resolvePasswordPolicy() *domain.PasswordPolicy { + if h.IdpProvider != nil { + policy, err := h.IdpProvider.GetPasswordPolicy() + if err == nil && policy != nil { + return policy + } + } + return &domain.PasswordPolicy{ + MinLength: 12, + Lowercase: true, + Uppercase: false, + Number: true, + NonAlphanumeric: true, + MinCharacterTypes: 0, + } +} + +// validatePasswordWithPolicy는 정책 기준으로 비밀번호를 검증합니다. +func validatePasswordWithPolicy(policy *domain.PasswordPolicy, password string) error { + if policy == nil { + return nil + } + if policy.MinLength > 0 && len(password) < policy.MinLength { + return fmt.Errorf("비밀번호는 최소 %d자 이상이어야 합니다", policy.MinLength) } - policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background()) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + types := 0 + hasLower := regexp.MustCompile(`[a-z]`).MatchString(password) + hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password) + hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) + hasSymbol := regexp.MustCompile(`[\W_]`).MatchString(password) + if hasLower { + types++ } + if hasUpper { + types++ + } + if hasNumber { + types++ + } + if hasSymbol { + types++ + } + + if policy.MinCharacterTypes > 0 && types < policy.MinCharacterTypes { + return fmt.Errorf("비밀번호는 영문 대/소문자/숫자/특수문자 중 %d가지 이상을 포함해야 합니다", policy.MinCharacterTypes) + } + + if policy.Lowercase && !hasLower { + return fmt.Errorf("비밀번호에 소문자가 포함되어야 합니다") + } + if policy.Uppercase && !hasUpper { + return fmt.Errorf("비밀번호에 대문자가 포함되어야 합니다") + } + if policy.Number && !hasNumber { + return fmt.Errorf("비밀번호에 숫자가 포함되어야 합니다") + } + if policy.NonAlphanumeric && !hasSymbol { + return fmt.Errorf("비밀번호에 특수문자가 포함되어야 합니다") + } + + return nil +} + +// GetPasswordPolicy는 IDP 기준 비밀번호 정책을 제공합니다. +func (h *AuthHandler) GetPasswordPolicy(c *fiber.Ctx) error { + policy := h.resolvePasswordPolicy() return c.JSON(fiber.Map{ - "minLength": policy.MinLength, - "lowercase": policy.Lowercase, - "uppercase": policy.Uppercase, - "number": policy.Number, - "nonAlphanumeric": policy.NonAlphanumeric, + "minLength": policy.MinLength, + "lowercase": policy.Lowercase, + "uppercase": policy.Uppercase, + "number": policy.Number, + "nonAlphanumeric": policy.NonAlphanumeric, + "minCharacterTypes": policy.MinCharacterTypes, }) } @@ -475,12 +561,26 @@ func (h *AuthHandler) VerifySms(c *fiber.Ctx) error { h.RedisService.DeleteVerificationCode(sanitizedPhone) - // Note: In a real scenario, you might want to generate a Descope JWT here too - // using the same logic as VerifyMagicLink, but for now returning a placeholder - // or you can call the Descope logic if needed. - token := "sms-verified-placeholder-token" + if h.IdpProvider == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Authentication service not configured"}) + } - return c.JSON(fiber.Map{"token": token}) + loginID := normalizePhoneForLoginID(req.PhoneNumber) + authInfo, err := h.IdpProvider.IssueSession(loginID) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + } + if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + } + + return c.JSON(fiber.Map{ + "token": authInfo.SessionToken.JWT, + "message": "Login successful", + }) } // InitEnchantedLink - Custom Implementation (Restored) @@ -493,34 +593,57 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { loginID := strings.ReplaceAll(req.LoginID, "-", "") loginID = strings.ReplaceAll(loginID, " ", "") + lookupLoginID := loginID + if !strings.Contains(loginID, "@") { + lookupLoginID = normalizePhoneForLoginID(loginID) + } - // [New] Check if user exists before sending link - if h.DescopeClient != nil { - user, err := h.DescopeClient.Management.User().Load(context.Background(), loginID) - if err != nil || user == nil { - // Try searching by phone if not found by LoginID - searchPhone := loginID - if !strings.Contains(searchPhone, "@") { - if strings.HasPrefix(searchPhone, "010") { - searchPhone = "+82" + searchPhone[1:] - } else if strings.HasPrefix(searchPhone, "82") { - searchPhone = "+" + searchPhone - } - } - searchOptions := &descope.UserSearchOptions{ - Phones: []string{searchPhone}, - Limit: 1, - } - users, _, errSearch := h.DescopeClient.Management.User().SearchAll(context.Background(), searchOptions) - if errSearch != nil || len(users) == 0 { - slog.Warn("[Enchanted] User not found", "loginID", loginID) - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"}) - } - // 검색 결과가 있더라도 loginID는 사용자가 입력한 원래 값을 유지 (발송 수단 결정을 위해) + // 사용자 존재 여부 확인 + if h.IdpProvider == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + } + exists, err := h.IdpProvider.UserExists(lookupLoginID) + if err != nil { + slog.Warn("[Enchanted] IDP user lookup failed", "loginID", loginID, "error", err) + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + } + if !exists { + slog.Warn("[Enchanted] User not found", "loginID", loginID) + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"}) + } + + userfrontURL := os.Getenv("USERFRONT_URL") + if userfrontURL == "" { + userfrontURL = "http://sso.hmac.kr" + } + if req.URI != "" { + userfrontURL = req.URI + } + + if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" { + if init.FlowID != "" { + _ = h.RedisService.Set(prefixLoginCode+lookupLoginID, init.FlowID, loginCodeExpiration) } + expiresIn := 0 + if !init.ExpiresAt.IsZero() { + expiresIn = int(time.Until(init.ExpiresAt).Seconds()) + } + return c.JSON(fiber.Map{ + "linkId": "Sent", + "pendingRef": init.FlowID, + "maskedEmail": loginID, + "mode": init.Mode, + "provider": h.IdpProvider.Name(), + "expiresIn": expiresIn, + "interval": int(minPollInterval.Seconds()), + }) + } else if err != nil && !errors.Is(err, domain.ErrNotSupported) { + slog.Error("[Enchanted] Link login init failed", "provider", h.IdpProvider.Name(), "error", err) + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) } // [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정 + userCode := GenerateUserCode() token := GenerateSecureToken(3) pendingRef := GenerateSecureToken(3) @@ -528,14 +651,10 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { // Store in Redis h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), defaultExpiration) - h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, loginID), defaultExpiration) + h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration) // Generate Link - userfrontURL := os.Getenv("USERFRONT_URL") slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL) - if userfrontURL == "" { - userfrontURL = "http://sso.hmac.kr" - } link := fmt.Sprintf("%s/verify/%s", userfrontURL, token) // Route based on LoginID type @@ -555,9 +674,10 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
로그인 완료하기
+

간편 코드: %s

만약 본인이 요청하지 않았다면 이 메일을 무시하셔도 됩니다.

- `, link) + `, link, userCode) slog.Info("[Enchanted] Sending Email via AWS SES", "loginID", loginID) if err := h.EmailService.SendEmail(loginID, subject, body); err != nil { @@ -566,7 +686,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { } } else { // Send SMS - content := fmt.Sprintf("[Baron SSO] 로그인 링크: %s", link) + content := fmt.Sprintf("[Baron SSO] 로그인 링크: %s | 코드: %s", link, userCode) slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID) if err := h.SmsService.SendSms(loginID, content); err != nil { @@ -579,6 +699,9 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { "linkId": "Sent", "pendingRef": pendingRef, "maskedEmail": loginID, + "expiresIn": int(defaultExpiration.Seconds()), + "interval": int(minPollInterval.Seconds()), + "userCode": userCode, }) } @@ -589,9 +712,17 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } + pollKey := prefixPollMeta + "enchanted:" + req.PendingRef + if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "slow_down", + "interval": interval, + }) + } + val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { - return c.JSON(fiber.Map{"status": statusPending}) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "expired_token"}) } var data map[string]string @@ -605,7 +736,10 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { }) } - return c.JSON(fiber.Map{"status": statusPending}) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "authorization_pending", + "interval": int(minPollInterval.Seconds()), + }) } // VerifyMagicLink - Validate token and login (Restored) @@ -632,60 +766,23 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { slog.Info("[Verify] Token valid", "loginID", loginID, "pendingRef", pendingRef) - // 1. Generate Descope Session Directly (Management SDK) - if h.DescopeClient == nil { - slog.Error("[Verify] Descope Client is nil!") - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"}) + if h.IdpProvider == nil { + slog.Error("[Verify] IDP Provider is nil") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) } - // [Fix] Search for existing user by phone to prevent fragmentation - // Normalize Phone Number for Search (E.164) - searchPhone := loginID - if !strings.Contains(searchPhone, "@") { - // If it looks like a KR mobile number (010...), format to +8210... - if strings.HasPrefix(searchPhone, "010") { - searchPhone = "+82" + searchPhone[1:] - } else if strings.HasPrefix(searchPhone, "82") { - searchPhone = "+" + searchPhone - } - } - - slog.Info("[Verify] Searching for user", "phone", searchPhone) - searchOptions := &descope.UserSearchOptions{ - Phones: []string{searchPhone}, - Limit: 1, - } - - var targetLoginID string - users, _, errSearch := h.DescopeClient.Management.User().SearchAll(context.Background(), searchOptions) - - if errSearch == nil && len(users) > 0 { - if len(users[0].LoginIDs) > 0 { - targetLoginID = users[0].LoginIDs[0] - slog.Info("[Verify] User found", "existingLoginID", targetLoginID) - } else { - // Should not happen for a valid user, but fallback to UserID or searchPhone - slog.Warn("[Verify] User found but no LoginIDs, using UserID") - targetLoginID = users[0].UserID - } - } else { - // [Changed] If not found, do NOT auto-create. Return error. - slog.Warn("[Verify] User not found by phone", "loginID", searchPhone) - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"}) - } - - slog.Info("[Verify] Generating embedded link", "loginID", targetLoginID) - embeddedToken, err := h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0) + authInfo, err := h.IdpProvider.IssueSession(loginID) if err != nil { - slog.Error("[Verify] Descope Error", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate upstream token"}) + if errors.Is(err, domain.ErrNotSupported) { + slog.Warn("[Verify] IDP session issue not supported", "provider", h.IdpProvider.Name()) + return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + } + slog.Error("[Verify] IDP session issue failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) } - - slog.Info("[Verify] Exchanging embedded token for session JWT") - authInfo, err := h.DescopeClient.Auth.MagicLink().Verify(context.Background(), embeddedToken, nil) - if err != nil { - slog.Error("[Verify] Final verification failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify upstream token"}) + if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + slog.Error("[Verify] IDP returned empty session") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) } sessionToken := authInfo.SessionToken.JWT @@ -702,6 +799,60 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { }) } +// VerifyLoginCode - Verify Kratos login code and issue session. +func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { + var req struct { + LoginID string `json:"loginId"` + Code string `json:"code"` + } + if err := c.BodyParser(&req); err != nil { + slog.Error("[LoginCode] Body parse error", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + loginID := strings.TrimSpace(req.LoginID) + loginID = strings.ReplaceAll(loginID, " ", "+") + if loginID == "" || req.Code == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "loginId and code are required"}) + } + + lookupLoginID := loginID + if !strings.Contains(loginID, "@") { + lookupLoginID = normalizePhoneForLoginID(loginID) + } + + if h.IdpProvider == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + } + + flowID, err := h.RedisService.Get(prefixLoginCode + lookupLoginID) + if err != nil || flowID == "" { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"}) + } + + authInfo, err := h.IdpProvider.VerifyLoginCode(lookupLoginID, flowID, req.Code) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + } + slog.Error("[LoginCode] Verify failed", "loginID", loginID, "error", err) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"}) + } + if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + } + + h.RedisService.Delete(prefixLoginCode + lookupLoginID) + + return c.JSON(fiber.Map{ + "token": authInfo.SessionToken.JWT, + "sessionJwt": authInfo.SessionToken.JWT, + "provider": h.IdpProvider.Name(), + "subject": authInfo.Subject, + "message": "Login successful", + }) +} + // PasswordLogin - Authenticate a user with login ID and password. func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { startTime := time.Now() @@ -738,6 +889,9 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { authInfo, err := h.IdpProvider.SignIn(loginID, req.Password) if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + } ale.Status = fiber.StatusUnauthorized ale.LatencyMs = time.Since(startTime) ale.DescopeError = err.Error() @@ -973,7 +1127,7 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error { return c.Redirect(redirectURL) } -// CompletePasswordReset - 제공된 loginID와 새 비밀번호로 Descope에 비밀번호를 업데이트합니다. +// CompletePasswordReset - 제공된 loginID와 새 비밀번호로 IDP 비밀번호를 업데이트합니다. // 리프레시 토큰은 요청 쿠키에 포함되어 있어야 합니다. func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { startTime := time.Now() @@ -984,7 +1138,6 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { if h.IdpProvider != nil { providerName = h.IdpProvider.Name() } - isDescopeProvider := strings.Contains(strings.ToLower(providerName), "descope") var req struct { NewPassword string `json:"newPassword"` @@ -1033,66 +1186,13 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { // 디버깅을 위해 요청된 새 비밀번호를 로그로 출력 ale.Log(slog.LevelInfo, "Received new password for reset") - if len(req.NewPassword) < 8 { + policy := h.resolvePasswordPolicy() + if err := validatePasswordWithPolicy(policy, req.NewPassword); err != nil { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) - ale.DescopeError = "Password must be at least 8 characters long" - ale.Log(slog.LevelWarn, "Validation failed: password too short (fallback policy)") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError}) - } - - 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") + ale.DescopeError = err.Error() + ale.Log(slog.LevelWarn, "Validation failed: "+err.Error()) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } ale.Log(slog.LevelInfo, "Attempting to update password via IDP", slog.String("idp", providerName)) @@ -1125,6 +1225,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { // InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다. func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { pendingRef := GenerateSecureToken(16) + userCode := GenerateUserCode() // QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다. userfrontURL := os.Getenv("USERFRONT_URL") @@ -1142,6 +1243,8 @@ func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { "qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환 "pendingRef": pendingRef, "expiresIn": 300, + "interval": int(minPollInterval.Seconds()), + "userCode": userCode, }) } @@ -1154,9 +1257,17 @@ func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"}) } + pollKey := prefixPollMeta + "qr:" + req.PendingRef + if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "slow_down", + "interval": interval, + }) + } + val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { - return c.JSON(fiber.Map{"status": "expired"}) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "expired_token"}) } var data map[string]string @@ -1170,7 +1281,10 @@ func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error { }) } - return c.JSON(fiber.Map{"status": statusPending}) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "authorization_pending", + "interval": int(minPollInterval.Seconds()), + }) } // ScanQRLogin - Step 3: 모바일 앱에서 QR 스캔 후 승인할 때 호출합니다. @@ -1209,6 +1323,147 @@ func (h *AuthHandler) ProxyToDescope(c *fiber.Ctx, path string, payload interfac return c.Status(501).SendString("Descope Proxy Disabled") } +type kratosCourierRequest struct { + Recipient string `json:"recipient"` + TemplateType string `json:"template_type"` + TemplateData map[string]interface{} `json:"template_data"` + Subject string `json:"subject"` + Body string `json:"body"` +} + +// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다. +func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { + var req kratosCourierRequest + if err := c.BodyParser(&req); err != nil { + slog.Error("[Kratos Courier] Body parsing failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + if req.Recipient == "" { + slog.Warn("[Kratos Courier] Missing recipient") + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing recipient"}) + } + + subject, body := h.buildKratosCourierMessage(&req) + if strings.TrimSpace(body) == "" { + slog.Warn("[Kratos Courier] Empty body", "recipient", req.Recipient, "template", req.TemplateType) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Empty message"}) + } + + if strings.Contains(req.Recipient, "@") { + if h.EmailService == nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) + } + if err := h.EmailService.SendEmail(req.Recipient, subject, body); err != nil { + slog.Error("[Kratos Courier] Email send failed", "to", req.Recipient, "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send email"}) + } + slog.Info("[Kratos Courier] Email sent", "to", req.Recipient, "template", req.TemplateType) + return c.JSON(fiber.Map{"status": "ok"}) + } + + if h.SmsService == nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SMS service not configured"}) + } + phone := sanitizePhoneForSms(req.Recipient) + if err := h.SmsService.SendSms(phone, body); err != nil { + slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) + } + slog.Info("[Kratos Courier] SMS sent", "to", phone, "template", req.TemplateType) + return c.JSON(fiber.Map{"status": "ok"}) +} + +func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (string, string) { + subject := strings.TrimSpace(req.Subject) + body := strings.TrimSpace(req.Body) + if body != "" || subject != "" { + if subject == "" { + subject = "[Baron SSO] 알림" + } + return subject, body + } + + templateType := strings.ToLower(req.TemplateType) + loginCode := extractFirstString(req.TemplateData, "login_code") + verificationCode := extractFirstString(req.TemplateData, "verification_code") + recoveryCode := extractFirstString(req.TemplateData, "recovery_code") + code := firstNonEmpty(loginCode, verificationCode, recoveryCode, extractFirstString(req.TemplateData, "code")) + + label := "알림" + if loginCode != "" || strings.Contains(templateType, "login") { + label = "로그인" + } else if verificationCode != "" || strings.Contains(templateType, "verification") { + label = "인증" + } else if recoveryCode != "" || strings.Contains(templateType, "recovery") { + label = "복구" + } else if strings.Contains(templateType, "code") { + label = "인증" + } + + if subject == "" { + if label == "알림" { + subject = "[Baron SSO] 알림" + } else { + subject = fmt.Sprintf("[Baron SSO] %s 코드", label) + } + } + + if code == "" { + return subject, fmt.Sprintf("[Baron SSO] %s 요청이 도착했습니다", label) + } + + message := fmt.Sprintf("[Baron SSO] %s 코드: %s", label, code) + if label == "로그인" { + baseURL := os.Getenv("USERFRONT_URL") + if baseURL == "" { + baseURL = "http://localhost:5000" + } + baseURL = strings.TrimRight(baseURL, "/") + link := fmt.Sprintf("%s/verify?loginId=%s&code=%s", + baseURL, + url.QueryEscape(req.Recipient), + url.QueryEscape(code), + ) + message = fmt.Sprintf("%s | 링크: %s", message, link) + } + + return subject, message +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func extractFirstString(data map[string]interface{}, keys ...string) string { + if data == nil { + return "" + } + for _, key := range keys { + if val, ok := data[key]; ok { + if str, ok := val.(string); ok && str != "" { + return str + } + } + } + return "" +} + +func sanitizePhoneForSms(phone string) string { + sanitized := strings.TrimSpace(phone) + if strings.HasPrefix(sanitized, "+82") { + sanitized = "0" + sanitized[3:] + } + sanitized = strings.ReplaceAll(sanitized, "-", "") + sanitized = strings.ReplaceAll(sanitized, " ", "") + return sanitized +} + // HandleDescopeSmsRelay func (h *AuthHandler) HandleDescopeSmsRelay(c *fiber.Ctx) error { var req struct { @@ -1305,123 +1560,346 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string { // GetMe - Returns current user's profile with 010 phone format func (h *AuthHandler) GetMe(c *fiber.Ctx) error { token := h.getBearerToken(c) - if token == "" { + if token != "" { + if looksLikeJWT(token) && h.DescopeClient != nil { + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err == nil && authorized { + userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"}) + } + + dept, _ := userResponse.CustomAttributes["department"].(string) + affType, _ := userResponse.CustomAttributes["affiliationType"].(string) + compCode, _ := userResponse.CustomAttributes["companyCode"].(string) + + resp := domain.UserProfileResponse{ + ID: userResponse.UserID, + Email: userResponse.Email, + Name: userResponse.Name, + Phone: h.formatPhoneForDisplay(userResponse.Phone), + Department: dept, + AffiliationType: affType, + CompanyCode: compCode, + } + return c.JSON(resp) + } + } + + profile, err := h.getKratosProfile(token) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + return c.JSON(profile) + } + + cookie := c.Get("Cookie") + if cookie == "" { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"}) } - - authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) - if err != nil || !authorized { + profile, err := h.getKratosProfileWithCookie(cookie) + if err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) } + return c.JSON(profile) +} - userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) +func looksLikeJWT(token string) bool { + return strings.Count(token, ".") == 2 +} + +func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) { + if looksLikeJWT(token) && h.DescopeClient != nil { + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err == nil && authorized { + return userToken.ID, nil + } + } + id, _, err := h.getKratosIdentity(token) + return id, err +} + +func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) { + kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") + if kratosURL == "" { + kratosURL = "http://kratos:4433" + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"}) + return "", nil, err + } + req.Header.Set("X-Session-Token", sessionToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) } - dept, _ := userResponse.CustomAttributes["department"].(string) - affType, _ := userResponse.CustomAttributes["affiliationType"].(string) - compCode, _ := userResponse.CustomAttributes["companyCode"].(string) + var result struct { + Identity struct { + ID string `json:"id"` + Traits map[string]interface{} `json:"traits"` + } `json:"identity"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", nil, err + } - resp := domain.UserProfileResponse{ - ID: userResponse.UserID, - Email: userResponse.Email, - Name: userResponse.Name, - Phone: h.formatPhoneForDisplay(userResponse.Phone), + return result.Identity.ID, result.Identity.Traits, nil +} + +func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) { + kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") + if kratosURL == "" { + kratosURL = "http://kratos:4433" + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) + if err != nil { + return "", nil, err + } + req.Header.Set("Cookie", cookie) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var result struct { + Identity struct { + ID string `json:"id"` + Traits map[string]interface{} `json:"traits"` + } `json:"identity"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", nil, err + } + + return result.Identity.ID, result.Identity.Traits, nil +} + +func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]interface{}) error { + kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/") + if kratosAdminURL == "" { + kratosAdminURL = "http://kratos:4434" + } + + payload := map[string]interface{}{ + "schema_id": "default", + "traits": traits, + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", kratosAdminURL, identityID), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("kratos admin update failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + return nil +} + +func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) { + identityID, traits, err := h.getKratosIdentity(sessionToken) + if err != nil { + return nil, err + } + + email, _ := traits["email"].(string) + name, _ := traits["name"].(string) + phone, _ := traits["phone_number"].(string) + dept, _ := traits["department"].(string) + affType, _ := traits["affiliationType"].(string) + compCode, _ := traits["companyCode"].(string) + + profile := &domain.UserProfileResponse{ + ID: identityID, + Email: email, + Name: name, + Phone: h.formatPhoneForDisplay(phone), Department: dept, AffiliationType: affType, CompanyCode: compCode, } + return profile, nil +} - return c.JSON(resp) +func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) { + identityID, traits, err := h.getKratosIdentityWithCookie(cookie) + if err != nil { + return nil, err + } + + email, _ := traits["email"].(string) + name, _ := traits["name"].(string) + phone, _ := traits["phone_number"].(string) + dept, _ := traits["department"].(string) + affType, _ := traits["affiliationType"].(string) + compCode, _ := traits["companyCode"].(string) + + profile := &domain.UserProfileResponse{ + ID: identityID, + Email: email, + Name: name, + Phone: h.formatPhoneForDisplay(phone), + Department: dept, + AffiliationType: affType, + CompanyCode: compCode, + } + return profile, nil } // UpdateMe - Updates current user's profile with phone verification check func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { token := h.getBearerToken(c) - if token == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"}) - } - - authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) - if err != nil || !authorized { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) - } - var req domain.UpdateUserRequest if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } - // 1. Load current user to check changes - currentUser, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load current user"}) + if token != "" && looksLikeJWT(token) && h.DescopeClient != nil { + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err == nil && authorized { + // 1. Load current user to check changes + currentUser, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load current user"}) + } + + newPhoneStorage := h.formatPhoneForStorage(req.Phone) + oldPhoneStorage := currentUser.Phone + + slog.Info("[UpdateMe] Checking changes", "userID", userToken.ID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name) + + // 2. Handle Phone Number Change + if newPhoneStorage != "" && newPhoneStorage != oldPhoneStorage { + // Check verification status in Redis + verifyKey := "verify_update_phone:" + userToken.ID + ":" + newPhoneStorage + val, _ := h.RedisService.Get(verifyKey) + if val != "verified" { + slog.Warn("[UpdateMe] Phone verification missing", "key", verifyKey) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다."}) + } + + // Update Phone in Descope and mark as verified + slog.Info("[UpdateMe] Updating phone number", "userID", userToken.ID, "newPhone", newPhoneStorage) + _, err = h.DescopeClient.Management.User().UpdatePhone(c.Context(), userToken.ID, newPhoneStorage, true, false) + if err != nil { + slog.Error("Failed to update phone in Descope", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "전화번호 업데이트에 실패했습니다."}) + } + + // If the old phone was used as a LoginID, replace it with the new one + for _, loginID := range currentUser.LoginIDs { + // Normalize for comparison + normID := strings.ReplaceAll(loginID, "+82", "0") + normOld := strings.ReplaceAll(oldPhoneStorage, "+82", "0") + + if loginID == oldPhoneStorage || (normOld != "" && normID == normOld) { + slog.Info("[UpdateMe] Updating LoginID", "old", loginID, "new", newPhoneStorage) + _, err = h.DescopeClient.Management.User().UpdateLoginID(c.Context(), loginID, newPhoneStorage) + if err != nil { + slog.Warn("Failed to update LoginID", "error", err) + } + break + } + } + + // Clear verification after successful update + h.RedisService.Delete(verifyKey) + } + + // 3. Update Name if changed + if req.Name != "" && req.Name != currentUser.Name { + slog.Info("[UpdateMe] Updating display name", "userID", userToken.ID, "newName", req.Name) + _, err = h.DescopeClient.Management.User().UpdateDisplayName(c.Context(), userToken.ID, req.Name) + if err != nil { + slog.Error("Failed to update user name", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "이름 업데이트에 실패했습니다."}) + } + } + + // 4. Update Custom Attributes (Department) + if req.Department != "" { + slog.Info("[UpdateMe] Updating department", "userID", userToken.ID, "dept", req.Department) + if _, err := h.DescopeClient.Management.User().UpdateCustomAttribute(c.Context(), userToken.ID, "department", req.Department); err != nil { + slog.Error("Failed to update department", "error", err) + } + } + + slog.Info("[UpdateMe] Profile update completed successfully", "userID", userToken.ID) + + return c.JSON(fiber.Map{ + "status": "success", + "updatedAt": time.Now().Format(time.RFC3339), + }) + } } + var ( + identityID string + traits map[string]interface{} + err error + ) + if token != "" { + identityID, traits, err = h.getKratosIdentity(token) + } else { + cookie := c.Get("Cookie") + if cookie == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"}) + } + identityID, traits, err = h.getKratosIdentityWithCookie(cookie) + } + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + + currentPhone, _ := traits["phone_number"].(string) newPhoneStorage := h.formatPhoneForStorage(req.Phone) - oldPhoneStorage := currentUser.Phone - slog.Info("[UpdateMe] Checking changes", "userID", userToken.ID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name) + slog.Info("[UpdateMe] Checking changes (Kratos)", "identityID", identityID, "oldPhone", currentPhone, "newPhone", newPhoneStorage, "newName", req.Name) - // 2. Handle Phone Number Change - if newPhoneStorage != "" && newPhoneStorage != oldPhoneStorage { - // Check verification status in Redis - verifyKey := "verify_update_phone:" + userToken.ID + ":" + newPhoneStorage + if newPhoneStorage != "" && newPhoneStorage != currentPhone { + verifyKey := "verify_update_phone:" + identityID + ":" + newPhoneStorage val, _ := h.RedisService.Get(verifyKey) if val != "verified" { - slog.Warn("[UpdateMe] Phone verification missing", "key", verifyKey) + slog.Warn("[UpdateMe] Phone verification missing (Kratos)", "key", verifyKey) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다."}) } - - // Update Phone in Descope and mark as verified - slog.Info("[UpdateMe] Updating phone number", "userID", userToken.ID, "newPhone", newPhoneStorage) - _, err = h.DescopeClient.Management.User().UpdatePhone(c.Context(), userToken.ID, newPhoneStorage, true, false) - if err != nil { - slog.Error("Failed to update phone in Descope", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "전화번호 업데이트에 실패했습니다."}) - } - - // If the old phone was used as a LoginID, replace it with the new one - for _, loginID := range currentUser.LoginIDs { - // Normalize for comparison - normID := strings.ReplaceAll(loginID, "+82", "0") - normOld := strings.ReplaceAll(oldPhoneStorage, "+82", "0") - - if loginID == oldPhoneStorage || (normOld != "" && normID == normOld) { - slog.Info("[UpdateMe] Updating LoginID", "old", loginID, "new", newPhoneStorage) - _, err = h.DescopeClient.Management.User().UpdateLoginID(c.Context(), loginID, newPhoneStorage) - if err != nil { - slog.Warn("Failed to update LoginID", "error", err) - } - break - } - } - - // Clear verification after successful update + traits["phone_number"] = newPhoneStorage h.RedisService.Delete(verifyKey) } - // 3. Update Name if changed - if req.Name != "" && req.Name != currentUser.Name { - slog.Info("[UpdateMe] Updating display name", "userID", userToken.ID, "newName", req.Name) - _, err = h.DescopeClient.Management.User().UpdateDisplayName(c.Context(), userToken.ID, req.Name) - if err != nil { - slog.Error("Failed to update user name", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "이름 업데이트에 실패했습니다."}) - } + if req.Name != "" { + traits["name"] = req.Name } - - // 4. Update Custom Attributes (Department) if req.Department != "" { - slog.Info("[UpdateMe] Updating department", "userID", userToken.ID, "dept", req.Department) - if _, err := h.DescopeClient.Management.User().UpdateCustomAttribute(c.Context(), userToken.ID, "department", req.Department); err != nil { - slog.Error("Failed to update department", "error", err) - } + traits["department"] = req.Department } - slog.Info("[UpdateMe] Profile update completed successfully", "userID", userToken.ID) + if err := h.updateKratosIdentity(identityID, traits); err != nil { + slog.Error("Failed to update profile in Kratos", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "프로필 업데이트에 실패했습니다."}) + } + slog.Info("[UpdateMe] Profile update completed successfully (Kratos)", "identityID", identityID) return c.JSON(fiber.Map{ "status": "success", "updatedAt": time.Now().Format(time.RFC3339), @@ -1431,12 +1909,20 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { // SendUpdateCode - Sends OTP for phone number change func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { token := h.getBearerToken(c) - if token == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + var ( + userID string + err error + ) + if token != "" { + userID, err = h.resolveIdentityID(c, token) + } else { + cookie := c.Get("Cookie") + if cookie == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + userID, _, err = h.getKratosIdentityWithCookie(cookie) } - - authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) - if err != nil || !authorized { + if err != nil || userID == "" { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) } @@ -1451,7 +1937,7 @@ func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { code := fmt.Sprintf("%06d", rand.Intn(1000000)) // Store code in Redis - key := "otp_update_phone:" + userToken.ID + ":" + phone + key := "otp_update_phone:" + userID + ":" + phone h.RedisService.Set(key, code, 5*time.Minute) // Send SMS @@ -1464,8 +1950,20 @@ func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { // VerifyUpdateCode - Verifies OTP for phone number change func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error { token := h.getBearerToken(c) - authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) - if err != nil || !authorized { + var ( + userID string + err error + ) + if token != "" { + userID, err = h.resolveIdentityID(c, token) + } else { + cookie := c.Get("Cookie") + if cookie == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + userID, _, err = h.getKratosIdentityWithCookie(cookie) + } + if err != nil || userID == "" { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) } @@ -1478,7 +1976,7 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error { } phone := h.formatPhoneForStorage(req.Phone) - key := "otp_update_phone:" + userToken.ID + ":" + phone + key := "otp_update_phone:" + userID + ":" + phone storedCode, _ := h.RedisService.Get(key) if storedCode == "" || storedCode != req.Code { @@ -1486,7 +1984,7 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error { } // Mark as verified for 10 minutes - verifyKey := "verify_update_phone:" + userToken.ID + ":" + phone + verifyKey := "verify_update_phone:" + userID + ":" + phone h.RedisService.Set(verifyKey, "verified", 10*time.Minute) h.RedisService.Delete(key) diff --git a/backend/internal/handler/auth_handler_test.go b/backend/internal/handler/auth_handler_test.go index 746b82eb..1a06bcac 100644 --- a/backend/internal/handler/auth_handler_test.go +++ b/backend/internal/handler/auth_handler_test.go @@ -72,7 +72,7 @@ func TestCompletePasswordReset_InvalidPasswordPolicy(t *testing.T) { if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatalf("failed to decode response: %v", err) } - if got["error"] != "Password must be at least 8 characters long" { + if got["error"] != "비밀번호는 최소 12자 이상이어야 합니다" { t.Fatalf("unexpected error message: %v", got["error"]) } } diff --git a/backend/internal/idp/factory.go b/backend/internal/idp/factory.go index b05b812c..221e027a 100644 --- a/backend/internal/idp/factory.go +++ b/backend/internal/idp/factory.go @@ -124,43 +124,144 @@ func (c *chainedProvider) GetMetadata() (*domain.IDPMetadata, error) { } func (c *chainedProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { - var errs []error - for idx, p := range c.providers { + for _, p := range c.providers { id, err := p.CreateUser(user, password) if err != nil { - errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + if errors.Is(err, domain.ErrNotSupported) { + continue + } slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "CreateUser", "error", err) - continue - } - if idx > 0 { - slog.Info("IDP fallback succeeded", "operation", "CreateUser", "provider", p.Name()) + return "", err } return id, nil } - if len(errs) == 0 { - return "", fmt.Errorf("no IDP providers available for CreateUser") - } - return "", fmt.Errorf("all IDP providers failed for CreateUser: %w", errors.Join(errs...)) + return "", domain.ErrNotSupported } func (c *chainedProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { - var errs []error - for idx, p := range c.providers { + for _, p := range c.providers { info, err := p.SignIn(loginID, password) if err != nil { - errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + if errors.Is(err, domain.ErrNotSupported) { + continue + } slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "SignIn", "error", err) + return nil, err + } + return info, nil + } + return nil, domain.ErrNotSupported +} + +func (c *chainedProvider) UserExists(loginID string) (bool, error) { + var errs []error + for _, p := range c.providers { + exists, err := p.UserExists(loginID) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + continue + } + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + continue + } + if exists { + return true, nil + } + } + if len(errs) == 0 { + return false, nil + } + return false, fmt.Errorf("all IDP providers failed for UserExists: %w", errors.Join(errs...)) +} + +func (c *chainedProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { + var errs []error + for idx, p := range c.providers { + info, err := p.IssueSession(loginID) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + continue + } + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "IssueSession", "error", err) continue } if idx > 0 { - slog.Info("IDP fallback succeeded", "operation", "SignIn", "provider", p.Name()) + slog.Info("IDP fallback succeeded", "operation", "IssueSession", "provider", p.Name()) } return info, nil } if len(errs) == 0 { - return nil, fmt.Errorf("no IDP providers available for SignIn") + return nil, domain.ErrNotSupported } - return nil, fmt.Errorf("all IDP providers failed for SignIn: %w", errors.Join(errs...)) + return nil, fmt.Errorf("all IDP providers failed for IssueSession: %w", errors.Join(errs...)) +} + +func (c *chainedProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + var errs []error + for idx, p := range c.providers { + info, err := p.InitiateLinkLogin(loginID, returnTo) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + continue + } + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "InitiateLinkLogin", "error", err) + continue + } + if idx > 0 { + slog.Info("IDP fallback succeeded", "operation", "InitiateLinkLogin", "provider", p.Name()) + } + return info, nil + } + if len(errs) == 0 { + return nil, domain.ErrNotSupported + } + return nil, fmt.Errorf("all IDP providers failed for InitiateLinkLogin: %w", errors.Join(errs...)) +} + +func (c *chainedProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + var errs []error + for idx, p := range c.providers { + info, err := p.VerifyLoginCode(loginID, flowID, code) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + continue + } + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "VerifyLoginCode", "error", err) + continue + } + if idx > 0 { + slog.Info("IDP fallback succeeded", "operation", "VerifyLoginCode", "provider", p.Name()) + } + return info, nil + } + if len(errs) == 0 { + return nil, domain.ErrNotSupported + } + return nil, fmt.Errorf("all IDP providers failed for VerifyLoginCode: %w", errors.Join(errs...)) +} + +func (c *chainedProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + var errs []error + for _, p := range c.providers { + policy, err := p.GetPasswordPolicy() + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + continue + } + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + continue + } + if policy != nil { + return policy, nil + } + } + if len(errs) == 0 { + return nil, domain.ErrNotSupported + } + return nil, fmt.Errorf("all IDP providers failed for GetPasswordPolicy: %w", errors.Join(errs...)) } func (c *chainedProvider) InitiatePasswordReset(loginID, redirectUrl string) error { diff --git a/backend/internal/idp/factory_test.go b/backend/internal/idp/factory_test.go index 923c2083..9778d29d 100644 --- a/backend/internal/idp/factory_test.go +++ b/backend/internal/idp/factory_test.go @@ -10,19 +10,31 @@ import ( ) type stubProvider struct { - name string - metadata []string - createErr error - initiateErr error - verifyErr error - updateErr error - signInErr error - initiateCalls int - verifyCalls int - updateCalls int - signInCalls int - createCalls int - verifyResponse *domain.AuthInfo + name string + metadata []string + createErr error + initiateErr error + verifyErr error + updateErr error + signInErr error + userExistsErr error + issueErr error + linkInitErr error + verifyCodeErr error + policyErr error + initiateCalls int + verifyCalls int + updateCalls int + signInCalls int + createCalls int + userExistsCalls int + issueCalls int + linkInitCalls int + verifyCodeCalls int + policyCalls int + verifyResponse *domain.AuthInfo + userExists bool + policy *domain.PasswordPolicy } func (s *stubProvider) Name() string { return s.name } @@ -47,6 +59,46 @@ func (s *stubProvider) SignIn(loginID, password string) (*domain.AuthInfo, error return &domain.AuthInfo{Subject: "subject-123"}, nil } +func (s *stubProvider) UserExists(loginID string) (bool, error) { + s.userExistsCalls++ + if s.userExistsErr != nil { + return false, s.userExistsErr + } + return s.userExists, nil +} + +func (s *stubProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { + s.issueCalls++ + if s.issueErr != nil { + return nil, s.issueErr + } + return &domain.AuthInfo{Subject: "issue-subject"}, nil +} + +func (s *stubProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + s.linkInitCalls++ + if s.linkInitErr != nil { + return nil, s.linkInitErr + } + return &domain.LinkLoginInit{FlowID: "flow-123", Mode: "cookie"}, nil +} + +func (s *stubProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + s.verifyCodeCalls++ + if s.verifyCodeErr != nil { + return nil, s.verifyCodeErr + } + return &domain.AuthInfo{Subject: "verify-code-subject"}, nil +} + +func (s *stubProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + s.policyCalls++ + if s.policyErr != nil { + return nil, s.policyErr + } + return s.policy, nil +} + func (s *stubProvider) InitiatePasswordReset(loginID, redirectUrl string) error { s.initiateCalls++ return s.initiateErr diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go new file mode 100644 index 00000000..ea26520a --- /dev/null +++ b/backend/internal/middleware/audit_middleware.go @@ -0,0 +1,183 @@ +package middleware + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/utils" + "encoding/json" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +type AuditConfig struct { + Repo domain.AuditRepository + ExcludePaths map[string]struct{} + BodyDump bool + WorkerCount int + QueueSize int +} + +// AuditMiddleware provides comprehensive audit logging for all requests. +// It enforces strict logging for state-changing commands (POST, PUT, DELETE, PATCH) +// and best-effort logging for queries (GET, HEAD, OPTIONS). +func AuditMiddleware(config AuditConfig) fiber.Handler { + // 0. Initialize Worker Pool for Async Logging + if config.WorkerCount <= 0 { + config.WorkerCount = 5 // Default workers + } + if config.QueueSize <= 0 { + config.QueueSize = 1000 // Default queue size + } + + auditQueue := make(chan *domain.AuditLog, config.QueueSize) + var once sync.Once + + // Start workers only once + once.Do(func() { + for i := 0; i < config.WorkerCount; i++ { + go func(workerID int) { + slog.Debug("Audit worker started", "id", workerID) + for log := range auditQueue { + func() { + defer func() { + if r := recover(); r != nil { + slog.Error("Audit worker panic recovery", "reason", r, "req_id", log.EventID) + } + }() + if err := config.Repo.Create(log); err != nil { + slog.Warn("Failed to write async audit log", "error", err, "req_id", log.EventID) + } + }() + } + }(i) + } + }) + + // Default methods classification + writeMethods := map[string]struct{}{ + fiber.MethodPost: {}, + fiber.MethodPut: {}, + fiber.MethodPatch: {}, + fiber.MethodDelete: {}, + } + + if config.ExcludePaths == nil { + config.ExcludePaths = map[string]struct{}{} + } + + return func(c *fiber.Ctx) error { + // 1. Check exclusions + if _, excluded := config.ExcludePaths[c.Path()]; excluded { + return c.Next() + } + + // 2. Setup context variables + start := time.Now() + reqID := c.Get("X-Request-Id") + if reqID == "" { + reqID = uuid.New().String() + c.Set("X-Request-Id", reqID) + } + + // 3. Process Request + err := c.Next() + + // 4. Gather Metrics & Context + latency := time.Since(start) + status := c.Response().StatusCode() + + // If Fiber handler returned an error, status might default to 500 or be in the error + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + status = fiberErr.Code + } else { + status = fiber.StatusInternalServerError + } + } + + statusText := "success" + if status >= fiber.StatusBadRequest { + statusText = "failure" + } + + // 5. Extract User Context (populated by AuthMiddleware/TenantGuard) + userID, _ := c.Locals("user_id").(string) + loginID, _ := c.Locals("login_id").(string) + tenantID, _ := c.Locals("tenant_id").(string) + + // 6. Capture & Mask Body + var maskedBody string + if config.BodyDump { + if c.Method() != fiber.MethodGet && c.Method() != fiber.MethodHead { + bodyBytes := c.Body() + if len(bodyBytes) > 0 { + maskedBytes := utils.MaskSensitiveJSON(bodyBytes) + maskedBody = string(maskedBytes) + } + } + } + + // 7. Construct Details JSON + details := map[string]any{ + "request_id": reqID, + "method": c.Method(), + "path": c.Path(), + "status": status, + "latency_ms": latency.Milliseconds(), + "login_id": loginID, + "tenant_id": tenantID, + "request_body": maskedBody, + } + if err != nil { + details["error"] = err.Error() + } + + detailsJSON, _ := json.Marshal(details) + + // 8. Create Audit Log Object + auditLog := &domain.AuditLog{ + EventID: reqID, + Timestamp: start, + UserID: userID, + EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()), + Status: statusText, + IPAddress: c.IP(), + UserAgent: c.Get("User-Agent"), + Details: string(detailsJSON), + } + + // 9. Store Log (Policy Enforcement) + _, isWrite := writeMethods[c.Method()] + + if config.Repo == nil { + if isWrite { + slog.Error("Audit repository missing for command", "req_id", reqID) + return fiber.NewError(fiber.StatusServiceUnavailable, "Audit system unavailable") + } + return err + } + + if isWrite { + // Strict Mode: Synchronous write + if createErr := config.Repo.Create(auditLog); createErr != nil { + slog.Error("Failed to write audit log (sync)", "error", createErr, "req_id", reqID) + return fiber.NewError(fiber.StatusServiceUnavailable, "Audit logging failed") + } + } else { + // Best Effort: Load Shedding via Buffered Channel + select { + case auditQueue <- auditLog: + // Successfully queued + default: + // Queue full -> DROP (Load Shedding) + slog.Warn("Audit queue full, dropping log (load shedding)", "req_id", reqID, "path", c.Path()) + } + } + + return err + } +} diff --git a/backend/internal/middleware/audit_middleware_test.go b/backend/internal/middleware/audit_middleware_test.go new file mode 100644 index 00000000..4042dd72 --- /dev/null +++ b/backend/internal/middleware/audit_middleware_test.go @@ -0,0 +1,117 @@ +package middleware + +import ( + "baron-sso-backend/internal/domain" + "context" + "encoding/json" + "errors" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockAuditRepository is a mock implementation of AuditRepository +type MockAuditRepository struct { + mock.Mock +} + +func (m *MockAuditRepository) Create(log *domain.AuditLog) error { + args := m.Called(log) + return args.Error(0) +} + +func (m *MockAuditRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor) ([]domain.AuditLog, error) { + args := m.Called(ctx, limit, cursor) + return args.Get(0).([]domain.AuditLog), args.Error(1) +} + +func (m *MockAuditRepository) Ping(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func TestAuditMiddleware(t *testing.T) { + t.Run("POST request - Sync Success", func(t *testing.T) { + app := fiber.New() + mockRepo := new(MockAuditRepository) + + app.Use(AuditMiddleware(AuditConfig{ + Repo: mockRepo, + BodyDump: true, + })) + + app.Post("/test", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + mockRepo.On("Create", mock.MatchedBy(func(log *domain.AuditLog) bool { + var details map[string]any + json.Unmarshal([]byte(log.Details), &details) + return log.Status == "success" && + details["method"] == "POST" && + details["request_body"] == `{"password":"*****","user":"test"}` + })).Return(nil) + + req := httptest.NewRequest("POST", "/test", strings.NewReader(`{"user": "test", "password": "mypassword"}`)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req) + assert.Equal(t, fiber.StatusOK, resp.StatusCode) + mockRepo.AssertExpectations(t) + }) + + t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) { + app := fiber.New() + mockRepo := new(MockAuditRepository) + + app.Use(AuditMiddleware(AuditConfig{ + Repo: mockRepo, + })) + + app.Post("/test", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + mockRepo.On("Create", mock.Anything).Return(errors.New("db error")) + + req := httptest.NewRequest("POST", "/test", nil) + resp, _ := app.Test(req) + + // Should return 503 because Audit failed on a Write method + assert.Equal(t, fiber.StatusServiceUnavailable, resp.StatusCode) + }) + + t.Run("GET request - Async Load Shedding", func(t *testing.T) { + app := fiber.New() + mockRepo := new(MockAuditRepository) + + // Set very small queue and no workers to force load shedding + app.Use(AuditMiddleware(AuditConfig{ + Repo: mockRepo, + QueueSize: 1, + WorkerCount: 0, // This will be defaulted to 5 by the code, so let's use another way or just small queue + })) + + app.Get("/test", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + // 1. First request fills the queue + mockRepo.On("Create", mock.Anything).Return(nil) + + req1 := httptest.NewRequest("GET", "/test", nil) + resp1, _ := app.Test(req1) + assert.Equal(t, fiber.StatusOK, resp1.StatusCode) + + // 2. Second request should be dropped (load shedding) if workers are slow + // Since we can't easily pause workers without modifying code, + // this test mostly ensures the non-blocking send doesn't hang. + req2 := httptest.NewRequest("GET", "/test", nil) + resp2, _ := app.Test(req2) + assert.Equal(t, fiber.StatusOK, resp2.StatusCode) + }) +} \ No newline at end of file diff --git a/backend/internal/middleware/audit_required.go b/backend/internal/middleware/audit_required.go deleted file mode 100644 index 5741df0f..00000000 --- a/backend/internal/middleware/audit_required.go +++ /dev/null @@ -1,106 +0,0 @@ -package middleware - -import ( - "baron-sso-backend/internal/domain" - "encoding/json" - "fmt" - "log/slog" - "time" - - "github.com/gofiber/fiber/v2" - "github.com/google/uuid" -) - -type AuditRequiredConfig struct { - Repo domain.AuditRepository - ExcludePaths map[string]struct{} - CommandMethods map[string]struct{} -} - -func RequireAudit(config AuditRequiredConfig) fiber.Handler { - commandMethods := config.CommandMethods - if len(commandMethods) == 0 { - commandMethods = map[string]struct{}{ - fiber.MethodPost: {}, - fiber.MethodPut: {}, - fiber.MethodPatch: {}, - fiber.MethodDelete: {}, - } - } - - excludePaths := config.ExcludePaths - if excludePaths == nil { - excludePaths = map[string]struct{}{} - } - - return func(c *fiber.Ctx) error { - if _, ok := commandMethods[c.Method()]; !ok { - return c.Next() - } - if _, excluded := excludePaths[c.Path()]; excluded { - return c.Next() - } - if config.Repo == nil { - return fiber.NewError(fiber.StatusServiceUnavailable, "audit repository unavailable") - } - - start := time.Now() - reqID := c.Get("X-Request-Id") - if reqID == "" { - reqID = uuid.New().String() - c.Set("X-Request-Id", reqID) - } - - err := c.Next() - latency := time.Since(start) - - status := c.Response().StatusCode() - if err != nil { - if fiberErr, ok := err.(*fiber.Error); ok { - status = fiberErr.Code - } else { - status = fiber.StatusInternalServerError - } - } - - statusText := "success" - if status >= fiber.StatusBadRequest { - statusText = "failure" - } - - details := map[string]any{ - "request_id": reqID, - "method": c.Method(), - "path": c.Path(), - "status": status, - "latency_ms": latency.Milliseconds(), - } - if err != nil { - details["error"] = err.Error() - } - - detailsJSON, jsonErr := json.Marshal(details) - if jsonErr != nil { - slog.Warn("failed to marshal audit details", "error", jsonErr, "req_id", reqID) - } - - auditLog := &domain.AuditLog{ - EventID: reqID, - Timestamp: time.Now(), - UserID: "", - EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()), - Status: statusText, - IPAddress: c.IP(), - UserAgent: c.Get("User-Agent"), - DeviceID: "", - Details: string(detailsJSON), - } - - if createErr := config.Repo.Create(auditLog); createErr != nil { - slog.Error("audit log write failed", "error", createErr, "req_id", reqID, "path", c.Path()) - return fiber.NewError(fiber.StatusServiceUnavailable, "audit logging unavailable") - } - - return err - } -} diff --git a/backend/internal/service/descope_service.go b/backend/internal/service/descope_service.go index e9f44889..42d0bab5 100644 --- a/backend/internal/service/descope_service.go +++ b/backend/internal/service/descope_service.go @@ -145,6 +145,101 @@ func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, er return res, nil } +// UserExists는 loginID(이메일/전화번호) 기준으로 사용자가 있는지 확인합니다. +func (d *DescopeProvider) UserExists(loginID string) (bool, error) { + if d.Client == nil { + return false, fmt.Errorf("descope provider: client is nil") + } + + ctx := context.Background() + if strings.Contains(loginID, "@") { + user, err := d.Client.Management.User().Load(ctx, loginID) + if err != nil { + if isDescopeNotFound(err) { + return false, nil + } + return false, err + } + return user != nil, nil + } + + phone := normalizePhone(loginID) + searchOptions := &descope.UserSearchOptions{ + Phones: []string{phone}, + Limit: 1, + } + users, _, err := d.Client.Management.User().SearchAll(ctx, searchOptions) + if err != nil { + return false, err + } + return len(users) > 0, nil +} + +// IssueSession은 비밀번호 없이 로그인 세션을 발급합니다. +func (d *DescopeProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { + if d.Client == nil { + return nil, fmt.Errorf("descope provider: client is nil") + } + ctx := context.Background() + + targetLoginID, err := d.resolveLoginID(loginID) + if err != nil { + return nil, err + } + + embeddedToken, err := d.Client.Management.User().GenerateEmbeddedLink(ctx, targetLoginID, nil, 0) + if err != nil { + return nil, fmt.Errorf("descope provider: generate embedded link failed: %w", err) + } + + authInfo, err := d.Client.Auth.MagicLink().Verify(ctx, embeddedToken, nil) + if err != nil { + return nil, fmt.Errorf("descope provider: magic link verify failed: %w", err) + } + + res := &domain.AuthInfo{ + SessionToken: &domain.Token{ + JWT: authInfo.SessionToken.JWT, + Expiration: time.Unix(authInfo.SessionToken.Expiration, 0), + }, + Subject: authInfo.User.UserID, + } + if authInfo.RefreshToken != nil { + res.RefreshToken = &domain.Token{ + JWT: authInfo.RefreshToken.JWT, + Expiration: time.Unix(authInfo.RefreshToken.Expiration, 0), + } + } + return res, nil +} + +func (d *DescopeProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + return nil, domain.ErrNotSupported +} + +func (d *DescopeProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + return nil, domain.ErrNotSupported +} + +// GetPasswordPolicy는 Descope 비밀번호 정책을 반환합니다. +func (d *DescopeProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + if d.Client == nil { + return nil, fmt.Errorf("descope provider: client is nil") + } + policy, err := d.Client.Auth.Password().GetPasswordPolicy(context.Background()) + if err != nil { + return nil, err + } + return &domain.PasswordPolicy{ + MinLength: int(policy.MinLength), + Lowercase: policy.Lowercase, + Uppercase: policy.Uppercase, + Number: policy.Number, + NonAlphanumeric: policy.NonAlphanumeric, + MinCharacterTypes: 0, + }, nil +} + func (d *DescopeProvider) InitiatePasswordReset(loginID, redirectUrl string) error { ctx := context.Background() err := d.Client.Auth.Password().SendPasswordReset(ctx, loginID, redirectUrl, nil) @@ -197,3 +292,57 @@ func (d *DescopeProvider) UpdateUserPassword(loginID, newPassword string, r *htt ctx := context.Background() return d.Client.Auth.Password().UpdateUserPassword(ctx, loginID, newPassword, r) } + +func (d *DescopeProvider) resolveLoginID(loginID string) (string, error) { + if strings.Contains(loginID, "@") { + return loginID, nil + } + + phone := normalizePhone(loginID) + searchOptions := &descope.UserSearchOptions{ + Phones: []string{phone}, + Limit: 1, + } + users, _, err := d.Client.Management.User().SearchAll(context.Background(), searchOptions) + if err != nil { + return "", fmt.Errorf("descope provider: user search failed: %w", err) + } + if len(users) == 0 { + return "", fmt.Errorf("descope provider: user not found") + } + if len(users[0].LoginIDs) > 0 { + return users[0].LoginIDs[0], nil + } + if users[0].UserID != "" { + return users[0].UserID, nil + } + return "", fmt.Errorf("descope provider: user found but login id missing") +} + +func normalizePhone(phone string) string { + normalized := strings.ReplaceAll(phone, "-", "") + normalized = strings.ReplaceAll(normalized, " ", "") + if strings.HasPrefix(normalized, "010") { + return "+82" + normalized[1:] + } + if strings.HasPrefix(normalized, "82") { + return "+" + normalized + } + return normalized +} + +func isDescopeNotFound(err error) bool { + if de, ok := err.(*descope.Error); ok { + if rawStatus, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok { + switch v := rawStatus.(type) { + case int: + return v == http.StatusNotFound + case float64: + return int(v) == http.StatusNotFound + case string: + return v == fmt.Sprintf("%d", http.StatusNotFound) + } + } + } + return false +} diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go index bdbc6afe..655455a2 100644 --- a/backend/internal/service/ory_service.go +++ b/backend/internal/service/ory_service.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "os" + "strings" "time" ) @@ -63,6 +64,15 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri if existingID != "" { return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email) } + if user.PhoneNumber != "" { + existingPhoneID, err := o.findIdentityID(user.PhoneNumber) + if err != nil { + return "", fmt.Errorf("ory provider: search identity failed: %w", err) + } + if existingPhoneID != "" { + return "", fmt.Errorf("ory provider: identity already exists for phone=%s", user.PhoneNumber) + } + } traits := map[string]interface{}{ "email": user.Email, @@ -84,6 +94,27 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri }, }, } + verifiable := []map[string]interface{}{ + { + "value": user.Email, + "verified": true, + "via": "email", + }, + } + if user.PhoneNumber != "" { + verifiable = append(verifiable, map[string]interface{}{ + "value": user.PhoneNumber, + "verified": true, + "via": "sms", + }) + } + payload["verifiable_addresses"] = verifiable + payload["recovery_addresses"] = []map[string]interface{}{ + { + "value": user.Email, + "via": "email", + }, + } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, fmt.Sprintf("%s/admin/identities", o.KratosAdminURL), bytes.NewReader(body)) @@ -119,7 +150,7 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) return nil, fmt.Errorf("ory provider: loginID and password are required") } - flowID, err := o.startLoginFlow() + flowID, err := o.startLoginFlow("") if err != nil { return nil, err } @@ -178,6 +209,326 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) }, nil } +// UserExists는 Kratos Admin API로 loginID 존재 여부를 확인합니다. +func (o *OryProvider) UserExists(loginID string) (bool, error) { + if loginID == "" { + return false, fmt.Errorf("ory provider: loginID is empty") + } + identityID, err := o.findIdentityID(loginID) + if err != nil { + return false, fmt.Errorf("ory provider: find identity failed: %w", err) + } + return identityID != "", nil +} + +// IssueSession은 Ory에서 별도 세션 발급이 필요할 때 사용합니다. (현재 미지원) +func (o *OryProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { + return nil, domain.ErrNotSupported +} + +// InitiateLinkLogin은 Kratos Public API로 링크 로그인 플로우를 시작하고 이메일 전송을 트리거합니다. +func (o *OryProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + if loginID == "" { + return nil, fmt.Errorf("ory provider: loginID is required") + } + + init, err := o.submitLoginCodeInit(loginID, returnTo) + if err == nil { + return init, nil + } + + if shouldBootstrapCodeLogin(err) { + if ensureErr := o.ensureCodeLoginIdentifier(loginID); ensureErr == nil { + return o.submitLoginCodeInit(loginID, returnTo) + } else { + slog.Warn("Ory code login bootstrap failed", "loginID", loginID, "error", ensureErr) + } + } + + return nil, err +} + +func (o *OryProvider) submitLoginCodeInit(loginID, returnTo string) (*domain.LinkLoginInit, error) { + flowID, err := o.startLoginFlow(returnTo) + if err != nil { + return nil, err + } + + body, _ := json.Marshal(map[string]string{ + "method": "code", + "identifier": loginID, + }) + loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("ory provider: build link login request failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := o.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("ory provider: link login request failed: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 300 { + init, ok := parseKratosLinkLoginResponse(flowID, respBody) + if ok { + slog.Info("Ory link login initiated with non-2xx response", "loginID", loginID, "flow_id", flowID, "status", resp.StatusCode) + return init, nil + } + return nil, fmt.Errorf("ory provider: link login failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + var result struct { + ExpiresAt time.Time `json:"expires_at"` + } + _ = json.Unmarshal(respBody, &result) + + slog.Info("Ory link login initiated", "loginID", loginID, "flow_id", flowID) + + return &domain.LinkLoginInit{ + FlowID: flowID, + ExpiresAt: result.ExpiresAt, + Mode: "link", + }, nil +} + +func parseKratosLinkLoginResponse(flowID string, body []byte) (*domain.LinkLoginInit, bool) { + if len(body) == 0 { + return nil, false + } + var parsed struct { + ExpiresAt time.Time `json:"expires_at"` + State string `json:"state"` + Active string `json:"active"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, false + } + state := strings.ToLower(parsed.State) + active := strings.ToLower(parsed.Active) + if strings.Contains(state, "sent") || active == "code" { + return &domain.LinkLoginInit{ + FlowID: flowID, + ExpiresAt: parsed.ExpiresAt, + Mode: "link", + }, true + } + return nil, false +} + +func shouldBootstrapCodeLogin(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "has not setup sign in with code") || + strings.Contains(msg, "4000035") +} + +type kratosVerifiableAddress struct { + Value string `json:"value"` + Via string `json:"via"` + Verified bool `json:"verified"` + Status string `json:"status,omitempty"` +} + +func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error { + identityID, err := o.findIdentityID(loginID) + if err != nil { + return fmt.Errorf("ory provider: find identity failed: %w", err) + } + if identityID == "" { + return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID) + } + + identity, err := o.fetchIdentity(identityID) + if err != nil { + return err + } + + via := "sms" + if strings.Contains(loginID, "@") { + via = "email" + } + + exists := false + existingIndex := -1 + addresses := make([]kratosVerifiableAddress, 0, len(identity.VerifiableAddresses)+1) + for idx, addr := range identity.VerifiableAddresses { + addresses = append(addresses, kratosVerifiableAddress{ + Value: addr.Value, + Via: addr.Via, + Verified: addr.Verified, + Status: addr.Status, + }) + if addr.Value == loginID && addr.Via == via { + exists = true + existingIndex = idx + } + } + ops := make([]map[string]interface{}, 0, 2) + if !exists { + ops = append(ops, map[string]interface{}{ + "op": "add", + "path": "/verifiable_addresses/-", + "value": map[string]interface{}{ + "value": loginID, + "via": via, + "verified": true, + "status": "completed", + }, + }) + } else { + addr := identity.VerifiableAddresses[existingIndex] + if !addr.Verified { + ops = append(ops, map[string]interface{}{ + "op": "replace", + "path": fmt.Sprintf("/verifiable_addresses/%d/verified", existingIndex), + "value": true, + }) + } + if addr.Status != "" && addr.Status != "completed" { + ops = append(ops, map[string]interface{}{ + "op": "replace", + "path": fmt.Sprintf("/verifiable_addresses/%d/status", existingIndex), + "value": "completed", + }) + } + } + + if len(ops) == 0 { + slog.Info("Ory identity verifiable address already ready", "identity_id", identityID, "loginID", loginID, "via", via) + return nil + } + + return o.patchIdentity(identityID, ops) +} + +type kratosIdentity struct { + VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"` +} + +func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error { + body, _ := json.Marshal(ops) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("ory provider: build identity patch failed: %w", err) + } + req.Header.Set("Content-Type", "application/json-patch+json") + + resp, err := o.httpClient().Do(req) + if err != nil { + return fmt.Errorf("ory provider: identity patch failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("ory provider: identity patch failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + slog.Info("Ory identity patched", "identity_id", identityID, "ops", len(ops)) + return nil +} + +func (o *OryProvider) fetchIdentity(identityID string) (*kratosIdentity, error) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil) + if err != nil { + return nil, fmt.Errorf("ory provider: build identity get failed: %w", err) + } + + resp, err := o.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("ory provider: identity get failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("ory provider: identity get failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var identity kratosIdentity + if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { + return nil, fmt.Errorf("ory provider: decode identity failed: %w", err) + } + return &identity, nil +} + +// VerifyLoginCode는 Kratos 로그인 코드 제출로 세션을 발급합니다. +func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + if loginID == "" || flowID == "" || code == "" { + return nil, fmt.Errorf("ory provider: loginID, flowID and code are required") + } + + body, _ := json.Marshal(map[string]string{ + "method": "code", + "identifier": loginID, + "code": code, + }) + loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("ory provider: build login code request failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := o.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("ory provider: login code request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("ory provider: login code failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + var result struct { + SessionToken string `json:"session_token"` + SessionTokenExpiresAt time.Time `json:"session_token_expires_at"` + Session struct { + Identity struct { + ID string `json:"id"` + } `json:"identity"` + } `json:"session"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("ory provider: decode login code response failed: %w", err) + } + if result.SessionToken == "" { + return nil, fmt.Errorf("ory provider: empty session token returned") + } + + slog.Info("Ory login code successful", + "identity_id", result.Session.Identity.ID, + "loginID", loginID, + "expires_at", result.SessionTokenExpiresAt, + ) + + return &domain.AuthInfo{ + SessionToken: &domain.Token{ + JWT: result.SessionToken, + Expiration: result.SessionTokenExpiresAt, + }, + Subject: result.Session.Identity.ID, + }, nil +} + +// GetPasswordPolicy는 Ory 환경에서 사용하는 기본 정책을 반환합니다. +func (o *OryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + return &domain.PasswordPolicy{ + MinLength: 12, + Lowercase: true, + Uppercase: false, + Number: true, + NonAlphanumeric: true, + MinCharacterTypes: 0, + }, nil +} + // InitiatePasswordReset는 현재 내부 토큰/메일 흐름을 사용하고 있으므로 NO-OP로 둡니다. func (o *OryProvider) InitiatePasswordReset(loginID, redirectUrl string) error { slog.Info("Ory InitiatePasswordReset bypassed (handled by app internal flow)", "loginID", loginID, "redirect", redirectUrl) @@ -301,8 +652,12 @@ func (o *OryProvider) httpClient() *http.Client { } // startLoginFlow는 Kratos Public API에서 login flow ID를 발급받습니다. -func (o *OryProvider) startLoginFlow() (string, error) { - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/self-service/login/api", o.KratosPublicURL), nil) +func (o *OryProvider) startLoginFlow(returnTo string) (string, error) { + loginURL := fmt.Sprintf("%s/self-service/login/api", o.KratosPublicURL) + if returnTo != "" { + loginURL = loginURL + "?return_to=" + url.QueryEscape(returnTo) + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, loginURL, nil) if err != nil { return "", fmt.Errorf("ory provider: build login flow request failed: %w", err) } diff --git a/backend/internal/utils/masking.go b/backend/internal/utils/masking.go new file mode 100644 index 00000000..ab83f5ca --- /dev/null +++ b/backend/internal/utils/masking.go @@ -0,0 +1,79 @@ +package utils + +import ( + "encoding/json" + "strings" +) + +var sensitiveKeys = map[string]struct{}{ + "password": {}, + "newpassword": {}, + "oldpassword": {}, + "token": {}, + "accesstoken": {}, + "access_token": {}, + "refreshtoken": {}, + "refresh_token": {}, + "secret": {}, + "clientsecret": {}, + "client_secret": {}, + "authorization": {}, + "cookie": {}, + "set-cookie": {}, + "verificationcode": {}, + "verification_code": {}, + "code": {}, // Auth code (sensitive) +} + +// MaskSensitiveJSON parses a JSON byte slice and masks values of sensitive keys. +// Returns the original data if it's not valid JSON. +func MaskSensitiveJSON(data []byte) []byte { + if len(data) == 0 { + return data + } + + var obj interface{} + if err := json.Unmarshal(data, &obj); err != nil { + // Not a JSON object/array, return as is + return data + } + + masked := maskValue(obj) + + result, err := json.Marshal(masked) + if err != nil { + return data + } + return result +} + +func maskValue(v interface{}) interface{} { + switch val := v.(type) { + case map[string]interface{}: + newMap := make(map[string]interface{}, len(val)) + for k, v := range val { + if isSensitive(k) { + newMap[k] = "*****" + } else { + newMap[k] = maskValue(v) + } + } + return newMap + case []interface{}: + newArr := make([]interface{}, len(val)) + for i, v := range val { + newArr[i] = maskValue(v) + } + return newArr + default: + return val + } +} + +func isSensitive(key string) bool { + // Check case-insensitive + // Remove common separators for looser matching? No, stick to lowercase check for now. + k := strings.ToLower(key) + _, ok := sensitiveKeys[k] + return ok +} diff --git a/backend/internal/utils/masking_test.go b/backend/internal/utils/masking_test.go new file mode 100644 index 00000000..6db6c30c --- /dev/null +++ b/backend/internal/utils/masking_test.go @@ -0,0 +1,59 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMaskSensitiveJSON(t *testing.T) { + tests := []struct { + name string + input string + expected string // We'll check containment or specific structure + }{ + { + name: "Flat object with password", + input: `{"username": "user", "password": "secret123"}`, + expected: `{"password":"*****","username":"user"}`, + }, + { + name: "Nested object with token", + input: `{"data": {"token": "abc-def", "id": 123}}`, + expected: `{"data":{"id":123,"token":"*****"}}`, + }, + { + name: "Case insensitive key", + input: `{"NewPassword": "changed"}`, + expected: `{"NewPassword":"*****"}`, + }, + { + name: "Array of objects", + input: `[{"secret": "s1"}, {"secret": "s2"}]`, + expected: `[{"secret":"*****"},{"secret":"*****"}]`, + }, + { + name: "Invalid JSON", + input: `not-json`, + expected: `not-json`, + }, + { + name: "Empty JSON", + input: ``, + expected: ``, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MaskSensitiveJSON([]byte(tt.input)) + // Since JSON map order is undefined, exact string match might fail if keys are reordered. + // Ideally we should unmarshal and compare maps, or use assert.JSONEq + if tt.name == "Invalid JSON" || tt.name == "Empty JSON" { + assert.Equal(t, tt.expected, string(result)) + } else { + assert.JSONEq(t, tt.expected, string(result)) + } + }) + } +} diff --git a/backend/internal/validator/schema_validator_test.go b/backend/internal/validator/schema_validator_test.go index 75ebcbc7..5bcf405f 100644 --- a/backend/internal/validator/schema_validator_test.go +++ b/backend/internal/validator/schema_validator_test.go @@ -29,6 +29,26 @@ func (m *MockProvider) SignIn(loginID, password string) (*domain.AuthInfo, error return &domain.AuthInfo{}, nil } +func (m *MockProvider) UserExists(loginID string) (bool, error) { + return false, nil +} + +func (m *MockProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { + return nil, domain.ErrNotSupported +} + +func (m *MockProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + return nil, domain.ErrNotSupported +} + +func (m *MockProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + return nil, domain.ErrNotSupported +} + +func (m *MockProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + return nil, domain.ErrNotSupported +} + // Stub implementations to satisfy the IdentityProvider interface for this unit test. func (m *MockProvider) InitiatePasswordReset(loginID, redirectUrl string) error { return nil diff --git a/compose.ory.yaml b/compose.ory.yaml index 8236279b..53ffae17 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -155,7 +155,6 @@ services: ports: - "4457:4455" # Proxy environment: - - LOG_LEVEL=${OATHKEEPER_LOG_LEVEL:info} - APP_ENV=${APP_ENV:-development} volumes: - ./docker/ory/oathkeeper:/etc/config/oathkeeper diff --git a/docker-compose.yaml b/docker-compose.yaml index 9cb73a97..23d59b02 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -36,7 +36,7 @@ services: - ory-net volumes: - ./backend:/app - command: ["go", "run", "./cmd/server/main.go"] + command: ["go", "run", "./cmd/server"] healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] diff --git a/docker/ory/kratos/courier-http.jsonnet b/docker/ory/kratos/courier-http.jsonnet new file mode 100644 index 00000000..10cf3f72 --- /dev/null +++ b/docker/ory/kratos/courier-http.jsonnet @@ -0,0 +1,8 @@ +// Kratos courier HTTP payload을 backend로 전달하는 템플릿입니다. +function(ctx) + local data = if std.objectHas(ctx, "template_data") && ctx.template_data != null then ctx.template_data else {}; + { + recipient: ctx.recipient, + template_type: ctx.template_type, + template_data: data, + } diff --git a/docker/ory/kratos/courier-templates/login_code/valid/email.body.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/email.body.gotmpl new file mode 100644 index 00000000..2d2b1c20 --- /dev/null +++ b/docker/ory/kratos/courier-templates/login_code/valid/email.body.gotmpl @@ -0,0 +1,17 @@ + + + +

Baron SSO 로그인

+

아래 버튼을 클릭하면 로그인이 완료됩니다.

+ +

+ + 로그인 완료하기 + +

+

또는 아래 로그인 코드를 입력해도 됩니다.

+

{{ .LoginCode }}

+

요청하지 않았다면 이 메일을 무시해 주세요.

+ + diff --git a/docker/ory/kratos/courier-templates/login_code/valid/email.body.plaintext.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/email.body.plaintext.gotmpl new file mode 100644 index 00000000..0f08b2a7 --- /dev/null +++ b/docker/ory/kratos/courier-templates/login_code/valid/email.body.plaintext.gotmpl @@ -0,0 +1,10 @@ +Baron SSO 로그인 + +# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }} + +아래 링크를 클릭하면 로그인이 완료됩니다. +http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }} + +로그인 코드: {{ .LoginCode }} + +요청하지 않았다면 이 메일을 무시해 주세요. diff --git a/docker/ory/kratos/courier-templates/login_code/valid/email.subject.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/email.subject.gotmpl new file mode 100644 index 00000000..2ca5dc3b --- /dev/null +++ b/docker/ory/kratos/courier-templates/login_code/valid/email.subject.gotmpl @@ -0,0 +1 @@ +Baron SSO 로그인 링크 diff --git a/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl new file mode 100644 index 00000000..f3f6fdf4 --- /dev/null +++ b/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl @@ -0,0 +1,4 @@ +[Baron SSO] 로그인 링크 +# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }} +http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }} +코드: {{ .LoginCode }} diff --git a/docker/ory/kratos/identity.schema.json b/docker/ory/kratos/identity.schema.json index 16bcbadf..d967d074 100644 --- a/docker/ory/kratos/identity.schema.json +++ b/docker/ory/kratos/identity.schema.json @@ -16,6 +16,10 @@ "credentials": { "password": { "identifier": true + }, + "code": { + "identifier": true, + "via": "email" } }, "recovery": { @@ -27,17 +31,68 @@ } }, "name": { - "type": "object", - "properties": { - "first": { - "type": "string", - "title": "First Name" - }, - "last": { - "type": "string", - "title": "Last Name" + "type": "string", + "title": "Name" + }, + "phone_number": { + "type": "string", + "title": "Phone Number", + "minLength": 7, + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + }, + "code": { + "identifier": true, + "via": "sms" + } } } + }, + "department": { + "type": "string", + "title": "Department" + }, + "affiliationType": { + "type": "string", + "title": "Affiliation Type" + }, + "companyCode": { + "type": "string", + "title": "Company Code" + }, + "displayname": { + "type": "string", + "title": "Display Name" + }, + "completeForm": { + "type": "boolean", + "title": "Complete Form" + }, + "team": { + "type": "string", + "title": "Team" + }, + "taxCode": { + "type": "string", + "title": "Tax Code" + }, + "familyCompany": { + "type": "string", + "title": "Family Company" + }, + "familyUniqueKey": { + "type": "string", + "title": "Family Unique Key" + }, + "personal": { + "type": "boolean", + "title": "Personal" + }, + "grade": { + "type": "string", + "title": "Grade" } }, "required": [ @@ -46,4 +101,4 @@ "additionalProperties": false } } -} \ No newline at end of file +} diff --git a/docker/ory/kratos/kratos.yml b/docker/ory/kratos/kratos.yml index 27ed71a2..1bcc337e 100644 --- a/docker/ory/kratos/kratos.yml +++ b/docker/ory/kratos/kratos.yml @@ -23,6 +23,7 @@ selfservice: enabled: true code: enabled: true + passwordless_enabled: true flows: error: @@ -72,5 +73,14 @@ identity: url: file:///etc/config/kratos/identity.schema.json courier: + template_override_path: /etc/config/kratos/courier-templates + delivery_strategy: http + http: + request_config: + url: http://baron_backend:3000/api/v1/auth/webhooks/kratos-courier + method: POST + body: file:///etc/config/kratos/courier-http.jsonnet + headers: + Content-Type: application/json smtp: connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true diff --git a/docker/ory/oathkeeper/rules.prod.json b/docker/ory/oathkeeper/rules.prod.json index b84e202c..d6537966 100644 --- a/docker/ory/oathkeeper/rules.prod.json +++ b/docker/ory/oathkeeper/rules.prod.json @@ -3,7 +3,7 @@ "id": "public-health", "description": "공개 헬스체크 (PROD 도메인)", "match": { - "url": "https://auth.brsw.kr/health", + "url": "https://app.brsw.kr/health", "methods": ["GET"] }, "upstream": { @@ -21,7 +21,7 @@ "id": "public-preflight", "description": "CORS preflight (PROD 도메인)", "match": { - "url": "https://auth.brsw.kr/api/v1/<.*>", + "url": "https://app.brsw.kr/api/v1/<.*>", "methods": ["OPTIONS"] }, "upstream": { @@ -39,7 +39,7 @@ "id": "public-auth", "description": "인증/회원가입 등 공개 엔드포인트 (PROD 도메인)", "match": { - "url": "https://auth.brsw.kr/api/v1/auth/<.*>", + "url": "https://app.brsw.kr/api/v1/auth/<.*>", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { @@ -57,7 +57,7 @@ "id": "backend-command", "description": "Command 요청은 Backend로 전달 (Audit 강제)", "match": { - "url": "https://auth.brsw.kr/api/v1/<.*>", + "url": "https://app.brsw.kr/api/v1/<.*>", "methods": ["POST", "PUT", "PATCH", "DELETE"] }, "upstream": { @@ -75,7 +75,7 @@ "id": "backend-query", "description": "Backend Query (admin/dev 포함)", "match": { - "url": "https://auth.brsw.kr/api/v1/<.*>", + "url": "https://app.brsw.kr/api/v1/<.*>", "methods": ["GET"] }, "upstream": { diff --git a/docs/auth-flow.md b/docs/auth-flow.md new file mode 100644 index 00000000..95c1be59 --- /dev/null +++ b/docs/auth-flow.md @@ -0,0 +1,89 @@ +# 인증/로그인 플로우 정리 (Backend IDP 추상화 기준) + +이 문서는 **Backend IDP 추상화(IdentityProvider)**를 기준으로, 현재 지원하는 로그인 방식과 UserFront 연동 API, 그리고 **Kratos 세션 공유 방식**을 정리합니다. + +> 목적: ID/Password 방식부터 시작해, 현재 지원 중인 로그인 플로우를 **IDP 추상화를 해치지 않는 범위**에서 일관되게 구현하고, Front/Backend/Oathkeeper 간 세션 전달 방식을 명확히 한다. + +--- + +## 1) 지원 로그인 방식 요약 + +| 방식 | Backend 엔드포인트 | 세션 토큰 반환 | 비고 | +|---|---|---|---| +| ID/Password | `POST /api/v1/auth/password/login` | `sessionJwt` | IDP 추상화 사용 (Ory/Descope) | +| Enchanted Link (Email/SMS) | `POST /api/v1/auth/enchanted-link/init` → `POST /api/v1/auth/enchanted-link/poll` | `sessionJwt` | 링크 클릭 시 `POST /api/v1/auth/magic-link/verify` 호출 | +| Magic Link Verify | `POST /api/v1/auth/magic-link/verify` | `token` | Polling 세션 갱신용 | +| SMS 코드 | `POST /api/v1/auth/sms` → `POST /api/v1/auth/verify-sms` | `token` | 현재는 내부 토큰(placeholder). Kratos 세션 교환 필요 | +| QR 로그인 | `POST /api/v1/auth/qr/init` → `POST /api/v1/auth/qr/poll` | `sessionJwt` | 모바일 승인: `POST /api/v1/auth/qr/approve` | + +--- + +## 2) UserFront 연동 API 매핑 + +### 2.1 ID/Password 로그인 +1. `POST /api/v1/auth/password/login` +2. 응답의 `sessionJwt` 사용 + +### 2.2 Enchanted Link (Email/SMS) +1. `POST /api/v1/auth/enchanted-link/init` → `pendingRef` 수신 +2. `POST /api/v1/auth/enchanted-link/poll`로 폴링 +3. 사용자가 링크 클릭하면 UserFront가 `POST /api/v1/auth/magic-link/verify` 호출 +4. Polling 응답에서 `sessionJwt` 수신 + +### 2.3 QR 로그인 +1. `POST /api/v1/auth/qr/init` → `qrCode`, `pendingRef` 수신 +2. 웹은 `POST /api/v1/auth/qr/poll`로 폴링 +3. 모바일 앱은 `POST /api/v1/auth/qr/approve`로 승인 +4. Polling 응답에서 `sessionJwt` 수신 + +### 2.4 SMS 코드 로그인 +1. `POST /api/v1/auth/sms`로 코드 발송 +2. `POST /api/v1/auth/verify-sms`로 코드 검증 +3. 현재는 내부 토큰 반환 (IDP 세션 교환은 TODO) + +--- + +## 3) Kratos 세션 생성/공유 방식 + +### 3.1 생성 (ID/Password 기준) +- Backend가 IDP 추상화(`IdentityProvider.SignIn`)를 호출해 `sessionJwt`를 발급 +- **Ory(Kratos)**의 경우: + - Kratos Login API를 통해 `session_token`을 반환 + - 이 값이 `sessionJwt`로 응답됨 + +### 3.2 공유 (Backend → UserFront / Oathkeeper) +현재 공유 방식은 **두 가지 선택지**가 있습니다. + +**A) Backend가 쿠키로 전달 (권장 방향)** +- `sessionJwt`가 Kratos `session_token`인 경우 `ory_kratos_session` 쿠키로 `Set-Cookie` +- Oathkeeper `cookie_session` authenticator가 Kratos `/sessions/whoami`로 검증 가능 + +**B) UserFront가 토큰을 보관/전달 (현재 동작)** +- `sessionJwt`를 로컬에 저장 후 Backend 호출 시 `Authorization: Bearer `로 전달 +- Oathkeeper 경유 경로에서는 쿠키가 필요하므로, 별도 토큰 교환 또는 Oathkeeper 인증기 추가가 필요 + +> 현재 구현은 **B 방식에 가깝고**, Oathkeeper 통과를 위한 쿠키 전달은 추가 구현이 필요합니다. + +--- + +## 4) IDP 추상화 관점에서의 구현 상태 + +- **ID/Password 로그인**: IDP 추상화 사용 (Ory/Descope) — 정상 +- **Enchanted/Magic Link**: 현재는 Descope 기반 로직이 포함됨. Ory 전환 시 Kratos `code/link` 플로우로 교체 필요 +- **SMS 코드**: 내부 토큰(placeholder). Kratos 세션 교환 로직 추가 필요 +- **QR 로그인**: 모바일 세션 토큰을 웹 세션으로 전달. Ory일 경우 Kratos 세션 토큰을 전달하도록 UI/토큰 저장 방식 정비 필요 + +--- + +## 5) UserFront 주의사항 + +- `sessionJwt`가 **JWT 형식이 아닐 수 있음** (Kratos session token은 opaque 가능) +- 현재 UserFront는 Descope SDK 기반 세션 처리 로직이 포함되어 있어, Ory 사용 시 이 부분은 분리/대체가 필요함 + +--- + +## 6) 다음 액션 제안 + +1. **Kratos 세션 쿠키 전달 방식(A) 구현** +2. Enchanted/Magic Link의 Ory 대응(로그인 코드/링크 방식) 설계 +3. SMS 코드/QR 플로우의 Kratos 세션 교환 정책 확정 diff --git a/docs/ory-usage.md b/docs/ory-usage.md index ed3ecf12..fa3367ec 100644 --- a/docs/ory-usage.md +++ b/docs/ory-usage.md @@ -95,3 +95,4 @@ docker run --rm --network baron_net curlimages/curl:8.10.1 -fsS http://kratos:44 - `compose.ory.yaml` - `docker/ory/kratos/kratos.yml` - `.env.sample` +- `docs/auth-flow.md` diff --git a/userfront/lib/core/services/audit_service.dart b/userfront/lib/core/services/audit_service.dart index 082d6a6d..5c37ed76 100644 --- a/userfront/lib/core/services/audit_service.dart +++ b/userfront/lib/core/services/audit_service.dart @@ -29,7 +29,6 @@ class AuditService { 'event_type': eventType, 'status': status, 'details': details, - 'timestamp': DateTime.now().toIso8601String(), }), ); diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index ced64e33..0cc15c0e 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'http_client.dart'; class AuthProxyService { static String _envOrDefault(String key, String fallback) { @@ -22,6 +23,24 @@ class AuthProxyService { } } + static Future> checkCookieSession() async { + final url = Uri.parse('$_baseUrl/api/v1/user/me'); + final client = createHttpClient(withCredentials: true); + try { + final response = await client.get( + url, + headers: {'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } + throw Exception('Failed to load profile: ${response.body}'); + } finally { + client.close(); + } + } + static Future> initEnchantedLink(String loginId, {String? method}) async { final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init'); final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr'); @@ -60,9 +79,11 @@ class AuthProxyService { if (response.statusCode == 200) { return jsonDecode(response.body); - } else { - throw Exception('Polling failed: ${response.body}'); } + if (response.statusCode == 400) { + return jsonDecode(response.body); + } + throw Exception('Polling failed: ${response.body}'); } static Future> verifyMagicLink(String token) async { @@ -83,6 +104,25 @@ class AuthProxyService { } } + static Future> verifyLoginCode(String loginId, String code) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify'); + + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'loginId': loginId, + 'code': code, + }), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception('Verification failed: ${response.body}'); + } + } + static Future> loginWithPassword(String loginId, String password) async { final url = Uri.parse('$_baseUrl/api/v1/auth/password/login'); @@ -205,9 +245,11 @@ class AuthProxyService { if (response.statusCode == 200) { return jsonDecode(response.body); - } else { - throw Exception('QR Polling failed: ${response.body}'); } + if (response.statusCode == 400) { + return jsonDecode(response.body); + } + throw Exception('QR Polling failed: ${response.body}'); } static Future approveQrLogin(String pendingRef, String token) async { diff --git a/userfront/lib/core/services/auth_token_store.dart b/userfront/lib/core/services/auth_token_store.dart new file mode 100644 index 00000000..5ba93582 --- /dev/null +++ b/userfront/lib/core/services/auth_token_store.dart @@ -0,0 +1,32 @@ +import 'auth_token_store_stub.dart' + if (dart.library.html) 'auth_token_store_web.dart'; + +class AuthTokenStore { + static String? getToken() => authTokenStore.getToken(); + + static String? getProvider() => authTokenStore.getProvider(); + + static bool usesCookie() => authTokenStore.usesCookie(); + + static void setToken(String token, {String? provider}) { + authTokenStore.setToken(token, provider: provider); + } + + static void setCookieMode({String? provider}) { + authTokenStore.setCookieMode(provider: provider); + } + + static String? getPendingProvider() => authTokenStore.getPendingProvider(); + + static void setPendingProvider(String? provider) { + authTokenStore.setPendingProvider(provider); + } + + static void clearPendingProvider() { + authTokenStore.setPendingProvider(null); + } + + static void clear() { + authTokenStore.clear(); + } +} diff --git a/userfront/lib/core/services/auth_token_store_stub.dart b/userfront/lib/core/services/auth_token_store_stub.dart new file mode 100644 index 00000000..229a4783 --- /dev/null +++ b/userfront/lib/core/services/auth_token_store_stub.dart @@ -0,0 +1,41 @@ +class AuthTokenStore { + String? _token; + String? _provider; + bool _cookieMode = false; + String? _pendingProvider; + + String? getToken() => _token; + + String? getProvider() => _provider; + + bool usesCookie() => _cookieMode; + + void setToken(String token, {String? provider}) { + _token = token; + _cookieMode = false; + _provider = provider; + } + + void setCookieMode({String? provider}) { + _cookieMode = true; + _token = null; + if (provider != null) { + _provider = provider; + } + } + + String? getPendingProvider() => _pendingProvider; + + void setPendingProvider(String? provider) { + _pendingProvider = provider; + } + + void clear() { + _token = null; + _provider = null; + _cookieMode = false; + _pendingProvider = null; + } +} + +final authTokenStore = AuthTokenStore(); diff --git a/userfront/lib/core/services/auth_token_store_web.dart b/userfront/lib/core/services/auth_token_store_web.dart new file mode 100644 index 00000000..dc7c6851 --- /dev/null +++ b/userfront/lib/core/services/auth_token_store_web.dart @@ -0,0 +1,49 @@ +import 'dart:html' as html; + +class AuthTokenStore { + static const _tokenKey = 'baron_auth_token'; + static const _providerKey = 'baron_auth_provider'; + static const _cookieModeKey = 'baron_auth_cookie_mode'; + static const _pendingProviderKey = 'baron_auth_pending_provider'; + + String? getToken() => html.window.localStorage[_tokenKey]; + + String? getProvider() => html.window.localStorage[_providerKey]; + + bool usesCookie() => html.window.localStorage[_cookieModeKey] == '1'; + + void setToken(String token, {String? provider}) { + html.window.localStorage[_tokenKey] = token; + html.window.localStorage.remove(_cookieModeKey); + if (provider != null) { + html.window.localStorage[_providerKey] = provider; + } + } + + void setCookieMode({String? provider}) { + html.window.localStorage[_cookieModeKey] = '1'; + html.window.localStorage.remove(_tokenKey); + if (provider != null) { + html.window.localStorage[_providerKey] = provider; + } + } + + String? getPendingProvider() => html.window.localStorage[_pendingProviderKey]; + + void setPendingProvider(String? provider) { + if (provider == null || provider.isEmpty) { + html.window.localStorage.remove(_pendingProviderKey); + return; + } + html.window.localStorage[_pendingProviderKey] = provider; + } + + void clear() { + html.window.localStorage.remove(_tokenKey); + html.window.localStorage.remove(_providerKey); + html.window.localStorage.remove(_cookieModeKey); + html.window.localStorage.remove(_pendingProviderKey); + } +} + +final authTokenStore = AuthTokenStore(); diff --git a/userfront/lib/core/services/http_client.dart b/userfront/lib/core/services/http_client.dart new file mode 100644 index 00000000..4be49f7e --- /dev/null +++ b/userfront/lib/core/services/http_client.dart @@ -0,0 +1,7 @@ +import 'package:http/http.dart' as http; +import 'http_client_stub.dart' + if (dart.library.html) 'http_client_web.dart'; + +http.Client createHttpClient({bool withCredentials = false}) { + return httpClientFactory.create(withCredentials: withCredentials); +} diff --git a/userfront/lib/core/services/http_client_stub.dart b/userfront/lib/core/services/http_client_stub.dart new file mode 100644 index 00000000..1b85678d --- /dev/null +++ b/userfront/lib/core/services/http_client_stub.dart @@ -0,0 +1,9 @@ +import 'package:http/http.dart' as http; + +class HttpClientFactory { + http.Client create({bool withCredentials = false}) { + return http.Client(); + } +} + +final httpClientFactory = HttpClientFactory(); diff --git a/userfront/lib/core/services/http_client_web.dart b/userfront/lib/core/services/http_client_web.dart new file mode 100644 index 00000000..8b6d49cf --- /dev/null +++ b/userfront/lib/core/services/http_client_web.dart @@ -0,0 +1,12 @@ +import 'package:http/browser_client.dart'; +import 'package:http/http.dart' as http; + +class HttpClientFactory { + http.Client create({bool withCredentials = false}) { + final client = BrowserClient(); + client.withCredentials = withCredentials; + return client; + } +} + +final httpClientFactory = HttpClientFactory(); diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 8e456415..9850654f 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -8,9 +8,9 @@ import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:qr_flutter/qr_flutter.dart'; -import '../../../core/services/audit_service.dart'; import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/auth_proxy_service.dart'; +import '../../../core/services/auth_token_store.dart'; import '../../../core/notifiers/auth_notifier.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; @@ -33,10 +33,12 @@ class _LoginScreenState extends ConsumerState // QR Login Variables String? _qrImageBase64; String? _qrPendingRef; + String? _qrUserCode; bool _isQrLoading = false; Timer? _qrPollingTimer; int _qrRemainingSeconds = 0; Timer? _qrCountdownTimer; + int _qrPollIntervalMs = 2000; @override void initState() { @@ -47,22 +49,58 @@ class _LoginScreenState extends ConsumerState // Check for tokens (Path Parameter or Legacy Query Parameter) WidgetsBinding.instance.addPostFrameCallback((_) { - if (widget.verificationToken != null) { + final uri = Uri.base; + final loginIdParam = uri.queryParameters['loginId']; + final codeParam = uri.queryParameters['code']; + if (loginIdParam != null && codeParam != null) { + _verifyLoginCode(loginIdParam, codeParam); + } else if (widget.verificationToken != null) { _verifyToken(widget.verificationToken!); - } else { - final uri = Uri.base; - if (uri.queryParameters.containsKey('t')) { - _verifyToken(uri.queryParameters['t']!); - } + } else if (uri.queryParameters.containsKey('t')) { + _verifyToken(uri.queryParameters['t']!); } - final uri = Uri.base; + _tryCookieSession(); + if (uri.queryParameters.containsKey('redirect_url')) { _redirectUrl = uri.queryParameters['redirect_url']; } }); } + Future _tryCookieSession({bool silent = true}) async { + if (AuthTokenStore.getToken() != null) { + return; + } + final pendingProvider = AuthTokenStore.getPendingProvider(); + final provider = pendingProvider ?? AuthTokenStore.getProvider(); + if (provider == null || !provider.toLowerCase().contains('ory')) { + return; + } + + try { + await AuthProxyService.checkCookieSession(); + AuthTokenStore.setCookieMode(provider: provider); + AuthTokenStore.clearPendingProvider(); + if (mounted) { + await ref.read(profileProvider.notifier).loadProfile(); + _onCookieLoginSuccess(provider); + } + } catch (e) { + if (!silent) { + _showError("로그인 확인 실패: ${e.toString().replaceFirst("Exception: ", "")}"); + } + } + } + + void _onCookieLoginSuccess(String provider) { + debugPrint("[Auth] Cookie-based login success. Provider: $provider"); + AuthNotifier.instance.notify(); + if (mounted) { + context.go('/'); + } + } + // Helper to decode JWT and get loginId String _getLoginIdFromJwt(String jwt) { try { @@ -107,6 +145,7 @@ class _LoginScreenState extends ConsumerState setState(() { _isQrLoading = true; _qrImageBase64 = null; + _qrUserCode = null; _qrRemainingSeconds = 0; }); @@ -117,6 +156,13 @@ class _LoginScreenState extends ConsumerState _qrImageBase64 = res['qrCode']; _qrPendingRef = res['pendingRef']; _qrRemainingSeconds = res['expiresIn'] ?? 300; + _qrUserCode = res['userCode']?.toString(); + final interval = res['interval']; + if (interval is int && interval > 0) { + _qrPollIntervalMs = interval * 1000; + } else { + _qrPollIntervalMs = 2000; + } _isQrLoading = false; }); _startQrPolling(); @@ -144,7 +190,7 @@ class _LoginScreenState extends ConsumerState void _startQrPolling() { _qrPollingTimer?.cancel(); - _qrPollingTimer = Timer.periodic(const Duration(milliseconds: 1500), (timer) async { + _qrPollingTimer = Timer.periodic(Duration(milliseconds: _qrPollIntervalMs), (timer) async { if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) { timer.cancel(); return; @@ -152,6 +198,33 @@ class _LoginScreenState extends ConsumerState try { final res = await AuthProxyService.pollQrStatus(_qrPendingRef!); + if (res['error'] == 'slow_down') { + final interval = res['interval']; + if (interval is int && interval > 0) { + final nextIntervalMs = interval * 1000; + if (nextIntervalMs != _qrPollIntervalMs) { + _qrPollIntervalMs = nextIntervalMs; + timer.cancel(); + _startQrPolling(); + return; + } + } else { + _qrPollIntervalMs += 500; + timer.cancel(); + _startQrPolling(); + return; + } + } + if (res['error'] == 'authorization_pending') { + return; + } + if (res['error'] == 'expired_token') { + timer.cancel(); + _qrCountdownTimer?.cancel(); + _showError("QR 세션이 만료되었습니다."); + return; + } + if (res['status'] == 'ok' && res['sessionJwt'] != null) { timer.cancel(); _qrCountdownTimer?.cancel(); @@ -233,6 +306,34 @@ class _LoginScreenState extends ConsumerState } } + Future _verifyLoginCode(String loginId, String code) async { + final sanitizedLoginId = loginId.replaceAll(' ', '+'); + debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId"); + try { + final res = await AuthProxyService.verifyLoginCode(sanitizedLoginId, code); + final jwt = res['sessionJwt'] ?? res['token']; + debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId"); + + if (jwt != null && mounted) { + final isJwt = (jwt as String).split('.').length == 3; + if (isJwt) { + final displayName = _getLoginIdFromJwt(jwt); + final dummyUser = DescopeUser( + 'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [], + ); + final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); + Descope.sessionManager.manageSession(session); + } + _onLoginSuccess(jwt, provider: res['provider'] as String?); + } + } catch (e) { + debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e"); + if (mounted) { + _showError("Verification failed: $e"); + } + } + } + @override void dispose() { _stopQrPolling(); @@ -271,9 +372,10 @@ class _LoginScreenState extends ConsumerState try { final res = await AuthProxyService.loginWithPassword(loginId, password); final jwt = res['sessionJwt']; + final provider = res['provider'] as String?; if (jwt != null && mounted) { Navigator.of(context).pop(); // 로딩 닫기 - _onLoginSuccess(jwt); + _onLoginSuccess(jwt, provider: provider); } } catch (e) { if (mounted) Navigator.of(context).pop(); // 로딩 닫기 @@ -326,11 +428,42 @@ class _LoginScreenState extends ConsumerState // 1. Init via Backend API final initResponse = await AuthProxyService.initEnchantedLink(loginId); final pendingRef = initResponse['pendingRef']; - debugPrint("[Auth] Link Sent. PendingRef: $pendingRef"); + final mode = (initResponse['mode'] ?? '').toString(); + final provider = (initResponse['provider'] ?? '').toString(); + final interval = initResponse['interval']; + debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider"); if (mounted) { Navigator.of(context).pop(); // Close Loading + if (mode == 'link' || provider.toLowerCase().contains('ory')) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: Text(isEmail ? "이메일 전송됨" : "SMS 전송됨"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(isEmail + ? "입력하신 이메일로 로그인 링크를 보냈습니다." + : "입력하신 번호로 로그인 링크를 보냈습니다."), + const SizedBox(height: 12), + const Text("메일/문자 링크를 열면 이 탭에서 자동으로 로그인됩니다."), + const SizedBox(height: 16), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("닫기"), + ) + ], + ), + ), + ); + return; + } + showDialog( context: context, barrierDismissible: false, @@ -339,9 +472,9 @@ class _LoginScreenState extends ConsumerState content: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(isEmail - ? "입력하신 이메일로 로그인 링크를 보냈습니다." - : "입력하신 번호로 로그인 링크를 보냈습니다."), + Text(isEmail + ? "입력하신 이메일로 로그인 링크를 보냈습니다." + : "입력하신 번호로 로그인 링크를 보냈습니다."), const SizedBox(height: 16), const LinearProgressIndicator(), const SizedBox(height: 16), @@ -349,8 +482,8 @@ class _LoginScreenState extends ConsumerState onPressed: () { debugPrint("[Auth] Polling canceled by user"); Navigator.of(context).pop(); - }, - child: const Text("취소") + }, + child: const Text("취소"), ) ], ), @@ -358,7 +491,10 @@ class _LoginScreenState extends ConsumerState ); // 2. Poll Backend manually - _pollForSession(pendingRef); + final initialInterval = (interval is int && interval > 0) + ? Duration(seconds: interval) + : const Duration(seconds: 2); + _pollForSession(pendingRef, initialInterval: initialInterval); } } catch (e) { debugPrint("[Auth] Initialization failed: $e"); @@ -371,18 +507,39 @@ class _LoginScreenState extends ConsumerState } } - Future _pollForSession(String pendingRef) async { + Future _pollForSession(String pendingRef, {Duration? initialInterval}) async { int attempts = 0; const maxAttempts = 60; // 2 minutes + var pollInterval = initialInterval ?? const Duration(seconds: 2); debugPrint("[Auth] Starting poll for ref: $pendingRef"); while (attempts < maxAttempts && mounted) { - await Future.delayed(const Duration(seconds: 2)); + await Future.delayed(pollInterval); attempts++; try { final result = await AuthProxyService.pollEnchantedLink(pendingRef); + if (result['error'] == 'slow_down') { + final interval = result['interval']; + if (interval is int && interval > 0) { + pollInterval = Duration(seconds: interval); + } else { + pollInterval += const Duration(seconds: 1); + } + continue; + } + if (result['error'] == 'authorization_pending') { + continue; + } + if (result['error'] == 'expired_token') { + if (mounted) { + Navigator.of(context).pop(); // Close Polling Dialog + _showError("Login timed out."); + } + return; + } + if (result['status'] == 'ok') { final jwt = result['sessionJwt']; if (jwt != null) { @@ -452,23 +609,31 @@ class _LoginScreenState extends ConsumerState } } - void _onLoginSuccess(String token) async { + void _onLoginSuccess(String token, {String? provider}) async { if (!mounted) return; _logTokenDetails(token); final userId = _getUserIdFromJwt(token); + final providerName = provider ?? AuthTokenStore.getProvider(); + final isJwt = token.split('.').length == 3; + final isOry = (providerName ?? '').toLowerCase().contains('ory') || !isJwt; + + AuthTokenStore.setToken(token, provider: providerName); + AuthTokenStore.clearPendingProvider(); // [New] 로그인 성공 직후 백엔드에서 전체 프로필 정보를 가져와 세션 업데이트 try { - // 임시 세션 생성 (API 호출을 위해) - final tempUser = DescopeUser(userId, [], 0, 'User', null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], []); - final tempSession = DescopeSession.fromJwt(token, token, tempUser); - Descope.sessionManager.manageSession(tempSession); + if (!isOry) { + // 임시 세션 생성 (API 호출을 위해) + final tempUser = DescopeUser(userId, [], 0, 'User', null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], []); + final tempSession = DescopeSession.fromJwt(token, token, tempUser); + Descope.sessionManager.manageSession(tempSession); + } // 백엔드 GetMe 호출 (프로필 노티파이어 사용) final profile = await ref.read(profileProvider.notifier).loadProfile(); - if (profile != null) { + if (profile != null && !isOry) { // 실제 정보로 세션 유저 정보 교체 final realUser = DescopeUser( userId, [], 0, profile.name, null, profile.email, false, profile.phone, false, {}, '', '', '', false, 'enabled', [], [], [], @@ -480,14 +645,6 @@ class _LoginScreenState extends ConsumerState debugPrint("[Auth] Failed to pre-fetch profile: $e"); } - // Record Audit Log - AuditService.logEvent( - userId: userId, - eventType: "LOGIN_SUCCESS", - status: "SUCCESS", - details: "User logged in via Baron SSO", - ); - // 1. Handle Popup Flow if (WebAuthIntegration.isPopup()) { debugPrint("[Auth] Popup detected. Notifying opener and attempting to close."); @@ -680,6 +837,14 @@ class _LoginScreenState extends ConsumerState ), ), const SizedBox(height: 8), + if (_qrUserCode != null) ...[ + Text( + "코드: $_qrUserCode", + textAlign: TextAlign.center, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + ], const Text( "모바일 앱으로 스캔하세요", textAlign: TextAlign.center, @@ -727,4 +892,4 @@ class _LoginScreenState extends ConsumerState ), ); } -} \ No newline at end of file +} diff --git a/userfront/lib/features/auth/presentation/reset_password_screen.dart b/userfront/lib/features/auth/presentation/reset_password_screen.dart index ae29d76b..ae9fad26 100644 --- a/userfront/lib/features/auth/presentation/reset_password_screen.dart +++ b/userfront/lib/features/auth/presentation/reset_password_screen.dart @@ -110,13 +110,17 @@ class _ResetPasswordScreenState extends State { if (_isPolicyLoading) { return "비밀번호 정책을 불러오는 중입니다..."; } - final minLength = (_policy?['minLength'] as int?) ?? 8; + final minLength = (_policy?['minLength'] as int?) ?? 12; + final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; final requiresLower = _policy?['lowercase'] ?? true; - final requiresUpper = _policy?['uppercase'] ?? true; + final requiresUpper = _policy?['uppercase'] ?? false; final requiresNumber = _policy?['number'] ?? true; final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; final parts = ["최소 ${minLength}자 이상"]; + if (minTypes > 0) { + parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상"); + } if (requiresLower) parts.add("소문자 1개 이상"); if (requiresUpper) parts.add("대문자 1개 이상"); if (requiresNumber) parts.add("숫자 1개 이상"); @@ -182,20 +186,35 @@ class _ResetPasswordScreenState extends State { if (val.isEmpty) { return '비밀번호를 입력해주세요.'; } - final minLength = (_policy?['minLength'] as int?) ?? 8; + final minLength = (_policy?['minLength'] as int?) ?? 12; if (val.length < minLength) { return '비밀번호는 최소 $minLength자 이상이어야 합니다.'; } - if ((_policy?['lowercase'] ?? true) && !RegExp(r'(?=.*[a-z])').hasMatch(val)) { + final hasLower = RegExp(r'[a-z]').hasMatch(val); + final hasUpper = RegExp(r'[A-Z]').hasMatch(val); + final hasNumber = RegExp(r'[0-9]').hasMatch(val); + final hasSymbol = RegExp(r'[\W_]').hasMatch(val); + int typeCount = 0; + if (hasLower) typeCount++; + if (hasUpper) typeCount++; + if (hasNumber) typeCount++; + if (hasSymbol) typeCount++; + + final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; + if (minTypes > 0 && typeCount < minTypes) { + return '비밀번호는 영문 대/소문자/숫자/특수문자 중 $minTypes가지 이상 포함해야 합니다.'; + } + + if ((_policy?['lowercase'] ?? true) && !hasLower) { return '최소 1개 이상의 소문자를 포함해야 합니다.'; } - if ((_policy?['uppercase'] ?? true) && !RegExp(r'(?=.*[A-Z])').hasMatch(val)) { + if ((_policy?['uppercase'] ?? false) && !hasUpper) { return '최소 1개 이상의 대문자를 포함해야 합니다.'; } - if ((_policy?['number'] ?? true) && !RegExp(r'(?=.*\d)').hasMatch(val)) { + if ((_policy?['number'] ?? true) && !hasNumber) { return '최소 1개 이상의 숫자를 포함해야 합니다.'; } - if ((_policy?['nonAlphanumeric'] ?? true) && !RegExp(r'(?=.*[\W_])').hasMatch(val)) { + if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) { return '최소 1개 이상의 특수문자를 포함해야 합니다.'; } return null; diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 82cfb6b7..7d2a3da4 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -781,36 +781,47 @@ class _SignupScreenState extends State { if (_isPolicyLoading) { return "비밀번호 정책을 불러오는 중입니다..."; } - final minLength = (_policy?['minLength'] as int?) ?? 8; + final minLength = (_policy?['minLength'] as int?) ?? 12; + final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; final requiresLower = _policy?['lowercase'] ?? true; - final requiresUpper = _policy?['uppercase'] ?? true; + final requiresUpper = _policy?['uppercase'] ?? false; final requiresNumber = _policy?['number'] ?? true; final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; final parts = ["최소 $minLength자 이상"]; + if (minTypes > 0) { + parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상"); + } if (requiresUpper) parts.add("대문자"); if (requiresLower) parts.add("소문자"); if (requiresNumber) parts.add("숫자"); if (requiresSymbol) parts.add("특수문자"); - return "보안 정책: ${parts.join(', ')}를 각각 최소 1자 이상 포함해야 합니다."; + return "보안 정책: ${parts.join(', ')}"; } Widget _buildStepPassword() { String p = _passwordController.text; // Default Policy Fallback - final minLength = (_policy?['minLength'] as int?) ?? 8; + final minLength = (_policy?['minLength'] as int?) ?? 12; + final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; final requiresLower = _policy?['lowercase'] ?? true; - final requiresUpper = _policy?['uppercase'] ?? true; + final requiresUpper = _policy?['uppercase'] ?? false; final requiresNumber = _policy?['number'] ?? true; final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; bool hasLength = p.length >= minLength; - bool hasUpper = !requiresUpper || p.contains(RegExp(r'[A-Z]')); - bool hasLower = !requiresLower || p.contains(RegExp(r'[a-z]')); - bool hasDigit = !requiresNumber || p.contains(RegExp(r'[0-9]')); - bool hasSpecial = !requiresSymbol || p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')); + bool hasUpper = p.contains(RegExp(r'[A-Z]')); + bool hasLower = p.contains(RegExp(r'[a-z]')); + bool hasDigit = p.contains(RegExp(r'[0-9]')); + bool hasSpecial = p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')); + int typeCount = 0; + if (hasUpper) typeCount++; + if (hasLower) typeCount++; + if (hasDigit) typeCount++; + if (hasSpecial) typeCount++; + bool hasTypeCount = minTypes <= 0 || typeCount >= minTypes; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -850,6 +861,7 @@ class _SignupScreenState extends State { spacing: 10, children: [ _cryptoCheck('$minLength자 이상', hasLength), + if (minTypes > 0) _cryptoCheck('문자 유형 ${minTypes}가지 이상', hasTypeCount), if (requiresUpper) _cryptoCheck('대문자', hasUpper), if (requiresLower) _cryptoCheck('소문자', hasLower), if (requiresNumber) _cryptoCheck('숫자', hasDigit), @@ -967,4 +979,4 @@ class _SignupScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 3c5eb135..da0cf4c4 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -1,15 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../../../core/notifiers/auth_notifier.dart'; +import '../../../../core/services/auth_token_store.dart'; +import '../../profile/domain/notifiers/profile_notifier.dart'; -class DashboardScreen extends StatelessWidget { +class DashboardScreen extends ConsumerWidget { const DashboardScreen({super.key}); Future _logout(BuildContext context) async { // ignore: use_build_context_synchronously Descope.sessionManager.clearSession(); + AuthTokenStore.clear(); AuthNotifier.instance.notify(); } @@ -18,9 +22,16 @@ class DashboardScreen extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final profile = ref.watch(profileProvider).value; final user = Descope.sessionManager.session?.user; - final userName = user?.name ?? user?.email ?? user?.phone ?? 'User'; + final userName = user?.name ?? + user?.email ?? + user?.phone ?? + profile?.name ?? + profile?.email ?? + profile?.phone ?? + 'User'; return Scaffold( backgroundColor: Colors.grey[50], diff --git a/userfront/lib/features/profile/data/repositories/profile_repository.dart b/userfront/lib/features/profile/data/repositories/profile_repository.dart index 005df473..4b3ed55c 100644 --- a/userfront/lib/features/profile/data/repositories/profile_repository.dart +++ b/userfront/lib/features/profile/data/repositories/profile_repository.dart @@ -1,8 +1,8 @@ import 'dart:convert'; -import 'package:http/http.dart' as http; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../models/user_profile_model.dart'; -import 'package:descope/descope.dart'; +import '../../../../core/services/auth_token_store.dart'; +import '../../../../core/services/http_client.dart'; class ProfileRepository { static String _envOrDefault(String key, String fallback) { @@ -16,22 +16,26 @@ class ProfileRepository { // Helper to get session token static Future _getToken() async { - final session = await Descope.sessionManager.session; - return session?.sessionToken.jwt; + return AuthTokenStore.getToken(); } Future getMyProfile() async { final token = await _getToken(); - if (token == null) throw Exception('No active session'); + final useCookie = AuthTokenStore.usesCookie(); + if (token == null && !useCookie) { + throw Exception('No active session'); + } final url = Uri.parse('$_baseUrl/api/v1/user/me'); - final response = await http.get( - url, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $token', - }, - ); + final client = createHttpClient(withCredentials: useCookie); + final headers = { + 'Content-Type': 'application/json', + }; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.get(url, headers: headers); + client.close(); if (response.statusCode == 200) { return UserProfile.fromJson(jsonDecode(response.body)); @@ -46,21 +50,27 @@ class ProfileRepository { required String department, }) async { final token = await _getToken(); - if (token == null) throw Exception('No active session'); + final useCookie = AuthTokenStore.usesCookie(); + if (token == null && !useCookie) throw Exception('No active session'); final url = Uri.parse('$_baseUrl/api/v1/user/me'); - final response = await http.put( + final client = createHttpClient(withCredentials: useCookie); + final headers = { + 'Content-Type': 'application/json', + }; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.put( url, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $token', - }, + headers: headers, body: jsonEncode({ 'name': name, 'phone': phone, 'department': department, }), ); + client.close(); if (response.statusCode != 200) { throw Exception('Failed to update profile: ${response.body}'); @@ -69,17 +79,23 @@ class ProfileRepository { Future sendUpdateCode(String phone) async { final token = await _getToken(); - if (token == null) throw Exception('No active session'); + final useCookie = AuthTokenStore.usesCookie(); + if (token == null && !useCookie) throw Exception('No active session'); final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code'); - final response = await http.post( + final client = createHttpClient(withCredentials: useCookie); + final headers = { + 'Content-Type': 'application/json', + }; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.post( url, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $token', - }, + headers: headers, body: jsonEncode({'phone': phone}), ); + client.close(); if (response.statusCode != 200) { throw Exception('인증번호 전송 실패: ${response.body}'); @@ -88,17 +104,23 @@ class ProfileRepository { Future verifyUpdateCode(String phone, String code) async { final token = await _getToken(); - if (token == null) throw Exception('No active session'); + final useCookie = AuthTokenStore.usesCookie(); + if (token == null && !useCookie) throw Exception('No active session'); final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code'); - final response = await http.post( + final client = createHttpClient(withCredentials: useCookie); + final headers = { + 'Content-Type': 'application/json', + }; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.post( url, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $token', - }, + headers: headers, body: jsonEncode({'phone': phone, 'code': code}), ); + client.close(); if (response.statusCode != 200) { throw Exception('인증 실패: ${response.body}'); diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 8aa98188..5a4b23e7 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -17,6 +17,7 @@ import 'features/admin/presentation/user_management_screen.dart'; import 'features/profile/presentation/pages/profile_page.dart'; import 'features/profile/presentation/pages/edit_profile_page.dart'; import 'core/services/auth_proxy_service.dart'; +import 'core/services/auth_token_store.dart'; import 'core/services/logger_service.dart'; import 'core/notifiers/auth_notifier.dart'; import 'package:logging/logging.dart'; @@ -108,6 +109,13 @@ final _router = GoRouter( return const SignupScreen(); }, ), + GoRoute( + path: '/verify', + builder: (context, state) { + _routerLogger.info("Navigating to /verify (query)"); + return const LoginScreen(); + }, + ), GoRoute( path: '/verify/:token', builder: (context, state) { @@ -157,13 +165,17 @@ final _router = GoRouter( ), ], redirect: (context, state) { - final isLoggedIn = + final hasDescopeSession = Descope.sessionManager.session?.refreshToken?.isExpired == false; + final hasStoredToken = AuthTokenStore.getToken() != null; + final hasCookieSession = AuthTokenStore.usesCookie(); + final isLoggedIn = hasDescopeSession || hasStoredToken || hasCookieSession; final path = state.uri.path; // Public paths that don't require login final isPublicPath = path == '/signin' || path == '/signup' || + path == '/verify' || path.startsWith('/verify/') || path == '/approve' || path == '/forgot-password' ||