diff --git a/IDP_ARCHITECTURE.md b/IDP_ARCHITECTURE.md new file mode 100644 index 00000000..74eb32df --- /dev/null +++ b/IDP_ARCHITECTURE.md @@ -0,0 +1,47 @@ +# Baron SSO IDP 아키텍처 설명 + +이 문서는 Baron SSO 백엔드에서 IDP(Identity Provider)를 관리하는 핵심 파일들의 역할과 상호작용을 설명합니다. 이 아키텍처의 핵심 목표는 **특정 IDP 구현으로부터 비즈니스 로직을 분리**하여 유연하고 확장 가능한 구조를 만드는 것입니다. + +--- + +## 파일별 역할 + +### 1. `backend/internal/domain/idp_models.go` (설계도 / 계약서) + +이 파일은 `IdentityProvider` 인터페이스와 `BrokerUser` 표준 사용자 모델을 정의하여, IDP가 제공해야 할 기능과 데이터 구조를 추상화합니다. 시스템이 IDP의 구체적인 구현과 독립적으로 동작하게 합니다. + +### 2. `backend/internal/idp/factory.go` (부품 공장) + +이 파일은 환경 변수(`IDP_PROVIDER`)에 따라 Descope와 같은 특정 IDP 구현체를 생성하고, 이를 `IdentityProvider` 인터페이스 타입으로 반환하는 팩토리 역할을 합니다. + +### 3. `backend/cmd/server/main.go` (조립 라인 / 최종 소비자) + +이 파일은 `factory.go`를 통해 IDP 객체를 초기화하고, `idp_models.go`의 `BrokerUser` 모델과의 호환성을 검증한 후, 애플리케이션의 나머지 부분에서 IDP를 활용하여 인증 및 인가 로직을 수행합니다. + +--- + +## 연계 동작 흐름 + +```mermaid +sequenceDiagram + participant M as main.go (소비자) + participant F as factory.go (공장) + participant D as idp_models.go (계약서) + + M->>F: 1. idp.InitializeProvider() 호출 + F->>F: 2. .env 확인 (IDP_PROVIDER="descope") + F->>F: 3. Descope 객체 생성 (service.NewDescopeProvider) + Note over F, D: 생성된 객체는
IdentityProvider 인터페이스를 구현 + F-->>M: 4. IdentityProvider로 포장된 객체 반환 + M->>D: 5. BrokerUser 호환성 검증
(idpProvider.GetMetadata() 호출) + M->>M: 6. 검증 통과 후 서버 실행 +``` + +--- + +## 이 아키텍처의 장점 + +- **느슨한 결합 (Loose Coupling)**: `main.go`는 Descope의 구체적인 구현을 몰라도 되므로, IDP가 변경되어도 `main.go` 코드는 수정할 필요가 없습니다. +- **확장성 (Extensibility)**: 새로운 IDP(예: `Keycloak`)를 추가하고 싶을 때, `IdentityProvider` 인터페이스를 구현하는 새로운 서비스와 `factory.go`에 `case` 문 하나만 추가하면 됩니다. +- **테스트 용이성 (Testability)**: 단위 테스트 시, 실제 IDP 대신 가짜(Mock) `IdentityProvider` 객체를 쉽게 만들어 주입할 수 있습니다. +- **조기 실패 (Fail-Fast)**: 서버 시작 시점에 설정을 검증함으로써, 런타임에 발생할 수 있는 치명적인 오류를 사전에 방지합니다. diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 58000329..5391faf4 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -228,6 +228,11 @@ func main() { auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) auth.Post("/password/login", authHandler.PasswordLogin) + + // ✅ 비밀번호 재설정 (추가) + auth.Post("/password-reset/init", authHandler.InitPasswordReset) + auth.Post("/password-reset/confirm", authHandler.ConfirmPasswordReset) + 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 5a431c2a..eb365509 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -37,6 +37,8 @@ const ( maxSignupFailures = 5 emailCodeTTL = 5 * time.Minute smsCodeTTL = 3 * time.Minute + prefixPwdResetToken = "pwdreset_token:" + pwdResetExpiration = 15 * time.Minute ) type AuthHandler struct { @@ -865,3 +867,138 @@ 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"}) } + +func (h *AuthHandler) InitPasswordReset(c *fiber.Ctx) error { + var req struct { + LoginID string `json:"loginId"` + } + + if err := c.BodyParser(&req); err != nil || strings.TrimSpace(req.LoginID) == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + loginID := strings.ReplaceAll(req.LoginID, "-", "") + loginID = strings.ReplaceAll(loginID, " ", "") + + // 토큰 생성 + Redis 저장 + token := GenerateSecureToken(16) + tokenKey := prefixPwdResetToken + token + + payload, _ := json.Marshal(map[string]string{ + "loginId": loginID, + }) + h.RedisService.Set(tokenKey, string(payload), pwdResetExpiration) + + // 링크 생성 (프론트에서 token 받아 새 비번 입력 페이지로 이동) + frontendURL := os.Getenv("FRONTEND_URL") + if frontendURL == "" { + frontendURL = "https://sso.hmac.kr" + } + + // 예: https://sso.hmac.kr/password-reset?token=xxxx + link := fmt.Sprintf("%s/password-reset?token=%s", frontendURL, token) + + // 발송 + if strings.Contains(loginID, "@") { + subject := "[Baron SSO] 비밀번호 재설정" + body := fmt.Sprintf(` +
+

