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> fetchPasswordPolicy() async { + final url = Uri.parse('$_baseUrl/api/v1/auth/password/policy'); + final response = await http.get(url); + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception('Failed to fetch password policy'); + } + } + static Future> initEnchantedLink(String loginId, {String? method}) async { final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init'); final frontendUrl = dotenv.env['FRONTEND_URL'] ?? 'http://sso.hmac.kr'; @@ -66,6 +76,69 @@ class AuthProxyService { } } + static Future> loginWithPassword(String loginId, String password) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/password/login'); + + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'loginId': loginId, + 'password': password, + }), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + final errorBody = jsonDecode(response.body); + throw Exception(errorBody['error'] ?? 'Failed to login'); + } + } + + static Future> initiatePasswordReset(String loginId) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate'); + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'loginId': loginId}), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + final errorBody = jsonDecode(response.body); + throw Exception(errorBody['error'] ?? 'Failed to initiate password reset'); + } + } + + static Future> completePasswordReset({ + String? loginId, + String? token, + required String newPassword, + }) async { + final query = {}; + if (loginId != null && loginId.isNotEmpty) { + query['loginId'] = loginId; + } + if (token != null && token.isNotEmpty) { + query['token'] = token; + } + final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/complete').replace(queryParameters: query); + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'newPassword': newPassword}), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + final errorBody = jsonDecode(response.body); + throw Exception(errorBody['error'] ?? 'Failed to complete password reset'); + } + } + static Future sendSms(String phoneNumber) async { final url = Uri.parse('$_baseUrl/api/v1/auth/sms'); diff --git a/frontend/lib/features/admin/presentation/create_user_screen.dart b/frontend/lib/features/admin/presentation/create_user_screen.dart index da5337ea..69418cfc 100644 --- a/frontend/lib/features/admin/presentation/create_user_screen.dart +++ b/frontend/lib/features/admin/presentation/create_user_screen.dart @@ -108,12 +108,28 @@ class _CreateUserScreenState extends State { setState(() => _isLoading = true); + String loginId = _loginIdController.text.trim(); + if (!loginId.contains('@')) { + loginId = loginId.replaceAll(RegExp(r'[-\s]'), ''); + if (loginId.startsWith('010')) { + loginId = '+82${loginId.substring(1)}'; + } + } + + String? phone = _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim(); + if (phone != null && !phone.contains('@')) { + phone = phone.replaceAll(RegExp(r'[-\s]'), ''); + if (phone.startsWith('010')) { + phone = '+82${phone.substring(1)}'; + } + } + try { await AuthProxyService.createUser( - loginId: _loginIdController.text.trim(), + loginId: loginId, adminPassword: _verifiedAdminPassword!, email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), - phone: _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim(), + phone: phone, displayName: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(), ); diff --git a/frontend/lib/features/admin/presentation/user_management_screen.dart b/frontend/lib/features/admin/presentation/user_management_screen.dart index f779e779..f61f94fb 100644 --- a/frontend/lib/features/admin/presentation/user_management_screen.dart +++ b/frontend/lib/features/admin/presentation/user_management_screen.dart @@ -205,13 +205,22 @@ class _UserManagementScreenState extends State with Single if (confirm != true) return; setState(() => _isLoading = true); + + String? phone = phoneController.text.trim().isEmpty ? null : phoneController.text.trim(); + if (phone != null && !phone.contains('@')) { + phone = phone.replaceAll(RegExp(r'[-\s]'), ''); + if (phone.startsWith('010')) { + phone = '+82${phone.substring(1)}'; + } + } + try { await AuthProxyService.updateUserDetails( adminPassword: _verifiedAdminPassword!, loginId: loginId, displayName: nameController.text.trim(), email: emailController.text.trim(), - phone: phoneController.text.trim(), + phone: phone, ); _showSuccess("User updated successfully"); _loadUsers(query: _searchController.text); @@ -228,12 +237,29 @@ class _UserManagementScreenState extends State with Single if (_verifiedAdminPassword == null) return; setState(() => _isLoading = true); + + String loginId = _createLoginIdController.text.trim(); + if (!loginId.contains('@')) { + loginId = loginId.replaceAll(RegExp(r'[-\s]'), ''); + if (loginId.startsWith('010')) { + loginId = '+82${loginId.substring(1)}'; + } + } + + String? phone = _createPhoneController.text.trim().isEmpty ? null : _createPhoneController.text.trim(); + if (phone != null && !phone.contains('@')) { + phone = phone.replaceAll(RegExp(r'[-\s]'), ''); + if (phone.startsWith('010')) { + phone = '+82${phone.substring(1)}'; + } + } + try { await AuthProxyService.createUser( - loginId: _createLoginIdController.text.trim(), + loginId: loginId, adminPassword: _verifiedAdminPassword!, email: _createEmailController.text.trim().isEmpty ? null : _createEmailController.text.trim(), - phone: _createPhoneController.text.trim().isEmpty ? null : _createPhoneController.text.trim(), + phone: phone, displayName: _createNameController.text.trim().isEmpty ? null : _createNameController.text.trim(), ); diff --git a/frontend/lib/features/auth/presentation/forgot_password_screen.dart b/frontend/lib/features/auth/presentation/forgot_password_screen.dart new file mode 100644 index 00000000..ae2b82e7 --- /dev/null +++ b/frontend/lib/features/auth/presentation/forgot_password_screen.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../core/services/auth_proxy_service.dart'; + +class ForgotPasswordScreen extends StatefulWidget { + const ForgotPasswordScreen({super.key}); + + @override + State createState() => _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState extends State { + final TextEditingController _loginIdController = TextEditingController(); + bool _isLoading = false; + + Future _handlePasswordReset() async { + final input = _loginIdController.text.trim(); + if (input.isEmpty) { + _showError("이메일 또는 휴대폰 번호를 입력해주세요."); + return; + } + + String loginId = input; + if (!input.contains('@')) { + // Format phone number if it's not an email + loginId = input.replaceAll(RegExp(r'[-\s]'), ''); + if (loginId.startsWith('010')) { + loginId = '+82${loginId.substring(1)}'; + } + } + + setState(() => _isLoading = true); + + try { + await AuthProxyService.initiatePasswordReset(loginId); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요."), + backgroundColor: Colors.green, + ), + ); + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + _showError("전송에 실패했습니다: ${e.toString()}"); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + void _showError(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.red), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("비밀번호 재설정"), + centerTitle: true, + ), + body: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 400), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "비밀번호를 잊으셨나요?", + style: GoogleFonts.outfit( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + const Text( + "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 40), + TextField( + controller: _loginIdController, + decoration: const InputDecoration( + labelText: "이메일 또는 휴대폰 번호", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + ), + onSubmitted: (_) => _handlePasswordReset(), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _isLoading ? null : _handlePasswordReset, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text("재설정 링크 전송"), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/features/auth/presentation/login_screen.dart b/frontend/lib/features/auth/presentation/login_screen.dart index 9e9eebf2..e2fefbf0 100644 --- a/frontend/lib/features/auth/presentation/login_screen.dart +++ b/frontend/lib/features/auth/presentation/login_screen.dart @@ -12,6 +12,7 @@ import '../../../core/services/audit_service.dart'; import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/auth_proxy_service.dart'; import '../../../core/notifiers/auth_notifier.dart'; +import './forgot_password_screen.dart'; class LoginScreen extends ConsumerStatefulWidget { final String? verificationToken; @@ -24,9 +25,9 @@ class LoginScreen extends ConsumerStatefulWidget { class _LoginScreenState extends ConsumerState with SingleTickerProviderStateMixin { late TabController _tabController; - final TextEditingController _idController = TextEditingController(); - final TextEditingController _smsCodeController = TextEditingController(); // Keep if needed for verification inputs later? Actually not used in link flow. - bool _smsSent = false; + final TextEditingController _linkIdController = TextEditingController(); + final TextEditingController _passwordLoginIdController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); String? _redirectUrl; // QR Login Variables @@ -40,7 +41,8 @@ class _LoginScreenState extends ConsumerState @override void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this); + // 탭 컨트롤러: 3개 탭, 기본 선택은 두 번째 탭("로그인 링크") + _tabController = TabController(length: 3, vsync: this, initialIndex: 1); _tabController.addListener(_handleTabSelection); // Check for tokens (Path Parameter or Legacy Query Parameter) @@ -92,9 +94,10 @@ class _LoginScreenState extends ConsumerState } void _handleTabSelection() { - if (_tabController.index == 1 && _qrPendingRef == null) { + // QR 탭 (세 번째 탭, index 2)이 선택되었을 때 QR 플로우 시작 + if (_tabController.index == 2 && _qrPendingRef == null) { _startQrFlow(); - } else if (_tabController.index != 1) { + } else if (_tabController.index != 2) { _stopQrPolling(); } } @@ -230,28 +233,65 @@ class _LoginScreenState extends ConsumerState } } - void _showSuccessDialog() { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const AlertDialog( - title: Text("Authentication Successful"), - content: Text("You can close this tab and return to the application."), - ), - ); - } - @override void dispose() { _stopQrPolling(); _tabController.dispose(); - _idController.dispose(); - _smsCodeController.dispose(); + _linkIdController.dispose(); + _passwordLoginIdController.dispose(); + _passwordController.dispose(); super.dispose(); } - Future _handleLogin() async { - final input = _idController.text.trim(); + // 이메일/비밀번호 로그인 처리 + Future _handlePasswordLogin() async { + final input = _passwordLoginIdController.text.trim(); + final password = _passwordController.text.trim(); + if (input.isEmpty || password.isEmpty) { + _showError("이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요."); + return; + } + + String loginId = input; + if (!input.contains('@')) { + // Format phone number if it's not an email + loginId = input.replaceAll(RegExp(r'[-\s]'), ''); + if (loginId.startsWith('010')) { + loginId = '+82${loginId.substring(1)}'; + } + } + + // 로딩 인디케이터 표시 + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center(child: CircularProgressIndicator()), + ); + + try { + final res = await AuthProxyService.loginWithPassword(loginId, password); + final jwt = res['sessionJwt']; + if (jwt != null && mounted) { + Navigator.of(context).pop(); // 로딩 닫기 + + 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); + } + } catch (e) { + if (mounted) Navigator.of(context).pop(); // 로딩 닫기 + _showError("로그인 실패: ${e.toString().replaceFirst("Exception: ", "")}"); + } + } + + // 로그인 링크 전송 처리 + Future _handleLinkLogin() async { + final input = _linkIdController.text.trim(); if (input.isEmpty) return; String loginId = input; @@ -340,26 +380,8 @@ class _LoginScreenState extends ConsumerState final displayName = _getLoginIdFromJwt(jwt); // Descope SDK 세션 강제 주입 - // Note: DescopeUser in 0.9.11 requires 18 positional arguments. final dummyUser = DescopeUser( - 'unknown', // userId - [], // loginIds - 0, // createdAt - displayName, // name - null, // picture (Uri?) - '', // email - false, // isVerifiedEmail - '', // phone - false, // isVerifiedPhone - {}, // customAttributes - '', // givenName - '', // middleName - '', // familyName - false, // hasPassword - 'enabled', // status - [], // roleNames - [], // ssoAppIds - [], // oauthProviders (List) + 'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [], ); final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); Descope.sessionManager.manageSession(session); @@ -397,38 +419,25 @@ class _LoginScreenState extends ConsumerState void _logTokenDetails(String jwt) { try { - // JWT는 세 부분(Header, Payload, Signature)이 '.'으로 구분된 문자열입니다. 이를 분리합니다. final parts = jwt.split('.'); - // 세 부분으로 정확히 나뉘지 않았다면 유효한 JWT가 아니므로 중단합니다. if (parts.length != 3) return; - // JWT의 두 번째 부분(Payload)은 Base64Url로 인코딩된 JSON 데이터입니다. - // 1. Base64Url 문자열을 디코딩하여 바이트 배열로 변환합니다. - // normalize()는 Base64 패딩(=) 문제를 처리해줍니다. final decodedPayload = base64Url.decode(base64Url.normalize(parts[1])); - // 2. 바이트 배열을 UTF-8 형식의 일반 문자열(JSON)로 변환합니다. final payloadJson = utf8.decode(decodedPayload); - // 3. JSON 문자열을 Dart에서 사용할 수 있는 Map 객체로 변환합니다. final data = json.decode(payloadJson) as Map; - // [FIX] 'exp'는 int 또는 double일 수 있으므로, 안전하게 num으로 처리합니다. final accessExpValue = data['exp'] as num?; - // 'exp' (Expiration Time) 필드는 Access Token의 만료 시간을 나타냅니다. Unix 타임스탬프(초 단위) 값입니다. - // 이 값을 Dart의 DateTime 객체로 변환합니다. (1000을 곱해 밀리초 단위로 만듦) final accessExp = accessExpValue != null ? DateTime.fromMillisecondsSinceEpoch(accessExpValue.toInt() * 1000) : 'N/A'; - // 'rexp' (Refresh Expiration) 필드는 Descope가 사용하는 커스텀 필드로, Refresh Token의 만료 시간을 ISO 8601 형식의 문자열로 나타냅니다. final refreshExp = data['rexp'] ?? 'N/A'; - // 확인된 만료 시간 정보들을 디버그 콘솔에 출력합니다. debugPrint(""" [Auth] Session Token Details --- - Access Token Expires: $accessExp - Refresh Token Expires: $refreshExp """); } catch (e) { - // JWT를 해석하는 과정에서 오류가 발생하면 콘솔에 에러를 출력합니다. debugPrint("[Auth] Failed to decode or log token details: $e"); } } @@ -438,7 +447,6 @@ class _LoginScreenState extends ConsumerState _logTokenDetails(token); - // [FIX] 감사 로그에 실제 사용자 ID를 전송하기 위해 토큰에서 ID를 추출합니다. final userId = _getUserIdFromJwt(token); // Record Audit Log @@ -449,16 +457,12 @@ class _LoginScreenState extends ConsumerState details: "User logged in via Baron SSO", ); - // 1. Handle Popup Flow (Highest Priority for child windows) - // If opened as a popup (has opener), we notify and try to close. + // 1. Handle Popup Flow if (WebAuthIntegration.isPopup()) { debugPrint("[Auth] Popup detected. Notifying opener and attempting to close."); WebAuthIntegration.sendLoginSuccess(token); - - // We don't 'return' here to allow a fallback if window.close() is blocked, - // but in most cases WebAuthIntegration.sendLoginSuccess will close the window. } else { - // 2. Handle Redirect Flow (Only if NOT a popup) + // 2. Handle Redirect Flow if (_redirectUrl != null && _redirectUrl!.isNotEmpty) { debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl"); final target = "$_redirectUrl?token=$token"; @@ -468,7 +472,6 @@ class _LoginScreenState extends ConsumerState } // 3. Standalone mode / Fallback - // If it's a standard login, or if a popup's window.close() was blocked by the browser. debugPrint("[Auth] Login success. Navigating to root."); AuthNotifier.instance.notify(); if (mounted) { @@ -505,7 +508,8 @@ class _LoginScreenState extends ConsumerState TabBar( controller: _tabController, tabs: const [ - Tab(text: "로그인"), + Tab(text: "비밀번호"), + Tab(text: "로그인 링크"), Tab(text: "QR 코드"), ], ), @@ -516,24 +520,72 @@ class _LoginScreenState extends ConsumerState child: TabBarView( controller: _tabController, children: [ - // Unified Login Form + // 1. 이메일/비밀번호 로그인 폼 Padding( padding: const EdgeInsets.only(top: 16.0), child: Column( children: [ TextField( - controller: _idController, + controller: _passwordLoginIdController, + decoration: const InputDecoration( + labelText: "이메일 또는 휴대폰 번호", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + ), + onSubmitted: (_) => _handlePasswordLogin(), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration( + labelText: "비밀번호", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.lock_outline), + ), + onSubmitted: (_) => _handlePasswordLogin(), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _handlePasswordLogin, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + ), + child: const Text("로그인"), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ForgotPasswordScreen(), + ), + ); + }, + child: const Text("비밀번호를 잊으셨나요?"), + ) + ], + ), + ), + + // 2. 로그인 링크 전송 폼 + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Column( + children: [ + TextField( + controller: _linkIdController, decoration: const InputDecoration( labelText: "이메일 또는 휴대폰 번호", hintText: "", border: OutlineInputBorder(), prefixIcon: Icon(Icons.person_outline), ), - onSubmitted: (_) => _handleLogin(), + onSubmitted: (_) => _handleLinkLogin(), ), const SizedBox(height: 24), FilledButton( - onPressed: _handleLogin, + onPressed: _handleLinkLogin, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(50), ), @@ -560,7 +612,7 @@ class _LoginScreenState extends ConsumerState ), ), - // QR Login View + // 3. QR 로그인 뷰 Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, diff --git a/frontend/lib/features/auth/presentation/reset_password_screen.dart b/frontend/lib/features/auth/presentation/reset_password_screen.dart new file mode 100644 index 00000000..e396bc5b --- /dev/null +++ b/frontend/lib/features/auth/presentation/reset_password_screen.dart @@ -0,0 +1,273 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/services/auth_proxy_service.dart'; + +class ResetPasswordScreen extends StatefulWidget { + final String? loginId; // Now receiving loginId + const ResetPasswordScreen({super.key, this.loginId}); + + @override + State createState() => _ResetPasswordScreenState(); +} + +class _ResetPasswordScreenState extends State { + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = TextEditingController(); + final _formKey = GlobalKey(); + bool _isLoading = false; + String? _loginId; + String? _token; + bool _isPasswordObscured = true; + bool _isConfirmPasswordObscured = true; + Map? _policy; + bool _isPolicyLoading = false; + + @override + void initState() { + super.initState(); + // 1. Get loginId from GoRouter state if available + _loginId = widget.loginId; + + // 2. Fallback to URI query parameter if not available via router + if (_loginId == null || _loginId!.isEmpty) { + final uri = Uri.base; + _loginId = uri.queryParameters['loginId']; + } + + // 토큰도 함께 읽어놓는다. + final uri = Uri.base; + _token = uri.queryParameters['token']; + + _loadPolicy(); + } + + Future _loadPolicy() async { + setState(() { + _isPolicyLoading = true; + }); + try { + final policy = await AuthProxyService.fetchPasswordPolicy(); + if (mounted) { + setState(() { + _policy = policy; + }); + } + } catch (_) { + // 실패해도 기본 검증 로직 사용 + } finally { + if (mounted) { + setState(() { + _isPolicyLoading = false; + }); + } + } + } + + Future _handlePasswordReset() async { + if (_formKey.currentState?.validate() != true) return; + if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) { + _showError("유효하지 않은 재설정 링크입니다. (loginId/token 누락)"); + return; + } + + setState(() => _isLoading = true); + + try { + await AuthProxyService.completePasswordReset( + loginId: _loginId, + token: _token, + newPassword: _passwordController.text, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."), + backgroundColor: Colors.green, + ), + ); + context.go('/login'); + } + } catch (e) { + if (mounted) { + _showError("비밀번호 변경에 실패했습니다: ${e.toString()}"); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + void _showError(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.red), + ); + } + + String _buildPolicyDescription() { + if (_isPolicyLoading) { + return "비밀번호 정책을 불러오는 중입니다..."; + } + final minLength = (_policy?['minLength'] as int?) ?? 8; + final requiresLower = _policy?['lowercase'] ?? true; + final requiresUpper = _policy?['uppercase'] ?? true; + final requiresNumber = _policy?['number'] ?? true; + final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; + + final parts = ["최소 ${minLength}자 이상"]; + if (requiresLower) parts.add("소문자 1개 이상"); + if (requiresUpper) parts.add("대문자 1개 이상"); + if (requiresNumber) parts.add("숫자 1개 이상"); + if (requiresSymbol) parts.add("특수문자 1개 이상"); + + return parts.join(", "); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("새 비밀번호 설정"), + centerTitle: true, + ), + body: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 400), + padding: const EdgeInsets.all(24), + child: (_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty) + ? _buildInvalidTokenView() + : Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "새로운 비밀번호 설정", + style: GoogleFonts.outfit( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + _buildPolicyDescription(), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.grey), + ), + const SizedBox(height: 40), + TextFormField( + controller: _passwordController, + obscureText: _isPasswordObscured, + decoration: InputDecoration( + labelText: "새 비밀번호", + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _isPasswordObscured ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () { + setState(() { + _isPasswordObscured = !_isPasswordObscured; + }); + }, + ), + ), + validator: (value) { + final val = value ?? ""; + if (val.isEmpty) { + return '비밀번호를 입력해주세요.'; + } + final minLength = (_policy?['minLength'] as int?) ?? 8; + if (val.length < minLength) { + return '비밀번호는 최소 $minLength자 이상이어야 합니다.'; + } + if ((_policy?['lowercase'] ?? true) && !RegExp(r'(?=.*[a-z])').hasMatch(val)) { + return '최소 1개 이상의 소문자를 포함해야 합니다.'; + } + if ((_policy?['uppercase'] ?? true) && !RegExp(r'(?=.*[A-Z])').hasMatch(val)) { + return '최소 1개 이상의 대문자를 포함해야 합니다.'; + } + if ((_policy?['number'] ?? true) && !RegExp(r'(?=.*\d)').hasMatch(val)) { + return '최소 1개 이상의 숫자를 포함해야 합니다.'; + } + if ((_policy?['nonAlphanumeric'] ?? true) && !RegExp(r'(?=.*[\W_])').hasMatch(val)) { + return '최소 1개 이상의 특수문자를 포함해야 합니다.'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _confirmPasswordController, + obscureText: _isConfirmPasswordObscured, + decoration: InputDecoration( + labelText: "새 비밀번호 확인", + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _isConfirmPasswordObscured ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () { + setState(() { + _isConfirmPasswordObscured = !_isConfirmPasswordObscured; + }); + }, + ), + ), + validator: (value) { + if (value != _passwordController.text) { + return '비밀번호가 일치하지 않습니다.'; + } + return null; + }, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _isLoading ? null : _handlePasswordReset, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text("비밀번호 변경"), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildInvalidTokenView() { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, color: Colors.red, size: 60), + SizedBox(height: 16), + Text( + "유효하지 않은 링크입니다.", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + SizedBox(height: 8), + Text( + "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.", + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index d657730f..54c4f2a9 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -10,6 +10,8 @@ import 'features/auth/presentation/login_screen.dart'; import 'features/auth/presentation/signup_screen.dart'; import 'features/auth/presentation/approve_qr_screen.dart'; import 'features/auth/presentation/qr_scan_screen.dart'; +import 'features/auth/presentation/forgot_password_screen.dart'; +import 'features/auth/presentation/reset_password_screen.dart'; import 'features/dashboard/presentation/dashboard_screen.dart'; import 'features/admin/presentation/user_management_screen.dart'; import 'core/services/auth_proxy_service.dart'; @@ -99,6 +101,23 @@ final _router = GoRouter( return LoginScreen(verificationToken: token); }, ), + GoRoute( + path: '/forgot-password', + builder: (context, state) { + _routerLogger.info("Navigating to /forgot-password"); + return const ForgotPasswordScreen(); + }, + ), + GoRoute( + // Supports both /reset-password and /reset-password?token=... + path: '/reset-password', + builder: (context, state) { + // For deep linking, you might pass the token in the path, e.g., /reset-password/:token + // final token = state.pathParameters['token']; + _routerLogger.info("Navigating to /reset-password"); + return const ResetPasswordScreen(); + }, + ), GoRoute( path: '/approve', builder: (context, state) { @@ -131,26 +150,29 @@ final _router = GoRouter( final isPublicPath = path == '/login' || path == '/signup' || path.startsWith('/verify/') || - path == '/approve'; + path == '/approve' || + path == '/forgot-password' || + path == '/reset-password'; _routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn"); - // 0. ALWAYS allow /verify/ to proceed so it can signal the backend - if (path.startsWith('/verify/')) { + // 0. ALWAYS allow public paths to proceed so they can function + if (isPublicPath) { return null; } // If not logged in and trying to access a protected page, redirect to /login - if (!isLoggedIn && !isPublicPath) { + if (!isLoggedIn) { _routerLogger.info("Not logged in, redirecting to /login"); return '/login'; } // If logged in and trying to access login page, redirect to root (dashboard) - if (isLoggedIn && path == '/login') { - _routerLogger.info("Logged in, redirecting to /"); - return '/'; - } + // This is now implicitly handled by the isPublicPath check, but kept for clarity. + // if (isLoggedIn && path == '/login') { + // _routerLogger.info("Logged in, redirecting to /"); + // return '/'; + // } return null; },