diff --git a/.env.sample b/.env.sample
index 3a9baa7d..bec6f5aa 100644
--- a/.env.sample
+++ b/.env.sample
@@ -21,11 +21,13 @@ DB_NAME=baron_sso
# --- Backend Configuration ---
# Must be 32 bytes. Generate with `openssl rand -hex 32`
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
-REDIS_ADDR=redis:6379
+REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
# 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 ---
NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
@@ -44,4 +46,6 @@ ADMIN_PASSWORD=admin
# --- URLs for Proxy/Handoff ---
FRONTEND_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용)
-BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소
\ No newline at end of file
+BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소
+
+IDP_PROVIDER=descopse, hydra ...
diff --git a/.gemini/settings.json b/.gemini/settings.json
new file mode 100644
index 00000000..b9cfd51a
--- /dev/null
+++ b/.gemini/settings.json
@@ -0,0 +1,5 @@
+{
+ "general": {
+ "previewFeatures": true
+ }
+}
\ No newline at end of file
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index fc09f2a7..a6cc84a1 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -3,6 +3,7 @@ package main
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/handler"
+ "baron-sso-backend/internal/idp"
"baron-sso-backend/internal/logger"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
@@ -73,14 +74,12 @@ func main() {
)
// --- Fail-Fast Schema Validation ---
- // Initialize the IDP Provider (Descope)
- descopeProjectID := getEnv("DESCOPE_PROJECT_ID", "")
- descopeManagementKey := getEnv("DESCOPE_MANAGEMENT_KEY", "")
-
- // We create a provider instance to check schema compatibility.
- // This ensures that our BrokerUser model requirements (e.g. custom attributes)
- // are supported by the configured IDP.
- idpProvider := service.NewDescopeProvider(descopeProjectID, descopeManagementKey)
+ // 팩토리를 사용하여 IDP 공급자를 초기화합니다.
+ idpProvider, err := idp.InitializeProvider()
+ if err != nil {
+ slog.Error("❌ [CRITICAL] Failed to initialize IDP Provider", "error", err)
+ os.Exit(1)
+ }
if err := validator.ValidateIDPCompatibility(domain.BrokerUser{}, idpProvider); err != nil {
slog.Error("❌ [CRITICAL] Broker Schema Mismatch",
@@ -167,8 +166,21 @@ func main() {
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS",
}))
+
+ // Ensure COOKIE_SECRET is exactly 32 bytes for AES-256
+ cookieSecret := getEnv("COOKIE_SECRET", "secret-key-must-be-32-bytes-long!")
+ if len(cookieSecret) != 32 {
+ slog.Warn("COOKIE_SECRET length is not 32 bytes. Adjusting...", "original_length", len(cookieSecret))
+ if len(cookieSecret) > 32 {
+ cookieSecret = cookieSecret[:32]
+ } else {
+ // Pad with '0' if too short
+ cookieSecret = fmt.Sprintf("%-32s", cookieSecret)
+ }
+ }
+
app.Use(encryptcookie.New(encryptcookie.Config{
- Key: getEnv("COOKIE_SECRET", "secret-key-must-be-32-bytes-long!"),
+ Key: cookieSecret,
}))
// Routes
@@ -228,6 +240,14 @@ 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("/password/login", authHandler.PasswordLogin)
+ auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
+ // [Changed] Use Interstitial Page for GET to prevent Scanner consumption
+ auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
+ // [Added] Use POST for actual verification triggered by the user
+ auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken)
+ auth.Post("/password/reset/complete", authHandler.CompletePasswordReset)
+ auth.Get("/password/policy", authHandler.GetPasswordPolicy)
auth.Post("/sms", authHandler.SendSms)
auth.Post("/verify-sms", authHandler.VerifySms)
auth.Post("/qr/init", authHandler.InitQRLogin)
diff --git a/backend/go.mod b/backend/go.mod
index 6e332a5f..4fa90ee1 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -4,30 +4,31 @@ go 1.25.4
require (
github.com/ClickHouse/clickhouse-go/v2 v2.42.0
- github.com/descope/go-sdk v1.6.23
+ github.com/aws/aws-sdk-go-v2 v1.41.1
+ github.com/aws/aws-sdk-go-v2/config v1.32.7
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.7
+ github.com/aws/aws-sdk-go-v2/service/ses v1.34.18
+ github.com/bwmarrin/snowflake v0.3.0
+ github.com/descope/go-sdk v1.7.0
github.com/go-redis/redis/v8 v8.11.5
github.com/gofiber/fiber/v2 v2.52.10
+ github.com/google/uuid v1.6.0
)
require (
github.com/ClickHouse/ch-go v0.69.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
- github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
- github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect
- github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
- github.com/aws/aws-sdk-go-v2/service/ses v1.34.18 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
- github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
diff --git a/backend/go.sum b/backend/go.sum
index 40ee75e9..4ddc9e4c 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -43,8 +43,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
-github.com/descope/go-sdk v1.6.23 h1:YO283ULq8O/6aCNLbqkG+QBaYnNMxf/mHSb4pmWe8u4=
-github.com/descope/go-sdk v1.6.23/go.mod h1:lCwCgYOfrgjANMsR2BVe1yfX0Siwd2NjNAig0myWZqY=
+github.com/descope/go-sdk v1.7.0 h1:DIRmnA4Q8TDtWdGJ9z0I11+AWMrzyNiiozFH557LrgQ=
+github.com/descope/go-sdk v1.7.0/go.mod h1:lCwCgYOfrgjANMsR2BVe1yfX0Siwd2NjNAig0myWZqY=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go
index 445e5ff6..d6ebae01 100644
--- a/backend/internal/domain/auth_models.go
+++ b/backend/internal/domain/auth_models.go
@@ -59,3 +59,14 @@ type SignupRequest struct {
Department string `json:"department"`
TermsAccepted bool `json:"termsAccepted"`
}
+
+// PasswordResetInitiateRequest is the request body for initiating a password reset.
+type PasswordResetInitiateRequest struct {
+ LoginID string `json:"loginId"`
+}
+
+// PasswordResetCompleteRequest is the request body for completing a password reset.
+type PasswordResetCompleteRequest struct {
+ LoginID string `json:"loginId"`
+ NewPassword string `json:"newPassword"`
+}
\ No newline at end of file
diff --git a/backend/internal/domain/idp_models.go b/backend/internal/domain/idp_models.go
index 5400ce36..29283318 100644
--- a/backend/internal/domain/idp_models.go
+++ b/backend/internal/domain/idp_models.go
@@ -1,5 +1,10 @@
package domain
+import (
+ "net/http"
+ "time"
+)
+
// 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 {
@@ -19,10 +24,25 @@ type IDPMetadata struct {
SupportedFields []string
}
+// Token represents a session or refresh token.
+type Token struct {
+ JWT string
+ Expiration time.Time
+}
+
+// AuthInfo contains authentication information after a successful login.
+type AuthInfo struct {
+ SessionToken *Token
+ RefreshToken *Token
+}
+
// IdentityProvider is the interface that all IDP adapters must implement.
type IdentityProvider interface {
Name() string
// GetMetadata returns the schema support information for this IDP.
// This is used for startup-time validation.
GetMetadata() (*IDPMetadata, error)
+ InitiatePasswordReset(loginID, redirectUrl string) error
+ VerifyPasswordResetToken(token string) (*AuthInfo, error)
+ UpdateUserPassword(loginID, newPassword string, r *http.Request) error
}
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index 18f61d7c..a835d945 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -2,6 +2,8 @@ package handler
import (
"baron-sso-backend/internal/domain"
+ "baron-sso-backend/internal/idp"
+ "baron-sso-backend/internal/logger"
"baron-sso-backend/internal/service"
"context"
crand "crypto/rand"
@@ -11,6 +13,8 @@ import (
"log/slog"
"math/rand"
"os"
+ "regexp"
+ "strconv"
"strings"
"time"
@@ -37,6 +41,8 @@ const (
maxSignupFailures = 5
emailCodeTTL = 5 * time.Minute
smsCodeTTL = 3 * time.Minute
+ prefixPwdResetToken = "pwdreset_token:"
+ pwdResetExpiration = 15 * time.Minute
)
type AuthHandler struct {
@@ -45,6 +51,7 @@ type AuthHandler struct {
EmailService domain.EmailService
RedisService *service.RedisService
DescopeClient *client.DescopeClient
+ IdpProvider domain.IdentityProvider
}
type signupState struct {
@@ -79,12 +86,20 @@ func NewAuthHandler(redisService *service.RedisService) *AuthHandler {
}
}
+ idpProvider, err := idp.InitializeProvider()
+ if err != nil {
+ slog.Error("Failed to initialize IDP Provider", "error", err)
+ // Depending on the application's needs, you might want to panic here
+ // if the IDP provider is essential for the application to run.
+ }
+
return &AuthHandler{
ProjectID: projectID,
SmsService: service.NewSmsService(),
EmailService: service.NewEmailService(),
RedisService: redisService,
DescopeClient: descopeClient,
+ IdpProvider: idpProvider,
}
}
@@ -405,6 +420,26 @@ 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 frontend 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"})
+ }
+
+ policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background())
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ return c.JSON(fiber.Map{
+ "minLength": policy.MinLength,
+ "lowercase": policy.Lowercase,
+ "uppercase": policy.Uppercase,
+ "number": policy.Number,
+ "nonAlphanumeric": policy.NonAlphanumeric,
+ })
+}
+
// SendSms sends a verification code via SMS. (Restored for completeness)
func (h *AuthHandler) SendSms(c *fiber.Ctx) error {
var req domain.SmsRequest
@@ -462,8 +497,8 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
loginID = strings.ReplaceAll(loginID, " ", "")
// Generate secure tokens
- token := GenerateSecureToken(3)
- pendingRef := GenerateSecureToken(3)
+ token := GenerateSecureToken(32)
+ pendingRef := GenerateSecureToken(16)
slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef)
@@ -473,6 +508,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
// Generate Link
frontendURL := os.Getenv("FRONTEND_URL")
+ slog.Info("[Enchanted] Read FRONTEND_URL", "url", frontendURL)
if frontendURL == "" {
frontendURL = "http://sso.hmac.kr"
}
@@ -667,6 +703,433 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
})
}
+// PasswordLogin - Authenticate a user with login ID and password.
+func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
+ startTime := time.Now()
+ ale := logger.NewAuditLogEntry(c, "login")
+ ale.Operation = "Auth.Password().SignIn"
+
+ var req struct {
+ LoginID string `json:"loginId"`
+ Password string `json:"password"`
+ }
+
+ if err := c.BodyParser(&req); err != nil {
+ ale.Status = fiber.StatusBadRequest
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = err.Error()
+ ale.Log(slog.LevelError, "Body parse error")
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
+ }
+
+ loginID := strings.TrimSpace(req.LoginID)
+ ale.LoginIDs["loginId"] = req.LoginID // 원문
+ ale.LoginIDs["loginId_normalized"] = loginID
+ ale.NewPassword = req.Password // For test only, logging password (sensitive)
+
+ ale.Log(slog.LevelInfo, "Attempting to login")
+
+ // Validate password complexity before sending to Descope
+ password := req.Password
+ if len(password) < 8 {
+ ale.Status = fiber.StatusBadRequest
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "Password must be at least 8 characters long"
+ ale.Log(slog.LevelWarn, "Validation failed: password too short")
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must be at least 8 characters long"})
+ }
+ if ok, _ := regexp.MatchString(`[a-z]`, password); !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 ok, _ := regexp.MatchString(`[A-Z]`, password); !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 ok, _ := regexp.MatchString(`[0-9]`, password); !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 ok, _ := regexp.MatchString(`[\W_]`, password); !ok {
+ ale.Status = fiber.StatusBadRequest
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "Password must contain at least one special character"
+ ale.Log(slog.LevelWarn, "Validation failed: no special character")
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one special character"})
+ }
+
+ if h.DescopeClient == nil {
+ ale.Status = fiber.StatusInternalServerError
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "Descope Client is nil!"
+ ale.Log(slog.LevelError, "Descope Client is nil")
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
+ }
+
+ // Sign in using Descope
+ authInfo, err := h.DescopeClient.Auth.Password().SignIn(context.Background(), req.LoginID, req.Password, nil)
+ if err != nil {
+ ale.Status = fiber.StatusUnauthorized
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = err.Error()
+ ale.Log(slog.LevelWarn, "Descope sign-in failed")
+ // It's good practice to return a generic error message for security.
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
+ }
+
+ ale.Status = fiber.StatusOK
+ ale.LatencyMs = time.Since(startTime)
+ ale.SessionJwt = authInfo.SessionToken.JWT
+ ale.Log(slog.LevelInfo, "Login successful")
+ return c.JSON(fiber.Map{
+ "sessionJwt": authInfo.SessionToken.JWT,
+ "status": "ok",
+ })
+}
+
+// InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 Descope를 통해 이메일 또는 SMS를 보냅니다.
+func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
+ startTime := time.Now()
+ ale := logger.NewAuditLogEntry(c, "initiate")
+
+ var req domain.PasswordResetInitiateRequest
+ if err := c.BodyParser(&req); err != nil {
+ ale.Status = fiber.StatusBadRequest
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = err.Error()
+ ale.Log(slog.LevelError, "Body parse error")
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
+ }
+
+ loginID := strings.TrimSpace(req.LoginID)
+ ale.LoginIDs["loginId"] = req.LoginID // 원문
+ ale.LoginIDs["loginId_normalized"] = loginID
+
+ if loginID == "" {
+ ale.Status = fiber.StatusBadRequest
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "Login ID is required"
+ ale.Log(slog.LevelWarn, "Login ID missing")
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID is required"})
+ }
+
+ if h.IdpProvider == nil {
+ ale.Status = fiber.StatusInternalServerError
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "IDP Provider is not initialized"
+ ale.Log(slog.LevelError, "IDP Provider is not initialized")
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
+ }
+
+ frontendURL := os.Getenv("FRONTEND_URL")
+ if frontendURL == "" {
+ ale.Status = fiber.StatusInternalServerError
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "FRONTEND_URL is not set"
+ ale.Log(slog.LevelError, "FRONTEND_URL is not set")
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "FRONTEND_URL environment variable is not set"})
+ }
+ // [Changed] Point to Backend API for verification (which then redirects to Frontend)
+ redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", frontendURL)
+ ale.RedirectTo = redirectURL
+
+ // 내부 토큰 발급 + 우리 채널로 전송
+ resetToken := GenerateSecureToken(32)
+ if resetToken == "" {
+ ale.Status = fiber.StatusInternalServerError
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "Failed to generate reset token"
+ ale.Log(slog.LevelError, "Failed to generate reset token")
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate reset token"})
+ }
+
+ if err := h.RedisService.Set(prefixPwdResetToken+resetToken, loginID, pwdResetExpiration); err != nil {
+ ale.Status = fiber.StatusInternalServerError
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = err.Error()
+ ale.Log(slog.LevelError, "Failed to store reset token in Redis")
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to store reset token"})
+ }
+
+ resetLink := fmt.Sprintf("%s/reset-password?token=%s", frontendURL, resetToken)
+ ale.RedirectTo = resetLink
+ ale.Operation = "SendPasswordReset"
+ ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
+
+ if strings.Contains(loginID, "@") {
+ if h.EmailService == nil {
+ ale.Status = fiber.StatusInternalServerError
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "Email service not configured"
+ ale.Log(slog.LevelError, "Email service not configured")
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
+ }
+ subject := "[Baron SSO] 비밀번호 재설정"
+ body := fmt.Sprintf(`
+
+
Baron SSO 비밀번호 재설정
+
아래 버튼을 클릭해 비밀번호 재설정을 진행해 주세요. 링크는 15분간 유효합니다.
+
+
요청하지 않았다면 이 메일을 무시하세요.
+
+ `, resetLink)
+ if err := h.EmailService.SendEmail(loginID, subject, body); err != nil {
+ ale.Status = fiber.StatusInternalServerError
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = err.Error()
+ ale.Log(slog.LevelError, "Failed to send reset email", slog.String("loginId", loginID))
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset email"})
+ }
+ } else {
+ if err := h.SmsService.SendSms(loginID, fmt.Sprintf("[Baron SSO] 비밀번호 재설정 링크: %s", resetLink)); err != nil {
+ ale.Status = fiber.StatusInternalServerError
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = err.Error()
+ ale.Log(slog.LevelError, "Failed to send reset SMS", slog.String("loginId", loginID))
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset SMS"})
+ }
+ }
+
+ ale.Status = fiber.StatusOK
+ ale.LatencyMs = time.Since(startTime)
+ ale.Log(slog.LevelInfo, "Password reset link sent successfully (internal token)")
+ return c.JSON(fiber.Map{"message": "If an account with that login ID exists, a reset link has been sent."})
+}
+
+// VerifyPasswordResetPage - Serves an interstitial page to prevent link scanners from consuming the token.
+func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error {
+ token := c.Query("token")
+ if token == "" {
+ token = c.Query("t")
+ }
+
+ if token == "" {
+ return c.Status(fiber.StatusBadRequest).SendString("Missing token")
+ }
+
+ // Simple HTML page with a form to trigger the POST request
+ html := fmt.Sprintf(`
+
+
+
+ Baron SSO - 비밀번호 재설정
+
+
+
+
+
+
비밀번호 재설정
+
아래 버튼을 클릭하여 비밀번호 재설정을 계속해 주세요.
+
+
+
+
+ `, token)
+
+ c.Set("Content-Type", "text/html; charset=utf-8")
+ return c.SendString(html)
+}
+
+// ProcessPasswordResetToken - Handles the POST request from the interstitial page.
+// Verifies the token, sets the refresh token cookie, and redirects to the frontend.
+func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
+ startTime := time.Now()
+ ale := logger.NewAuditLogEntry(c, "verify")
+ ale.Operation = "Verify"
+
+ // Token comes from Form Body in POST or query
+ token := c.FormValue("token")
+ if token == "" {
+ token = c.Query("token")
+ if token == "" {
+ token = c.Query("t")
+ }
+ }
+ ale.Token = token
+
+ if token == "" {
+ ale.Status = fiber.StatusBadRequest
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "Missing token"
+ ale.Log(slog.LevelWarn, "Missing token in request")
+ return c.Status(fiber.StatusBadRequest).SendString("Missing token")
+ }
+
+ loginID, err := h.RedisService.Get(prefixPwdResetToken + token)
+ if err != nil || loginID == "" {
+ ale.Status = fiber.StatusUnauthorized
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "Invalid or expired reset token"
+ ale.Log(slog.LevelWarn, "Reset token invalid or expired")
+ return c.Status(fiber.StatusUnauthorized).SendString("Invalid or expired token")
+ }
+
+ ale.LoginIDs["loginId"] = loginID
+ ale.LoginIDs["loginId_normalized"] = loginID
+
+ redirectURL := fmt.Sprintf("%s/reset-password?loginId=%s&token=%s",
+ os.Getenv("FRONTEND_URL"),
+ loginID,
+ token,
+ )
+
+ ale.RedirectTo = redirectURL
+ ale.Status = fiber.StatusFound
+ ale.LatencyMs = time.Since(startTime)
+ ale.Log(slog.LevelInfo, "Token verified, redirecting to frontend")
+
+ return c.Redirect(redirectURL)
+}
+
+// CompletePasswordReset - 제공된 loginID와 새 비밀번호로 Descope에 비밀번호를 업데이트합니다.
+// 리프레시 토큰은 요청 쿠키에 포함되어 있어야 합니다.
+func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
+ startTime := time.Now()
+ ale := logger.NewAuditLogEntry(c, "complete")
+ ale.Operation = "UpdateUserPassword"
+
+ var req struct {
+ NewPassword string `json:"newPassword"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ ale.Status = fiber.StatusBadRequest
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = err.Error()
+ ale.Log(slog.LevelError, "Body parse error")
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
+ }
+
+ // loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다.
+ loginID := c.Query("loginId")
+ resetToken := c.Query("token")
+ if loginID == "" && resetToken != "" {
+ if val, err := h.RedisService.Get(prefixPwdResetToken + resetToken); err == nil && val != "" {
+ loginID = val
+ ale.Token = resetToken
+ }
+ }
+
+ ale.LoginIDs["loginId"] = loginID
+ ale.RequestBody = fmt.Sprintf("{\"newPassword\": \"%s\"}", req.NewPassword) // Log request body (for test only)
+ ale.NewPassword = req.NewPassword // Log new password (for test only)
+
+ // Request cookie logging (minimal)
+ if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" {
+ ale.Headers["Request-Cookie-Header"] = cookieHeader
+ if dsrfCookie := c.Cookies("DSRF"); dsrfCookie != "" {
+ ale.ParsedCookieDSRF = dsrfCookie
+ ale.HasCookieDSRF = true
+ } else {
+ ale.HasCookieDSRF = false
+ }
+ }
+
+ if loginID == "" || req.NewPassword == "" {
+ ale.Status = fiber.StatusBadRequest
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "Login ID and new password are required"
+ ale.Log(slog.LevelWarn, "Login ID or new password missing")
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID and new password are required"})
+ }
+
+ // 디버깅을 위해 요청된 새 비밀번호를 로그로 출력
+ ale.Log(slog.LevelInfo, "Received new password for reset")
+
+ // Validate password complexity
+ if len(req.NewPassword) < 8 {
+ ale.Status = fiber.StatusBadRequest
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "Password must be at least 8 characters long"
+ ale.Log(slog.LevelWarn, "Validation failed: password too short")
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must be at least 8 characters long"})
+ }
+ 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 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 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 ok, _ := regexp.MatchString(`[\W_]`, req.NewPassword); !ok {
+ ale.Status = fiber.StatusBadRequest
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "Password must contain at least one special character"
+ ale.Log(slog.LevelWarn, "Validation failed: no special character")
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one special character"})
+ }
+
+ ale.Log(slog.LevelInfo, "Attempting to update password via Descope Auth API")
+
+ // Descope Management API를 통해 비밀번호 업데이트
+ if h.DescopeClient == nil {
+ ale.Status = fiber.StatusInternalServerError
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = "Descope Client is nil!"
+ ale.Log(slog.LevelError, "Descope Client is nil")
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
+ }
+
+ if err := h.DescopeClient.Management.User().SetActivePassword(context.Background(), loginID, req.NewPassword); err != nil {
+ // Descope 에러 상세를 감사 로그에 포함
+ if de, ok := err.(*descope.Error); ok {
+ if statusRaw, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok {
+ if statusInt, convErr := strconv.Atoi(fmt.Sprintf("%v", statusRaw)); convErr == nil {
+ ale.DescopeStatus = statusInt
+ }
+ }
+ ale.DescopeBody = de.Message
+ }
+ ale.Status = fiber.StatusInternalServerError
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = err.Error()
+ ale.Log(slog.LevelError, "Failed to update password via IDP")
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update password"})
+ }
+
+ ale.Status = fiber.StatusOK
+ ale.LatencyMs = time.Since(startTime)
+ ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
+ if resetToken != "" {
+ _ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
+ }
+ return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
+}
+
// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
pendingRef := GenerateSecureToken(16)
diff --git a/backend/internal/handler/auth_handler_test.go b/backend/internal/handler/auth_handler_test.go
new file mode 100644
index 00000000..746b82eb
--- /dev/null
+++ b/backend/internal/handler/auth_handler_test.go
@@ -0,0 +1,108 @@
+package handler
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+// helper to build a Fiber app with the handler route mounted.
+func newTestApp(h *AuthHandler) *fiber.App {
+ app := fiber.New()
+ app.Post("/api/v1/auth/password/reset/complete", h.CompletePasswordReset)
+ return app
+}
+
+func TestCompletePasswordReset_MissingLoginID(t *testing.T) {
+ h := &AuthHandler{}
+ app := newTestApp(h)
+
+ body, _ := json.Marshal(map[string]string{
+ "newPassword": "Password1!",
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/complete", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("expected 400 for missing loginId, got %d", resp.StatusCode)
+ }
+
+ var got map[string]string
+ if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if got["error"] != "Login ID and new password are required" {
+ t.Fatalf("unexpected error message: %v", got["error"])
+ }
+}
+
+func TestCompletePasswordReset_InvalidPasswordPolicy(t *testing.T) {
+ h := &AuthHandler{}
+ app := newTestApp(h)
+
+ body, _ := json.Marshal(map[string]string{
+ "newPassword": "short", // too short + missing complexity
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/complete?loginId=user@example.com", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("expected 400 for weak password, got %d", resp.StatusCode)
+ }
+
+ var got map[string]string
+ 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" {
+ t.Fatalf("unexpected error message: %v", got["error"])
+ }
+}
+
+func TestCompletePasswordReset_NilDescopeClient(t *testing.T) {
+ h := &AuthHandler{} // DescopeClient intentionally nil to hit the configuration error branch
+ app := newTestApp(h)
+
+ body, _ := json.Marshal(map[string]string{
+ "newPassword": "StrongPass1!",
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/complete?loginId=user@example.com", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusInternalServerError {
+ t.Fatalf("expected 500 when Descope client is nil, got %d", resp.StatusCode)
+ }
+
+ var got map[string]string
+ if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if got["error"] != "Authentication service not configured" {
+ t.Fatalf("unexpected error message: %v", got["error"])
+ }
+}
diff --git a/backend/internal/handler/password_policy_test.go b/backend/internal/handler/password_policy_test.go
new file mode 100644
index 00000000..963ffbed
--- /dev/null
+++ b/backend/internal/handler/password_policy_test.go
@@ -0,0 +1,232 @@
+package handler
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strconv"
+ "testing"
+ "unicode"
+
+ "github.com/descope/go-sdk/descope"
+ "github.com/descope/go-sdk/descope/client"
+ mocksauth "github.com/descope/go-sdk/descope/tests/mocks/auth"
+)
+
+// 정책을 받아 필수 요구사항을 모두 포함하는 비밀번호를 생성한다.
+func generatePasswordFromPolicy(policy *descope.PasswordPolicy) string {
+ minLen := int(policy.MinLength)
+ if minLen < 8 {
+ minLen = 12 // 안전한 기본값
+ }
+
+ pwd := make([]rune, 0, minLen)
+
+ if policy.Lowercase {
+ pwd = append(pwd, 'a')
+ }
+ if policy.Uppercase {
+ pwd = append(pwd, 'B')
+ }
+ if policy.Number {
+ pwd = append(pwd, '3')
+ }
+ if policy.NonAlphanumeric {
+ pwd = append(pwd, '!')
+ }
+
+ charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
+ for len(pwd) < minLen {
+ pwd = append(pwd, rune(charset[randomInt(len(charset))]))
+ }
+
+ // 섞어서 예측 가능성을 낮춘다.
+ for i := range pwd {
+ j := randomInt(len(pwd))
+ pwd[i], pwd[j] = pwd[j], pwd[i]
+ }
+ return string(pwd)
+}
+
+func randomInt(n int) int {
+ if n <= 0 {
+ return 0
+ }
+ var b [8]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return 0
+ }
+ return int(binary.BigEndian.Uint64(b[:]) % uint64(n))
+}
+
+func TestGeneratePasswordUsesNonAlphanumericRequirement(t *testing.T) {
+ mockAuth := &mocksauth.MockAuthentication{
+ MockPassword: &mocksauth.MockPassword{
+ PolicyResponse: &descope.PasswordPolicy{
+ MinLength: 8,
+ Lowercase: true,
+ Uppercase: true,
+ Number: true,
+ NonAlphanumeric: true,
+ },
+ },
+ }
+
+ policy, err := mockAuth.Password().GetPasswordPolicy(context.Background())
+ if err != nil {
+ t.Fatalf("정책 조회 실패: %v", err)
+ }
+ if !policy.NonAlphanumeric {
+ t.Fatalf("정책에 비영문자 요구사항이 표시되지 않음")
+ }
+
+ pwd := generatePasswordFromPolicy(policy)
+
+ if len(pwd) < int(policy.MinLength) {
+ t.Fatalf("비밀번호 길이가 정책 최소 길이 미만: got %d, want >= %d", len(pwd), policy.MinLength)
+ }
+
+ var hasLower, hasUpper, hasNumber, hasSymbol bool
+ for _, r := range pwd {
+ switch {
+ case unicode.IsLower(r):
+ hasLower = true
+ case unicode.IsUpper(r):
+ hasUpper = true
+ case unicode.IsNumber(r):
+ hasNumber = true
+ case !unicode.IsLetter(r) && !unicode.IsNumber(r):
+ hasSymbol = true
+ }
+ }
+
+ if policy.Lowercase && !hasLower {
+ t.Fatalf("소문자 요구사항 미충족: %q", pwd)
+ }
+ if policy.Uppercase && !hasUpper {
+ t.Fatalf("대문자 요구사항 미충족: %q", pwd)
+ }
+ if policy.Number && !hasNumber {
+ t.Fatalf("숫자 요구사항 미충족: %q", pwd)
+ }
+ if policy.NonAlphanumeric && !hasSymbol {
+ t.Fatalf("비영문자 요구사항 미충족: %q", pwd)
+ }
+}
+
+// 통합 테스트: 실제 Descope 정책으로 비밀번호를 생성하고 교체 플로우를 검증한다.
+// 필요 env:
+// DESCOPE_PROJECT_ID, DESCOPE_MANAGEMENT_KEY, TEST_DESCOPE_LOGIN_ID, TEST_DESCOPE_CURRENT_PASSWORD
+func TestDescopePasswordPolicyAndChange(t *testing.T) {
+ projectID := os.Getenv("DESCOPE_PROJECT_ID")
+ managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
+ loginID := os.Getenv("DESCOPE_TEST_ACCOUNT")
+
+ if projectID == "" || managementKey == "" || loginID == "" {
+ t.Skip("환경변수(DESCOPE_PROJECT_ID, DESCOPE_MANAGEMENT_KEY, DESCOPE_TEST_ACCOUNT) 미설정으로 통합 테스트 건너뜀")
+ }
+
+ logf := func(format string, args ...any) {
+ t.Logf(format, args...)
+ fmt.Printf(format+"\n", args...)
+ }
+
+ ctx := context.Background()
+ cl, err := client.NewWithConfig(&client.Config{
+ ProjectID: projectID,
+ ManagementKey: managementKey,
+ })
+ if err != nil {
+ t.Fatalf("Descope 클라이언트 초기화 실패: %v", err)
+ }
+
+ policy, err := cl.Auth.Password().GetPasswordPolicy(ctx)
+ if err != nil {
+ t.Fatalf("비밀번호 정책 조회 실패: %v", err)
+ }
+ logf("정책: min=%d lower=%v upper=%v number=%v nonAlpha=%v", policy.MinLength, policy.Lowercase, policy.Uppercase, policy.Number, policy.NonAlphanumeric)
+
+ // 테스트 계정이 없으면 생성
+ users, _, err := cl.Management.User().SearchAll(ctx, &descope.UserSearchOptions{
+ LoginIDs: []string{loginID},
+ Limit: 1,
+ Page: 0,
+ })
+ if err != nil {
+ t.Fatalf("테스트 계정 검색 실패: %v", err)
+ }
+ if len(users) == 0 {
+ logf("테스트 계정 미존재, 생성 시도: %s", loginID)
+ if _, err := cl.Management.User().CreateTestUser(ctx, loginID, &descope.UserRequest{
+ User: descope.User{
+ Email: loginID,
+ },
+ }); err != nil {
+ t.Fatalf("테스트 계정 생성 실패: %v", err)
+ }
+ } else {
+ logf("테스트 계정 존재 확인: %s", loginID)
+ }
+
+ // 1) 기초 비밀번호 설정 (알려진 값으로 초기화)
+ basePassword := generatePasswordFromPolicy(policy)
+ if err := cl.Management.User().SetActivePassword(ctx, loginID, basePassword); err != nil {
+ logf("초기 비밀번호 설정 실패: status=%d err=%v", statusFromError(err), err)
+ t.Fatalf("초기 비밀번호 설정 실패: %v", err)
+ }
+ logf("초기 비밀번호 설정 완료: %s", basePassword)
+
+ // 2) 초기 비밀번호 로그인 검증
+ wOld := httptest.NewRecorder()
+ _, err = cl.Auth.Password().SignIn(ctx, loginID, basePassword, wOld)
+ logf("기초 비밀번호 로그인: status=%d err=%v", statusFromError(err), err)
+ if err != nil {
+ t.Fatalf("기초 비밀번호 로그인 실패: %v", err)
+ }
+
+ // 3) 새 비밀번호 생성 및 변경
+ newPassword := generatePasswordFromPolicy(policy)
+ if newPassword == basePassword {
+ newPassword = newPassword + "Z9!"
+ }
+ logf("새 비밀번호 생성: %s", newPassword)
+
+ if err := cl.Management.User().SetActivePassword(ctx, loginID, newPassword); err != nil {
+ logf("비밀번호 변경 실패: status=%d err=%v", statusFromError(err), err)
+ t.Fatalf("비밀번호 변경 실패: %v", err)
+ }
+ logf("비밀번호 변경 성공(status=200)")
+
+ // 4) 새 비밀번호로 로그인 확인
+ wNew := httptest.NewRecorder()
+ _, err = cl.Auth.Password().SignIn(ctx, loginID, newPassword, wNew)
+ logf("새 비밀번호 로그인: status=%d err=%v", statusFromError(err), err)
+ if err != nil {
+ t.Fatalf("새 비밀번호 로그인 실패: %v", err)
+ }
+}
+
+func statusFromError(err error) int {
+ if err == nil {
+ return http.StatusOK
+ }
+ var de *descope.Error
+ if errors.As(err, &de) {
+ if statusRaw, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok {
+ switch v := statusRaw.(type) {
+ case int:
+ return v
+ case string:
+ if n, convErr := strconv.Atoi(v); convErr == nil {
+ return n
+ }
+ }
+ }
+ }
+ return http.StatusInternalServerError
+}
diff --git a/backend/internal/idp/factory.go b/backend/internal/idp/factory.go
new file mode 100644
index 00000000..5b82721a
--- /dev/null
+++ b/backend/internal/idp/factory.go
@@ -0,0 +1,63 @@
+package idp
+
+import (
+ "baron-sso-backend/internal/domain"
+ "baron-sso-backend/internal/service"
+ "fmt"
+ "log/slog"
+ "os"
+ "strings"
+)
+
+// getEnv는 환경 변수를 읽거나 대체 값을 반환하는 헬퍼 함수입니다.
+func getEnv(key, fallback string) string {
+ if value, ok := os.LookupEnv(key); ok {
+ return value
+ }
+ return fallback
+}
+
+// InitializeProvider는 환경 설정을 기반으로 IDP 공급자를 생성하고 반환합니다.
+// 이것은 IdentityProvider 인터페이스의 팩토리 역할을 합니다.
+func InitializeProvider() (domain.IdentityProvider, error) {
+ rawProviders := getEnv("IDP_PROVIDER", "descope") // 기본값은 descope입니다.
+ providers := strings.Split(rawProviders, ",")
+ slog.Info("Initializing IDP", "providers", rawProviders)
+
+ for _, p := range providers {
+ providerName := strings.TrimSpace(strings.ToLower(p))
+ switch providerName {
+ case "descope":
+ descopeProjectID := getEnv("DESCOPE_PROJECT_ID", "")
+ descopeManagementKey := getEnv("DESCOPE_MANAGEMENT_KEY", "")
+ // 선택된 공급자에 대한 키가 설정되었는지 확인하기 위한 기본 유효성 검사
+ if descopeProjectID == "" || descopeManagementKey == "" {
+ return nil, fmt.Errorf("DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY must be set for the 'descope' provider")
+ }
+ return service.NewDescopeProvider(descopeProjectID, descopeManagementKey), nil
+
+ // --- 향후 공급자 구현 ---
+ // case "ory":
+ // // oryURL := getEnv("ORY_URL", "")
+ // // if oryURL == "" {
+ // // return nil, fmt.Errorf("ORY_URL must be set for the 'ory' provider")
+ // // }
+ // // return service.NewOryProvider(oryURL), nil
+ // // return nil, fmt.Errorf(\"'ory' provider is not yet implemented\")
+
+ // case "keycloak":
+ // // keycloakURL := getEnv("KEYCLOAK_URL", "")
+ // // keycloakRealm := getEnv("KEYCLOAK_REALM", "")
+ // // if keycloakURL == "" || keycloakRealm == "" {
+ // // return nil, fmt.Errorf("KEYCLOAK_URL and KEYCLOAK_REALM must be set for the 'keycloak' provider")
+ // // }
+ // // return service.NewKeycloakProvider(keycloakURL, keycloakRealm), nil
+ // // return nil, fmt.Errorf(\"'keycloak' provider is not yet implemented\")
+ default:
+ // 알 수 없는 공급자는 건너뛰고 다음 후보를 시도
+ slog.Warn("Skipping unsupported IDP provider entry", "provider", providerName)
+ }
+ }
+
+ return nil, fmt.Errorf("unsupported or unknown IDP_PROVIDER specified: %s", rawProviders)
+}
diff --git a/backend/internal/logger/audit_logger.go b/backend/internal/logger/audit_logger.go
new file mode 100644
index 00000000..ad1291ea
--- /dev/null
+++ b/backend/internal/logger/audit_logger.go
@@ -0,0 +1,216 @@
+package logger
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/google/uuid"
+)
+
+// AuditLogEntry holds common audit logging fields.
+type AuditLogEntry struct {
+ RequestID string
+ Stage string
+ Operation string // e.g., "SendPasswordReset", "Verify"
+ Method string
+ Path string
+ Status int
+ LatencyMs time.Duration
+ IP string
+ UserAgent string
+ Origin string
+ Referer string
+ Query map[string]string
+ Headers map[string]string // Core headers like Host, Cookie, Set-Cookie
+ LoginIDs map[string]string // loginId and loginId_normalized
+ Token string // For reset tokens, magic link tokens
+ DescopeError string
+ DescopeStatus int // Descope HTTP status
+ DescopeBody string // Descope response body (full raw)
+ RefreshToken string
+ SessionJwt string
+ AccessJwt string
+ UserLoginId string
+ UserID string
+ Email string
+ Phone string
+ SetCookieName string
+ SetCookieValue string
+ SetCookieAttrs map[string]string
+ RedirectTo string
+ HasCookieDSRF bool
+ ParsedCookieDSRF string
+ RequestBody string // For complete stage
+ NewPassword string // For complete stage (test only, sensitive)
+ // ... potentially more fields specific to different stages
+}
+
+// NewAuditLogEntry creates a new AuditLogEntry with a generated RequestID and initial common fields.
+func NewAuditLogEntry(c *fiber.Ctx, stage string) *AuditLogEntry {
+ reqID := uuid.New().String()
+
+ // Extract query parameters
+ queryParams := make(map[string]string)
+ c.Context().QueryArgs().VisitAll(func(key, value []byte) {
+ queryParams[string(key)] = string(value)
+ })
+
+ // Extract relevant headers
+ headers := make(map[string]string)
+ headers["Host"] = c.Get("Host")
+ headers["User-Agent"] = c.Get("User-Agent")
+ if cookie := c.Get("Cookie"); cookie != "" {
+ headers["Cookie"] = cookie
+ }
+ headers["Origin"] = c.Get("Origin")
+ headers["Referer"] = c.Get("Referer")
+
+
+ return &AuditLogEntry{
+ RequestID: reqID,
+ Stage: stage,
+ Method: c.Method(),
+ Path: c.Path(),
+ IP: c.IP(),
+ UserAgent: c.Get("User-Agent"),
+ Origin: c.Get("Origin"),
+ Referer: c.Get("Referer"),
+ Query: queryParams,
+ Headers: headers,
+ LoginIDs: make(map[string]string),
+ SetCookieAttrs: make(map[string]string),
+ }
+}
+
+
+// Log emits an audit log entry using slog.
+// It includes common fields and allows for additional custom fields.
+func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
+ attrs := []slog.Attr{
+ slog.String("req_id", ale.RequestID),
+ slog.String("stage", ale.Stage),
+ }
+
+ if ale.Operation != "" {
+ attrs = append(attrs, slog.String("op", ale.Operation))
+ }
+ if ale.Method != "" {
+ attrs = append(attrs, slog.String("method", ale.Method))
+ }
+ if ale.Path != "" {
+ attrs = append(attrs, slog.String("path", ale.Path))
+ }
+ if ale.Status != 0 {
+ attrs = append(attrs, slog.Int("status", ale.Status))
+ }
+ if ale.LatencyMs != 0 {
+ attrs = append(attrs, slog.Duration("latency_ms", ale.LatencyMs))
+ }
+ if ale.IP != "" {
+ attrs = append(attrs, slog.String("ip", ale.IP))
+ }
+ if ale.UserAgent != "" {
+ attrs = append(attrs, slog.String("user_agent", ale.UserAgent))
+ }
+ if ale.Origin != "" {
+ attrs = append(attrs, slog.String("origin", ale.Origin))
+ }
+ if ale.Referer != "" {
+ attrs = append(attrs, slog.String("referer", ale.Referer))
+ }
+ if len(ale.Query) > 0 {
+ queryGroupArgs := make([]any, 0, len(ale.Query))
+ for k, v := range ale.Query {
+ queryGroupArgs = append(queryGroupArgs, slog.String(k, v))
+ }
+ attrs = append(attrs, slog.Group("query", queryGroupArgs...))
+ }
+ if len(ale.Headers) > 0 {
+ headersGroupArgs := make([]any, 0, len(ale.Headers))
+ for k, v := range ale.Headers {
+ headersGroupArgs = append(headersGroupArgs, slog.String(k, v))
+ }
+ attrs = append(attrs, slog.Group("headers", headersGroupArgs...))
+ }
+ if len(ale.LoginIDs) > 0 {
+ loginIDGroupArgs := make([]any, 0, len(ale.LoginIDs))
+ for k, v := range ale.LoginIDs {
+ loginIDGroupArgs = append(loginIDGroupArgs, slog.String(k, v))
+ }
+ attrs = append(attrs, slog.Group("login_ids", loginIDGroupArgs...))
+ }
+ if ale.Token != "" {
+ attrs = append(attrs, slog.String("token", ale.Token))
+ }
+ if ale.DescopeError != "" {
+ attrs = append(attrs, slog.String("descope_error", ale.DescopeError))
+ }
+ if ale.DescopeStatus != 0 {
+ attrs = append(attrs, slog.Int("descope_http_status", ale.DescopeStatus))
+ }
+ if ale.DescopeBody != "" {
+ attrs = append(attrs, slog.String("descope_response_body", ale.DescopeBody))
+ }
+ if ale.RefreshToken != "" {
+ attrs = append(attrs, slog.String("refresh_token", ale.RefreshToken))
+ }
+ if ale.SessionJwt != "" {
+ attrs = append(attrs, slog.String("session_jwt", ale.SessionJwt))
+ }
+ if ale.AccessJwt != "" {
+ attrs = append(attrs, slog.String("access_jwt", ale.AccessJwt))
+ }
+ if ale.UserLoginId != "" {
+ attrs = append(attrs, slog.String("user_login_id", ale.UserLoginId))
+ }
+ if ale.UserID != "" {
+ attrs = append(attrs, slog.String("user_id", ale.UserID))
+ }
+ if ale.Email != "" {
+ attrs = append(attrs, slog.String("email", ale.Email))
+ }
+ if ale.Phone != "" {
+ attrs = append(attrs, slog.String("phone", ale.Phone))
+ }
+ if ale.SetCookieName != "" {
+ attrs = append(attrs, slog.String("set_cookie_name", ale.SetCookieName))
+ attrs = append(attrs, slog.String("set_cookie_value", ale.SetCookieValue))
+ if len(ale.SetCookieAttrs) > 0 {
+ cookieAttrsGroupArgs := make([]any, 0, len(ale.SetCookieAttrs))
+ for k, v := range ale.SetCookieAttrs {
+ cookieAttrsGroupArgs = append(cookieAttrsGroupArgs, slog.String(k, v))
+ }
+ attrs = append(attrs, slog.Group("set_cookie_attrs", cookieAttrsGroupArgs...))
+ }
+ }
+ if ale.RedirectTo != "" {
+ attrs = append(attrs, slog.String("redirect_to", ale.RedirectTo))
+ }
+ if ale.HasCookieDSRF {
+ attrs = append(attrs, slog.Bool("has_cookie_DSRF", ale.HasCookieDSRF))
+ }
+ if ale.ParsedCookieDSRF != "" {
+ attrs = append(attrs, slog.String("parsed_cookie_DSRF", ale.ParsedCookieDSRF))
+ }
+ if ale.RequestBody != "" {
+ attrs = append(attrs, slog.String("request_body", ale.RequestBody))
+ }
+ if ale.NewPassword != "" { // FOR TEST ONLY - DO NOT LOG IN PRODUCTION
+ attrs = append(attrs, slog.String("new_password", ale.NewPassword))
+ }
+
+ // Convert variadic args to slog.Attr before appending
+ for i := 0; i < len(args); i += 2 {
+ if i+1 < len(args) {
+ attrs = append(attrs, slog.Any(fmt.Sprintf("%v", args[i]), args[i+1]))
+ } else {
+ // Handle odd number of arguments - log the last one with a generic key
+ attrs = append(attrs, slog.Any(fmt.Sprintf("extra_arg_%d", i), args[i]))
+ }
+ }
+
+ slog.Default().LogAttrs(context.Background(), level, msg, attrs...)
+}
\ No newline at end of file
diff --git a/backend/internal/service/descope_service.go b/backend/internal/service/descope_service.go
index 9dd84d58..58cb36aa 100644
--- a/backend/internal/service/descope_service.go
+++ b/backend/internal/service/descope_service.go
@@ -2,13 +2,20 @@ package service
import (
"baron-sso-backend/internal/domain"
+ "context"
+ "fmt"
"log/slog"
+ "net/http"
+ "os"
+ "time"
+ "github.com/descope/go-sdk/descope"
"github.com/descope/go-sdk/descope/client"
)
type DescopeProvider struct {
Client *client.DescopeClient
+ FrontendURL string
fieldMapping map[string]string // Key: Broker Field Name, Value: Descope Attribute Key
}
@@ -36,6 +43,7 @@ func NewDescopeProvider(projectID, managementKey string) *DescopeProvider {
return &DescopeProvider{
Client: descopeClient,
+ FrontendURL: os.Getenv("FRONTEND_URL"),
fieldMapping: mapping,
}
}
@@ -60,3 +68,56 @@ func (d *DescopeProvider) GetMetadata() (*domain.IDPMetadata, error) {
SupportedFields: supported,
}, nil
}
+
+func (d *DescopeProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
+ ctx := context.Background()
+ err := d.Client.Auth.Password().SendPasswordReset(ctx, loginID, redirectUrl, nil)
+ if err != nil {
+ slog.Error("Descope SendPasswordReset failed (raw)",
+ "loginID", loginID,
+ "redirectUrl", redirectUrl,
+ "err", err,
+ "err_type", fmt.Sprintf("%T", err),
+ )
+
+ if de, ok := err.(*descope.Error); ok {
+ status := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode] // "Status-Code"
+ slog.Error("Descope error details",
+ "code", de.Code,
+ "description", de.Description,
+ "message", de.Message,
+ "status_code", status,
+ "info", de.Info,
+ )
+ }
+ }
+ return err
+}
+
+func (d *DescopeProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
+ ctx := context.Background()
+ authInfo, err := d.Client.Auth.MagicLink().Verify(ctx, token, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ res := &domain.AuthInfo{
+ SessionToken: &domain.Token{
+ JWT: authInfo.SessionToken.JWT,
+ Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
+ },
+ }
+ 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) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
+ ctx := context.Background()
+ return d.Client.Auth.Password().UpdateUserPassword(ctx, loginID, newPassword, r)
+}
diff --git a/backend/internal/validator/schema_validator_test.go b/backend/internal/validator/schema_validator_test.go
index a79a151d..fcccf780 100644
--- a/backend/internal/validator/schema_validator_test.go
+++ b/backend/internal/validator/schema_validator_test.go
@@ -2,6 +2,7 @@ package validator
import (
"baron-sso-backend/internal/domain"
+ "net/http"
"testing"
)
@@ -20,6 +21,19 @@ func (m *MockProvider) GetMetadata() (*domain.IDPMetadata, error) {
}, nil
}
+// Stub implementations to satisfy the IdentityProvider interface for this unit test.
+func (m *MockProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
+ return nil
+}
+
+func (m *MockProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
+ return &domain.AuthInfo{}, nil
+}
+
+func (m *MockProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
+ return nil
+}
+
func TestValidateIDPCompatibility(t *testing.T) {
// BrokerUser 모델은 다음과 같이 정의되어 있다고 가정합니다 (idp_models.go 참조):
// ID (required), Email (required), Name, PhoneNumber
diff --git a/frontend/lib/core/services/auth_proxy_service.dart b/frontend/lib/core/services/auth_proxy_service.dart
index 0d03eb84..f17832f8 100644
--- a/frontend/lib/core/services/auth_proxy_service.dart
+++ b/frontend/lib/core/services/auth_proxy_service.dart
@@ -5,6 +5,16 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
class AuthProxyService {
static String get _baseUrl => dotenv.env['BACKEND_URL'] ?? 'https://sso.hmac.kr';
+ static Future