From 72a36701dab0d333651183dc0999fdf17246be7d Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Tue, 27 Jan 2026 11:16:44 +0900 Subject: [PATCH] Refactor password reset flow --- .env.sample | 6 +- backend/cmd/server/main.go | 1 + backend/internal/handler/auth_handler.go | 213 ++++++++-------- backend/internal/handler/auth_handler_test.go | 108 ++++++++ .../internal/handler/password_policy_test.go | 232 ++++++++++++++++++ backend/internal/idp/factory.go | 69 +++--- .../validator/schema_validator_test.go | 14 ++ .../lib/core/services/auth_proxy_service.dart | 25 +- .../presentation/reset_password_screen.dart | 87 +++++-- 9 files changed, 606 insertions(+), 149 deletions(-) create mode 100644 backend/internal/handler/auth_handler_test.go create mode 100644 backend/internal/handler/password_policy_test.go diff --git a/.env.sample b/.env.sample index 3b8f5c00..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_... @@ -46,4 +48,4 @@ ADMIN_PASSWORD=admin FRONTEND_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용) BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소 -IDP_PROVIDER=descopse, hydra ... \ No newline at end of file +IDP_PROVIDER=descopse, hydra ... diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c84fafdc..a6cc84a1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -247,6 +247,7 @@ func main() { // [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/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 044bb533..a835d945 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -7,7 +7,6 @@ import ( "baron-sso-backend/internal/service" "context" crand "crypto/rand" - "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -15,6 +14,7 @@ import ( "math/rand" "os" "regexp" + "strconv" "strings" "time" @@ -42,7 +42,7 @@ const ( emailCodeTTL = 5 * time.Minute smsCodeTTL = 3 * time.Minute prefixPwdResetToken = "pwdreset_token:" - pwdResetExpiration = 15 * time.Minute + pwdResetExpiration = 15 * time.Minute ) type AuthHandler struct { @@ -420,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 @@ -683,7 +703,6 @@ 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() @@ -748,7 +767,6 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { 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) @@ -824,22 +842,69 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", frontendURL) ale.RedirectTo = redirectURL - ale.Operation = "SendPasswordReset" - ale.Log(slog.LevelInfo, "Initiating password reset via Descope") + // 내부 토큰 발급 + 우리 채널로 전송 + 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"}) + } - err := h.IdpProvider.InitiatePasswordReset(loginID, redirectURL) - if err != nil { + 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 initiate password reset via Descope") - return c.JSON(fiber.Map{"message": "If an account with that login ID exists, a reset link has been sent."}) + 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") - return c.JSON(fiber.Map{"message": "Password reset link sent successfully."}) + 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. @@ -893,10 +958,9 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error { ale := logger.NewAuditLogEntry(c, "verify") ale.Operation = "Verify" - // Token comes from Form Body in POST + // Token comes from Form Body in POST or query token := c.FormValue("token") if token == "" { - // Fallback to query param or body json if needed, but form is primary token = c.Query("token") if token == "" { token = c.Query("t") @@ -912,93 +976,29 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).SendString("Missing token") } - ale.Log(slog.LevelInfo, "Attempting to verify token via POST") - - authInfo, err := h.IdpProvider.VerifyPasswordResetToken(token) - if err != nil { + loginID, err := h.RedisService.Get(prefixPwdResetToken + token) + if err != nil || loginID == "" { ale.Status = fiber.StatusUnauthorized ale.LatencyMs = time.Since(startTime) - ale.DescopeError = err.Error() - ale.Log(slog.LevelError, "Failed to verify token with Descope") - - // Redirect to login with error - return c.Status(fiber.StatusUnauthorized).Redirect(h.IdpProvider.(*service.DescopeProvider).FrontendURL + "/login?error=invalid_token") + 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") } - if authInfo.RefreshToken == nil || authInfo.RefreshToken.JWT == "" { - ale.Status = fiber.StatusInternalServerError - ale.LatencyMs = time.Since(startTime) - ale.DescopeError = "Descope did not return a refresh token" - ale.Log(slog.LevelError, "Descope did not return a refresh token") - return c.Status(fiber.StatusInternalServerError).Redirect(h.IdpProvider.(*service.DescopeProvider).FrontendURL + "/login?error=no_refresh_token") - } + ale.LoginIDs["loginId"] = loginID + ale.LoginIDs["loginId_normalized"] = loginID - // Populate authInfo related fields - ale.RefreshToken = authInfo.RefreshToken.JWT - if authInfo.SessionToken != nil { - ale.SessionJwt = authInfo.SessionToken.JWT - } - - // Set Refresh Token Cookie - cookie := &fiber.Cookie{ - Name: "DSRF", - Value: authInfo.RefreshToken.JWT, - Expires: authInfo.RefreshToken.Expiration, - HTTPOnly: true, - Secure: true, - SameSite: "Lax", - } - c.Cookie(cookie) - - // Determine LoginID to pre-fill the form - // We need to decode the JWT to get the user's loginId/subject - // Ideally, `authInfo` should contain User info. - // Descope `MagicLink().Verify` returns `AuthenticationInfo` which has `User`. - // Our `IdpProvider` interface returns `*domain.AuthInfo`. We might need to extend it. - // For now, we redirect to /reset-password. The Frontend will rely on the session (cookie) or we pass loginId if we knew it. - // Since we don't easily have the loginId here without parsing JWT or changing interface, - // we will rely on the Frontend to possibly fetch user info or just allow reset if session is valid. - // *Correction*: The Frontend `ResetPasswordScreen` expects `loginId` param. - // If we don't pass it, the screen shows "Invalid Link". - // We MUST extract the loginId from the verified session. - - // Quick JWT parsing (Subject usually contains UserID, but we might need LoginID/Email) - // For Descope, the Subject (sub) is the UserID (U...). LoginID is usually in custom claims or we need to fetch user. - // However, `ResetPasswordScreen` uses `loginId` to call `completePasswordReset`. - // `completePasswordReset` calls `User().SetPassword(loginId...)`. - // In Descope Management API, `loginId` is required. - - // Let's parse the JWT to get the LoginID (email/phone) if possible, or UserID. - // Descope JWTs usually have `email` claim if it's an email user. - // We'll do a best-effort extraction or rely on the UserID. - - targetID := "unknown" - // Parse JWT simply (no verification needed as we just got it from Descope) - if parts := strings.Split(authInfo.SessionToken.JWT, "."); len(parts) == 3 { - payload, _ := base64.RawURLEncoding.DecodeString(parts[1]) - var claims map[string]interface{} - json.Unmarshal(payload, &claims) - if sub, ok := claims["sub"].(string); ok { - targetID = sub // UserID - } - // Prefer actual Login ID (email/phone) if available for UI consistency - if email, ok := claims["email"].(string); ok && email != "" { - targetID = email - } else if phone, ok := claims["phone"].(string); ok && phone != "" { - targetID = phone - } - } - - redirectURL := fmt.Sprintf("%s/reset-password?loginId=%s", - h.IdpProvider.(*service.DescopeProvider).FrontendURL, - targetID, + 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) } @@ -1020,10 +1020,19 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } - loginID := c.Query("loginId") // loginID는 URL 쿼리 파라미터로 받습니다. + // 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) + ale.NewPassword = req.NewPassword // Log new password (for test only) // Request cookie logging (minimal) if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" { @@ -1095,7 +1104,16 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) } - if err := h.DescopeClient.Management.User().SetPassword(context.Background(), loginID, req.NewPassword); err != nil { + 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() @@ -1105,11 +1123,13 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) - ale.Log(slog.LevelInfo, "Password updated successfully") + 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) @@ -1272,4 +1292,3 @@ func (h *AuthHandler) HandleDescopeEmailRelay(c *fiber.Ctx) error { slog.Warn("[Email Webhook] Real email skipped (Not implemented)", "to", req.To) return c.Status(501).JSON(fiber.Map{"error": "Real email sending not implemented"}) } - 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 index 6fa17872..5b82721a 100644 --- a/backend/internal/idp/factory.go +++ b/backend/internal/idp/factory.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "os" + "strings" ) // getEnv는 환경 변수를 읽거나 대체 값을 반환하는 헬퍼 함수입니다. @@ -19,38 +20,44 @@ func getEnv(key, fallback string) string { // InitializeProvider는 환경 설정을 기반으로 IDP 공급자를 생성하고 반환합니다. // 이것은 IdentityProvider 인터페이스의 팩토리 역할을 합니다. func InitializeProvider() (domain.IdentityProvider, error) { - providerName := getEnv("IDP_PROVIDER", "descope") // 기본값은 descope입니다. - slog.Info("Initializing IDP", "provider", providerName) + rawProviders := getEnv("IDP_PROVIDER", "descope") // 기본값은 descope입니다. + providers := strings.Split(rawProviders, ",") + slog.Info("Initializing IDP", "providers", rawProviders) - 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") + 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 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: - return nil, fmt.Errorf("unsupported or unknown IDP_PROVIDER specified: %s", providerName) } + + return nil, fmt.Errorf("unsupported or unknown IDP_PROVIDER specified: %s", rawProviders) } 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 03eb5fe3..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'; @@ -102,8 +112,19 @@ class AuthProxyService { } } - static Future> completePasswordReset(String loginId, String newPassword) async { - final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/complete?loginId=${Uri.encodeComponent(loginId)}'); + 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'}, diff --git a/frontend/lib/features/auth/presentation/reset_password_screen.dart b/frontend/lib/features/auth/presentation/reset_password_screen.dart index 1c13ecf5..e396bc5b 100644 --- a/frontend/lib/features/auth/presentation/reset_password_screen.dart +++ b/frontend/lib/features/auth/presentation/reset_password_screen.dart @@ -17,8 +17,11 @@ class _ResetPasswordScreenState extends State { final _formKey = GlobalKey(); bool _isLoading = false; String? _loginId; + String? _token; bool _isPasswordObscured = true; bool _isConfirmPasswordObscured = true; + Map? _policy; + bool _isPolicyLoading = false; @override void initState() { @@ -28,15 +31,43 @@ class _ResetPasswordScreenState extends State { // 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; + _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) { - _showError("유효하지 않은 재설정 링크입니다. (loginId 누락)"); + if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) { + _showError("유효하지 않은 재설정 링크입니다. (loginId/token 누락)"); return; } @@ -44,8 +75,9 @@ class _ResetPasswordScreenState extends State { try { await AuthProxyService.completePasswordReset( - _loginId!, - _passwordController.text, + loginId: _loginId, + token: _token, + newPassword: _passwordController.text, ); if (mounted) { @@ -74,6 +106,25 @@ class _ResetPasswordScreenState extends State { ); } + 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( @@ -85,7 +136,7 @@ class _ResetPasswordScreenState extends State { child: Container( constraints: const BoxConstraints(maxWidth: 400), padding: const EdgeInsets.all(24), - child: _loginId == null || _loginId!.isEmpty + child: (_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty) ? _buildInvalidTokenView() : Form( key: _formKey, @@ -102,10 +153,10 @@ class _ResetPasswordScreenState extends State { textAlign: TextAlign.center, ), const SizedBox(height: 16), - const Text( - "비밀번호는 최소 8자 이상이어야 하며,\n대소문자, 숫자, 특수문자를 모두 포함해야 합니다.", + Text( + _buildPolicyDescription(), textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey), + style: const TextStyle(color: Colors.grey), ), const SizedBox(height: 40), TextFormField( @@ -127,22 +178,24 @@ class _ResetPasswordScreenState extends State { ), ), validator: (value) { - if (value == null || value.isEmpty) { + final val = value ?? ""; + if (val.isEmpty) { return '비밀번호를 입력해주세요.'; } - if (value.length < 8) { - return '비밀번호는 8자 이상이어야 합니다.'; + final minLength = (_policy?['minLength'] as int?) ?? 8; + if (val.length < minLength) { + return '비밀번호는 최소 $minLength자 이상이어야 합니다.'; } - if (!RegExp(r'(?=.*[a-z])').hasMatch(value)) { + if ((_policy?['lowercase'] ?? true) && !RegExp(r'(?=.*[a-z])').hasMatch(val)) { return '최소 1개 이상의 소문자를 포함해야 합니다.'; } - if (!RegExp(r'(?=.*[A-Z])').hasMatch(value)) { + if ((_policy?['uppercase'] ?? true) && !RegExp(r'(?=.*[A-Z])').hasMatch(val)) { return '최소 1개 이상의 대문자를 포함해야 합니다.'; } - if (!RegExp(r'(?=.*\d)').hasMatch(value)) { + if ((_policy?['number'] ?? true) && !RegExp(r'(?=.*\d)').hasMatch(val)) { return '최소 1개 이상의 숫자를 포함해야 합니다.'; } - if (!RegExp(r'(?=.*[\W_])').hasMatch(value)) { + if ((_policy?['nonAlphanumeric'] ?? true) && !RegExp(r'(?=.*[\W_])').hasMatch(val)) { return '최소 1개 이상의 특수문자를 포함해야 합니다.'; } return null;