비밀번호 재설정

+

아래 버튼을 눌러 새 비밀번호를 설정해 주세요. 이 링크는 %d분 동안 유효합니다.

+
+ 비밀번호 재설정 +
+

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

+
`, int(pwdResetExpiration.Minutes()), link) + + if err := h.EmailService.SendEmail(loginID, subject, body); err != nil { + slog.Error("[PwdResetInit] Email failed", "loginID", loginID, "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send Email"}) + } + } else { + content := fmt.Sprintf( + "[Baron SSO] 비밀번호 재설정 링크(%d분 유효): %s", + int(pwdResetExpiration.Minutes()), + link, + ) + if err := h.SmsService.SendSms(loginID, content); err != nil { + slog.Error("[PwdResetInit] SMS failed", "loginID", loginID, "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) + } + } + + slog.Info("[PwdResetInit] Sent reset link", "loginID", loginID) + return c.JSON(fiber.Map{"status": "ok"}) +} + +func (h *AuthHandler) ConfirmPasswordReset(c *fiber.Ctx) error { + var req struct { + Token string `json:"token"` + NewPassword string `json:"newPassword"` + NewPasswordConfirm string `json:"newPasswordConfirm"` + } + + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + if req.Token == "" || req.NewPassword == "" || req.NewPasswordConfirm == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing required fields"}) + } + + if req.NewPassword != req.NewPasswordConfirm { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password confirmation does not match"}) + } + + if h.DescopeClient == nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"}) + } + + // 1) token 검증(=Redis) + tokenKey := prefixPwdResetToken + req.Token + val, err := h.RedisService.Get(tokenKey) + if err != nil || val == "" { + slog.Warn("[PwdResetConfirm] token not found/expired", "token", req.Token) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"}) + } + + var data map[string]string + _ = json.Unmarshal([]byte(val), &data) + + loginID := data["loginId"] + if loginID == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid token payload"}) + } + + // (선택) 1회성 토큰 처리: 먼저 삭제(레이스가 걱정되면 처리 순서 조정) + _ = h.RedisService.Delete(tokenKey) + + // 2) Management API로 Active Password 설정 + if err := h.DescopeClient.Management.User().SetActivePassword( + context.Background(), + loginID, + req.NewPassword, + ); err != nil { + slog.Error("[PwdResetConfirm] SetActivePassword failed", + "loginID", loginID, + "error", err, + ) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update password"}) + } + + // 3) 새 비밀번호로 자동 로그인 + authInfo, err := h.DescopeClient.Auth.Password().SignIn(context.Background(), loginID, req.NewPassword, nil) + if err != nil { + slog.Warn("[PwdResetConfirm] SignIn failed after reset", "loginID", loginID, "error", err) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"}) + } + + slog.Info("[PwdResetConfirm] Success", "loginID", loginID) + return c.JSON(fiber.Map{ + "status": "ok", + "sessionJwt": authInfo.SessionToken.JWT, + // 필요하면 refresh도 내려주기 + // "refreshJwt": authInfo.RefreshToken.JWT, + }) +} + diff --git a/frontend/lib/features/auth/presentation/login_screen.dart b/frontend/lib/features/auth/presentation/login_screen.dart index 189510b1..b02c0b5e 100644 --- a/frontend/lib/features/auth/presentation/login_screen.dart +++ b/frontend/lib/features/auth/presentation/login_screen.dart @@ -498,7 +498,7 @@ class _LoginScreenState extends ConsumerState TabBar( controller: _tabController, tabs: const [ - Tab(text: "이메일/전화번호"), + Tab(text: "비밀번호"), Tab(text: "로그인 링크"), Tab(text: "QR 코드"), ],