1
0
forked from baron/baron-sso

Refactor password reset flow

This commit is contained in:
Lectom C Han
2026-01-27 11:16:44 +09:00
committed by kyy
parent 739da39a61
commit 72a36701da
9 changed files with 606 additions and 149 deletions

View File

@@ -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 ...
IDP_PROVIDER=descopse, hydra ...

View File

@@ -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)

View File

@@ -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(`
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px; max-width: 500px;">
<h2 style="color: #1A1F2C;">Baron SSO 비밀번호 재설정</h2>
<p>아래 버튼을 클릭해 비밀번호 재설정을 진행해 주세요. 링크는 15분간 유효합니다.</p>
<div style="margin: 30px 0;">
<a href="%s" style="background-color: #1A1F2C; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">비밀번호 재설정</a>
</div>
<p style="font-size: 12px; color: #888;">요청하지 않았다면 이 메일을 무시하세요.</p>
</div>
`, 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"})
}

View File

@@ -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"])
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> completePasswordReset(String loginId, String newPassword) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/complete?loginId=${Uri.encodeComponent(loginId)}');
static Future<Map<String, dynamic>> completePasswordReset({
String? loginId,
String? token,
required String newPassword,
}) async {
final query = <String, String>{};
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'},

View File

@@ -17,8 +17,11 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
String? _loginId;
String? _token;
bool _isPasswordObscured = true;
bool _isConfirmPasswordObscured = true;
Map<String, dynamic>? _policy;
bool _isPolicyLoading = false;
@override
void initState() {
@@ -28,15 +31,43 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
// 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<void> _loadPolicy() async {
setState(() {
_isPolicyLoading = true;
});
try {
final policy = await AuthProxyService.fetchPasswordPolicy();
if (mounted) {
setState(() {
_policy = policy;
});
}
} catch (_) {
// 실패해도 기본 검증 로직 사용
} finally {
if (mounted) {
setState(() {
_isPolicyLoading = false;
});
}
}
}
Future<void> _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<ResetPasswordScreen> {
try {
await AuthProxyService.completePasswordReset(
_loginId!,
_passwordController.text,
loginId: _loginId,
token: _token,
newPassword: _passwordController.text,
);
if (mounted) {
@@ -74,6 +106,25 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
);
}
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 = <String>["최소 ${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<ResetPasswordScreen> {
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<ResetPasswordScreen> {
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<ResetPasswordScreen> {
),
),
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;