@@ -225,7 +278,7 @@ func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error {
h.saveSignupState(key, newState, signupStateExpiration)
// 4. Send SMS
- content := fmt.Sprintf("[Baron SSO] 인증번호 [%s]를 입력해주세요.", code)
+ content := fmt.Sprintf("[Baron 통합로그인] 인증번호 [%s]를 입력해주세요.", code)
go h.SmsService.SendSms(phone, content)
return c.JSON(fiber.Map{"message": "Verification code sent"})
@@ -297,26 +350,10 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Terms must be accepted"})
}
- // Password Validation
- if len(req.Password) < 12 {
- return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must be at least 12 characters"})
- }
- // Check complexity (at least 2 types: lower, upper, digit, special)
- types := 0
- if strings.ContainsAny(req.Password, "abcdefghijklmnopqrstuvwxyz") {
- types++
- }
- if strings.ContainsAny(req.Password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
- types++
- }
- if strings.ContainsAny(req.Password, "0123456789") {
- types++
- }
- if strings.ContainsAny(req.Password, "!@#$%^&*()_+-=[]{}|;:,.<>?") {
- types++
- }
- if types < 2 {
- return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least 2 types of characters (letters, numbers, symbols)"})
+ // 비밀번호 정책 검증
+ policy := h.resolvePasswordPolicy()
+ if err := validatePasswordWithPolicy(policy, req.Password); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
// 2. Verify Auth Status (Redis)
@@ -363,6 +400,9 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
providerID, err := h.IdpProvider.CreateUser(brokerUser, req.Password)
if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Signup method not supported"})
+ }
slog.Error("[Signup] Failed to create user via IDP", "provider", h.IdpProvider.Name(), "error", err)
if strings.Contains(err.Error(), "already exists") {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"})
@@ -398,6 +438,19 @@ func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string {
return parts[1]
}
+// normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다.
+func normalizePhoneForLoginID(phone string) string {
+ normalized := strings.ReplaceAll(phone, "-", "")
+ normalized = strings.ReplaceAll(normalized, " ", "")
+ if strings.HasPrefix(normalized, "010") {
+ return "+82" + normalized[1:]
+ }
+ if strings.HasPrefix(normalized, "82") {
+ return "+" + normalized
+ }
+ return normalized
+}
+
func (h *AuthHandler) getSignupState(key string) (*signupState, error) {
val, err := h.RedisService.Get(key)
if err != nil || val == "" {
@@ -418,23 +471,82 @@ 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 userfront 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"})
+// resolvePasswordPolicy는 IDP 정책을 우선 사용하고, 없으면 기본 정책을 반환합니다.
+func (h *AuthHandler) resolvePasswordPolicy() *domain.PasswordPolicy {
+ if h.IdpProvider != nil {
+ policy, err := h.IdpProvider.GetPasswordPolicy()
+ if err == nil && policy != nil {
+ return policy
+ }
+ }
+ return &domain.PasswordPolicy{
+ MinLength: 12,
+ Lowercase: true,
+ Uppercase: false,
+ Number: true,
+ NonAlphanumeric: true,
+ MinCharacterTypes: 0,
+ }
+}
+
+// validatePasswordWithPolicy는 정책 기준으로 비밀번호를 검증합니다.
+func validatePasswordWithPolicy(policy *domain.PasswordPolicy, password string) error {
+ if policy == nil {
+ return nil
+ }
+ if policy.MinLength > 0 && len(password) < policy.MinLength {
+ return fmt.Errorf("비밀번호는 최소 %d자 이상이어야 합니다", policy.MinLength)
}
- policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background())
- if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ types := 0
+ hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
+ hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
+ hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
+ hasSymbol := regexp.MustCompile(`[\W_]`).MatchString(password)
+ if hasLower {
+ types++
}
+ if hasUpper {
+ types++
+ }
+ if hasNumber {
+ types++
+ }
+ if hasSymbol {
+ types++
+ }
+
+ if policy.MinCharacterTypes > 0 && types < policy.MinCharacterTypes {
+ return fmt.Errorf("비밀번호는 영문 대/소문자/숫자/특수문자 중 %d가지 이상을 포함해야 합니다", policy.MinCharacterTypes)
+ }
+
+ if policy.Lowercase && !hasLower {
+ return fmt.Errorf("비밀번호에 소문자가 포함되어야 합니다")
+ }
+ if policy.Uppercase && !hasUpper {
+ return fmt.Errorf("비밀번호에 대문자가 포함되어야 합니다")
+ }
+ if policy.Number && !hasNumber {
+ return fmt.Errorf("비밀번호에 숫자가 포함되어야 합니다")
+ }
+ if policy.NonAlphanumeric && !hasSymbol {
+ return fmt.Errorf("비밀번호에 특수문자가 포함되어야 합니다")
+ }
+
+ return nil
+}
+
+// GetPasswordPolicy는 IDP 기준 비밀번호 정책을 제공합니다.
+func (h *AuthHandler) GetPasswordPolicy(c *fiber.Ctx) error {
+ policy := h.resolvePasswordPolicy()
return c.JSON(fiber.Map{
- "minLength": policy.MinLength,
- "lowercase": policy.Lowercase,
- "uppercase": policy.Uppercase,
- "number": policy.Number,
- "nonAlphanumeric": policy.NonAlphanumeric,
+ "minLength": policy.MinLength,
+ "lowercase": policy.Lowercase,
+ "uppercase": policy.Uppercase,
+ "number": policy.Number,
+ "nonAlphanumeric": policy.NonAlphanumeric,
+ "minCharacterTypes": policy.MinCharacterTypes,
})
}
@@ -449,7 +561,7 @@ func (h *AuthHandler) SendSms(c *fiber.Ctx) error {
sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "")
rand.Seed(time.Now().UnixNano())
code := fmt.Sprintf("%06d", rand.Intn(1000000))
- content := fmt.Sprintf("[Baron SSO] 인증번호: %s", code)
+ content := fmt.Sprintf("[Baron 통합로그인] 인증번호: %s", code)
h.RedisService.StoreVerificationCode(sanitizedPhone, code)
if err := h.SmsService.SendSms(sanitizedPhone, content); err != nil {
@@ -475,12 +587,26 @@ func (h *AuthHandler) VerifySms(c *fiber.Ctx) error {
h.RedisService.DeleteVerificationCode(sanitizedPhone)
- // Note: In a real scenario, you might want to generate a Descope JWT here too
- // using the same logic as VerifyMagicLink, but for now returning a placeholder
- // or you can call the Descope logic if needed.
- token := "sms-verified-placeholder-token"
+ if h.IdpProvider == nil {
+ return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Authentication service not configured"})
+ }
- return c.JSON(fiber.Map{"token": token})
+ loginID := normalizePhoneForLoginID(req.PhoneNumber)
+ authInfo, err := h.IdpProvider.IssueSession(loginID)
+ if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
+ }
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
+ }
+ if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
+ }
+
+ return c.JSON(fiber.Map{
+ "token": authInfo.SessionToken.JWT,
+ "message": "Login successful",
+ })
}
// InitEnchantedLink - Custom Implementation (Restored)
@@ -493,34 +619,77 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
loginID := strings.ReplaceAll(req.LoginID, "-", "")
loginID = strings.ReplaceAll(loginID, " ", "")
+ lookupLoginID := loginID
+ if !strings.Contains(loginID, "@") {
+ lookupLoginID = normalizePhoneForLoginID(loginID)
+ }
- // [New] Check if user exists before sending link
- if h.DescopeClient != nil {
- user, err := h.DescopeClient.Management.User().Load(context.Background(), loginID)
- if err != nil || user == nil {
- // Try searching by phone if not found by LoginID
- searchPhone := loginID
- if !strings.Contains(searchPhone, "@") {
- if strings.HasPrefix(searchPhone, "010") {
- searchPhone = "+82" + searchPhone[1:]
- } else if strings.HasPrefix(searchPhone, "82") {
- searchPhone = "+" + searchPhone
- }
- }
- searchOptions := &descope.UserSearchOptions{
- Phones: []string{searchPhone},
- Limit: 1,
- }
- users, _, errSearch := h.DescopeClient.Management.User().SearchAll(context.Background(), searchOptions)
- if errSearch != nil || len(users) == 0 {
- slog.Warn("[Enchanted] User not found", "loginID", loginID)
- return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"})
- }
- // 검색 결과가 있더라도 loginID는 사용자가 입력한 원래 값을 유지 (발송 수단 결정을 위해)
+ // 사용자 존재 여부 확인
+ if h.IdpProvider == nil {
+ return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
+ }
+ exists, err := h.IdpProvider.UserExists(lookupLoginID)
+ if err != nil {
+ slog.Warn("[Enchanted] IDP user lookup failed", "loginID", loginID, "error", err)
+ return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
+ }
+ if !exists {
+ slog.Warn("[Enchanted] User not found", "loginID", loginID)
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"})
+ }
+
+ userfrontURL := os.Getenv("USERFRONT_URL")
+ if userfrontURL == "" {
+ userfrontURL = "http://sso.hmac.kr"
+ }
+ if req.URI != "" {
+ userfrontURL = req.URI
+ }
+
+ if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" {
+ keyLoginID := lookupLoginID
+ if init.LoginID != "" {
+ keyLoginID = init.LoginID
}
+ if !strings.Contains(loginID, "@") && req.CodeOnly {
+ _ = h.RedisService.Set(prefixLoginCodeSmsOnly+keyLoginID, "1", loginCodeExpiration)
+ } else {
+ _ = h.RedisService.Delete(prefixLoginCodeSmsOnly + keyLoginID)
+ }
+ if init.FlowID != "" {
+ _ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration)
+ }
+ pendingRef := GenerateSecureToken(3)
+ h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), loginCodeExpiration)
+ _ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration)
+ if !strings.Contains(loginID, "@") && keyLoginID != lookupLoginID {
+ _ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration)
+ _ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration)
+ }
+ expiresIn := 0
+ if !init.ExpiresAt.IsZero() {
+ expiresIn = int(time.Until(init.ExpiresAt).Seconds())
+ }
+ if expiresIn <= 0 {
+ expiresIn = int(loginCodeExpiration.Seconds())
+ }
+ return c.JSON(fiber.Map{
+ "linkId": "Sent",
+ "pendingRef": pendingRef,
+ "maskedEmail": loginID,
+ "mode": init.Mode,
+ "provider": h.IdpProvider.Name(),
+ "expiresIn": expiresIn,
+ "interval": int(minPollInterval.Seconds()),
+ "resendAfter": int(linkResendCooldown.Seconds()),
+ })
+ } else if err != nil && !errors.Is(err, domain.ErrNotSupported) {
+ slog.Error("[Enchanted] Link login init failed", "provider", h.IdpProvider.Name(), "error", err)
+ return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
}
// [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정
+ userCode := GenerateUserCode()
token := GenerateSecureToken(3)
pendingRef := GenerateSecureToken(3)
@@ -528,14 +697,10 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
// Store in Redis
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), defaultExpiration)
- h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, loginID), defaultExpiration)
+ h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration)
// Generate Link
- userfrontURL := os.Getenv("USERFRONT_URL")
slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL)
- if userfrontURL == "" {
- userfrontURL = "http://sso.hmac.kr"
- }
link := fmt.Sprintf("%s/verify/%s", userfrontURL, token)
// Route based on LoginID type
@@ -546,7 +711,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
}
- subject := "[Baron SSO] 로그인 링크"
+ subject := "[Baron 통합로그인] 링크"
body := fmt.Sprintf(`
- `, link)
+ `, link, userCode)
slog.Info("[Enchanted] Sending Email via AWS SES", "loginID", loginID)
if err := h.EmailService.SendEmail(loginID, subject, body); err != nil {
@@ -566,7 +732,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
}
} else {
// Send SMS
- content := fmt.Sprintf("[Baron SSO] 로그인 링크: %s", link)
+ content := fmt.Sprintf("[Baron 통합로그인] 로그인 링크: %s | 코드: %s", link, userCode)
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID)
if err := h.SmsService.SendSms(loginID, content); err != nil {
@@ -579,6 +745,10 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
"linkId": "Sent",
"pendingRef": pendingRef,
"maskedEmail": loginID,
+ "expiresIn": int(defaultExpiration.Seconds()),
+ "interval": int(minPollInterval.Seconds()),
+ "resendAfter": int(linkResendCooldown.Seconds()),
+ "userCode": userCode,
})
}
@@ -589,9 +759,17 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
+ pollKey := prefixPollMeta + "enchanted:" + req.PendingRef
+ if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown {
+ return c.JSON(fiber.Map{
+ "error": "slow_down",
+ "interval": interval,
+ })
+ }
+
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
if err != nil || val == "" {
- return c.JSON(fiber.Map{"status": statusPending})
+ return c.JSON(fiber.Map{"error": "expired_token"})
}
var data map[string]string
@@ -605,7 +783,10 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
})
}
- return c.JSON(fiber.Map{"status": statusPending})
+ return c.JSON(fiber.Map{
+ "error": "authorization_pending",
+ "interval": int(minPollInterval.Seconds()),
+ })
}
// VerifyMagicLink - Validate token and login (Restored)
@@ -632,60 +813,23 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
slog.Info("[Verify] Token valid", "loginID", loginID, "pendingRef", pendingRef)
- // 1. Generate Descope Session Directly (Management SDK)
- if h.DescopeClient == nil {
- slog.Error("[Verify] Descope Client is nil!")
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"})
+ if h.IdpProvider == nil {
+ slog.Error("[Verify] IDP Provider is nil")
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
}
- // [Fix] Search for existing user by phone to prevent fragmentation
- // Normalize Phone Number for Search (E.164)
- searchPhone := loginID
- if !strings.Contains(searchPhone, "@") {
- // If it looks like a KR mobile number (010...), format to +8210...
- if strings.HasPrefix(searchPhone, "010") {
- searchPhone = "+82" + searchPhone[1:]
- } else if strings.HasPrefix(searchPhone, "82") {
- searchPhone = "+" + searchPhone
- }
- }
-
- slog.Info("[Verify] Searching for user", "phone", searchPhone)
- searchOptions := &descope.UserSearchOptions{
- Phones: []string{searchPhone},
- Limit: 1,
- }
-
- var targetLoginID string
- users, _, errSearch := h.DescopeClient.Management.User().SearchAll(context.Background(), searchOptions)
-
- if errSearch == nil && len(users) > 0 {
- if len(users[0].LoginIDs) > 0 {
- targetLoginID = users[0].LoginIDs[0]
- slog.Info("[Verify] User found", "existingLoginID", targetLoginID)
- } else {
- // Should not happen for a valid user, but fallback to UserID or searchPhone
- slog.Warn("[Verify] User found but no LoginIDs, using UserID")
- targetLoginID = users[0].UserID
- }
- } else {
- // [Changed] If not found, do NOT auto-create. Return error.
- slog.Warn("[Verify] User not found by phone", "loginID", searchPhone)
- return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"})
- }
-
- slog.Info("[Verify] Generating embedded link", "loginID", targetLoginID)
- embeddedToken, err := h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0)
+ authInfo, err := h.IdpProvider.IssueSession(loginID)
if err != nil {
- slog.Error("[Verify] Descope Error", "error", err)
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate upstream token"})
+ if errors.Is(err, domain.ErrNotSupported) {
+ slog.Warn("[Verify] IDP session issue not supported", "provider", h.IdpProvider.Name())
+ return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
+ }
+ slog.Error("[Verify] IDP session issue failed", "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
}
-
- slog.Info("[Verify] Exchanging embedded token for session JWT")
- authInfo, err := h.DescopeClient.Auth.MagicLink().Verify(context.Background(), embeddedToken, nil)
- if err != nil {
- slog.Error("[Verify] Final verification failed", "error", err)
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify upstream token"})
+ if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
+ slog.Error("[Verify] IDP returned empty session")
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
}
sessionToken := authInfo.SessionToken.JWT
@@ -702,6 +846,165 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
})
}
+// VerifyLoginCode - Verify Kratos login code and issue session.
+func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
+ var req struct {
+ LoginID string `json:"loginId"`
+ Code string `json:"code"`
+ PendingRef string `json:"pendingRef"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ slog.Error("[LoginCode] Body parse error", "error", err)
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
+ }
+
+ loginID := strings.TrimSpace(req.LoginID)
+ loginID = strings.ReplaceAll(loginID, " ", "+")
+ if loginID == "" || req.Code == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "loginId and code are required"})
+ }
+
+ lookupLoginID := loginID
+ if !strings.Contains(loginID, "@") {
+ lookupLoginID = normalizePhoneForLoginID(loginID)
+ }
+
+ if h.IdpProvider == nil {
+ return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
+ }
+
+ flowID, err := h.RedisService.Get(prefixLoginCode + lookupLoginID)
+ if err != nil || flowID == "" {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"})
+ }
+
+ authInfo, err := h.IdpProvider.VerifyLoginCode(lookupLoginID, flowID, req.Code)
+ if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
+ }
+ slog.Error("[LoginCode] Verify failed", "loginID", loginID, "error", err)
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"})
+ }
+ if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
+ }
+
+ h.RedisService.Delete(prefixLoginCode + lookupLoginID)
+ h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
+
+ pendingRef := strings.TrimSpace(req.PendingRef)
+ if pendingRef == "" {
+ storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID)
+ pendingRef = storedRef
+ }
+ if pendingRef != "" {
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": statusSuccess,
+ "jwt": authInfo.SessionToken.JWT,
+ })
+ h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
+ h.RedisService.Delete(prefixLoginCodePending + lookupLoginID)
+ h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
+ return c.JSON(fiber.Map{
+ "status": "approved",
+ "pendingRef": pendingRef,
+ "provider": h.IdpProvider.Name(),
+ "subject": authInfo.Subject,
+ "message": "Login approved",
+ })
+ }
+
+ return c.JSON(fiber.Map{
+ "token": authInfo.SessionToken.JWT,
+ "sessionJwt": authInfo.SessionToken.JWT,
+ "provider": h.IdpProvider.Name(),
+ "subject": authInfo.Subject,
+ "message": "Login successful",
+ })
+}
+
+// VerifyLoginShortCode - Verify short code (2 letters + 6 digits) and issue/approve session.
+func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
+ var req struct {
+ ShortCode string `json:"shortCode"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ slog.Error("[LoginShortCode] Body parse error", "error", err)
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
+ }
+
+ shortCode := strings.ToUpper(strings.TrimSpace(req.ShortCode))
+ if shortCode == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "shortCode is required"})
+ }
+
+ val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode)
+ if val == "" {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"})
+ }
+
+ var payload shortLoginCodePayload
+ if err := json.Unmarshal([]byte(val), &payload); err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Invalid code payload"})
+ }
+ if payload.LoginID == "" || payload.Code == "" {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"})
+ }
+
+ if h.IdpProvider == nil {
+ return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
+ }
+
+ flowID, err := h.RedisService.Get(prefixLoginCode + payload.LoginID)
+ if err != nil || flowID == "" {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"})
+ }
+
+ authInfo, err := h.IdpProvider.VerifyLoginCode(payload.LoginID, flowID, payload.Code)
+ if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
+ }
+ slog.Error("[LoginShortCode] Verify failed", "loginID", payload.LoginID, "error", err)
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"})
+ }
+ if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
+ }
+
+ h.RedisService.Delete(prefixLoginCode + payload.LoginID)
+ h.RedisService.Delete(prefixLoginCodeShort + shortCode)
+ h.RedisService.Delete(prefixLoginCodeSmsTarget + payload.LoginID)
+ h.RedisService.Delete(prefixLoginCodeSmsLookup + payload.LoginID)
+
+ if payload.PendingRef != "" {
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": statusSuccess,
+ "jwt": authInfo.SessionToken.JWT,
+ })
+ h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration)
+ h.RedisService.Delete(prefixLoginCodePending + payload.LoginID)
+ return c.JSON(fiber.Map{
+ "status": "approved",
+ "pendingRef": payload.PendingRef,
+ "token": authInfo.SessionToken.JWT,
+ "sessionJwt": authInfo.SessionToken.JWT,
+ "provider": h.IdpProvider.Name(),
+ "subject": authInfo.Subject,
+ "message": "Login approved",
+ })
+ }
+
+ return c.JSON(fiber.Map{
+ "token": authInfo.SessionToken.JWT,
+ "sessionJwt": authInfo.SessionToken.JWT,
+ "provider": h.IdpProvider.Name(),
+ "subject": authInfo.Subject,
+ "message": "Login successful",
+ })
+}
+
// PasswordLogin - Authenticate a user with login ID and password.
func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
startTime := time.Now()
@@ -738,6 +1041,9 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
authInfo, err := h.IdpProvider.SignIn(loginID, req.Password)
if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
+ }
ale.Status = fiber.StatusUnauthorized
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = err.Error()
@@ -844,7 +1150,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
ale.Log(slog.LevelError, "Email service not configured")
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
}
- subject := "[Baron SSO] 비밀번호 재설정"
+ subject := "[Baron 통합로그인] 비밀번호 재설정"
body := fmt.Sprintf(`
Baron SSO 비밀번호 재설정
@@ -863,7 +1169,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
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 {
+ if err := h.SmsService.SendSms(loginID, fmt.Sprintf("[Baron 통합로그인] 비밀번호 재설정 링크: %s", resetLink)); err != nil {
ale.Status = fiber.StatusInternalServerError
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = err.Error()
@@ -973,7 +1279,7 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
return c.Redirect(redirectURL)
}
-// CompletePasswordReset - 제공된 loginID와 새 비밀번호로 Descope에 비밀번호를 업데이트합니다.
+// CompletePasswordReset - 제공된 loginID와 새 비밀번호로 IDP 비밀번호를 업데이트합니다.
// 리프레시 토큰은 요청 쿠키에 포함되어 있어야 합니다.
func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
startTime := time.Now()
@@ -984,7 +1290,6 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
if h.IdpProvider != nil {
providerName = h.IdpProvider.Name()
}
- isDescopeProvider := strings.Contains(strings.ToLower(providerName), "descope")
var req struct {
NewPassword string `json:"newPassword"`
@@ -1033,66 +1338,13 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
// 디버깅을 위해 요청된 새 비밀번호를 로그로 출력
ale.Log(slog.LevelInfo, "Received new password for reset")
- if len(req.NewPassword) < 8 {
+ policy := h.resolvePasswordPolicy()
+ if err := validatePasswordWithPolicy(policy, req.NewPassword); err != nil {
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 (fallback policy)")
- return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError})
- }
-
- if isDescopeProvider && h.DescopeClient != nil {
- // Validate password complexity (Descope only)
- policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background())
- if err != nil {
- ale.Log(slog.LevelWarn, "Failed to fetch password policy, skipping dynamic validation: "+err.Error())
- } else {
- if len(req.NewPassword) < int(policy.MinLength) {
- ale.Status = fiber.StatusBadRequest
- ale.LatencyMs = time.Since(startTime)
- ale.DescopeError = fmt.Sprintf("Password must be at least %d characters long", policy.MinLength)
- ale.Log(slog.LevelWarn, "Validation failed: password too short")
- return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError})
- }
- if policy.Lowercase {
- 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 policy.Uppercase {
- 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 policy.Number {
- 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 policy.NonAlphanumeric {
- 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"})
- }
- }
- }
- } else if isDescopeProvider && h.DescopeClient == nil {
- ale.Log(slog.LevelWarn, "Descope selected but client is nil; skipping policy validation")
+ ale.DescopeError = err.Error()
+ ale.Log(slog.LevelWarn, "Validation failed: "+err.Error())
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
ale.Log(slog.LevelInfo, "Attempting to update password via IDP", slog.String("idp", providerName))
@@ -1125,23 +1377,29 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
pendingRef := GenerateSecureToken(16)
+ qrRef := GenerateSecureAlnumToken(64)
+ if qrRef == "" {
+ qrRef = GenerateSecureToken(16)
+ }
// QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다.
userfrontURL := os.Getenv("USERFRONT_URL")
if userfrontURL == "" {
userfrontURL = "https://sso.hmac.kr"
}
- qrPayload := fmt.Sprintf("%s/approve?ref=%s", userfrontURL, pendingRef)
+ qrPayload := fmt.Sprintf("%s/ql/%s", strings.TrimRight(userfrontURL, "/"), qrRef)
- slog.Info("[QR] Init", "pendingRef", pendingRef, "url", qrPayload)
+ slog.Info("[QR] Init", "pendingRef", pendingRef, "qrRef", qrRef, "url", qrPayload)
// Redis에 초기 상태 저장 (5분 만료)
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute)
+ h.RedisService.Set(prefixQrRef+qrRef, pendingRef, 5*time.Minute)
return c.JSON(fiber.Map{
"qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환
"pendingRef": pendingRef,
"expiresIn": 300,
+ "interval": int(minPollInterval.Seconds()),
})
}
@@ -1154,9 +1412,17 @@ func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
}
+ pollKey := prefixPollMeta + "qr:" + req.PendingRef
+ if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "slow_down",
+ "interval": interval,
+ })
+ }
+
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
if err != nil || val == "" {
- return c.JSON(fiber.Map{"status": "expired"})
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "expired_token"})
}
var data map[string]string
@@ -1170,7 +1436,10 @@ func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error {
})
}
- return c.JSON(fiber.Map{"status": statusPending})
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "authorization_pending",
+ "interval": int(minPollInterval.Seconds()),
+ })
}
// ScanQRLogin - Step 3: 모바일 앱에서 QR 스캔 후 승인할 때 호출합니다.
@@ -1185,21 +1454,66 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
}
- slog.Info("[QR] Scan & Approve", "pendingRef", req.PendingRef)
+ rawRef := strings.TrimSpace(req.PendingRef)
+ pendingRef, err := h.resolveQrPendingRef(rawRef)
+ if err != nil || pendingRef == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid pendingRef"})
+ }
+
+ slog.Info("[QR] Scan & Approve", "pendingRef", pendingRef)
// 1. Redis에서 세션 확인
- val, err := h.RedisService.Get(prefixSession + req.PendingRef)
+ val, err := h.RedisService.Get(prefixSession + pendingRef)
if err != nil || val == "" {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Session expired or not found"})
}
- // 2. 모바일 유저의 토큰으로 새 세션 토큰(웹용)을 발행하거나 그대로 전달
+ if req.Token == "" {
+ cookie := c.Get(fiber.HeaderCookie)
+ if cookie == "" {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing session token"})
+ }
+ _, traits, err := h.getKratosIdentityWithCookie(cookie)
+ if err != nil {
+ slog.Warn("[QR] Cookie session invalid", "error", err)
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
+ }
+ loginID := pickLoginIDFromTraits(traits)
+ if loginID == "" {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
+ }
+ if !strings.Contains(loginID, "@") {
+ loginID = normalizePhoneForLoginID(loginID)
+ }
+ if err := h.startQrCodeLoginForQr(loginID, pendingRef, rawRef); err != nil {
+ slog.Error("[QR] Start code login failed", "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"})
+ }
+ return c.JSON(fiber.Map{"message": "QR Login Approved"})
+ }
- sessionData, _ := json.Marshal(map[string]string{
- "status": statusSuccess,
- "jwt": req.Token,
- })
- h.RedisService.Set(prefixSession+req.PendingRef, string(sessionData), 5*time.Minute)
+ // 2. 모바일 토큰은 승인 검증용으로만 사용하고, 웹 전용 세션을 새로 발급
+ if sessionToken, err := h.tryIssueDescopeQrSession(c, req.Token); err != nil {
+ slog.Error("[QR] Issue web session failed", "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"})
+ } else if sessionToken != "" {
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": statusSuccess,
+ "jwt": sessionToken,
+ })
+ h.RedisService.Set(prefixSession+pendingRef, string(sessionData), 5*time.Minute)
+ return c.JSON(fiber.Map{"message": "QR Login Approved"})
+ }
+
+ loginID, err := h.resolveKratosLoginID(req.Token)
+ if err != nil {
+ slog.Warn("[QR] Invalid token", "error", err)
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
+ }
+ if err := h.startQrCodeLoginForQr(loginID, pendingRef, rawRef); err != nil {
+ slog.Error("[QR] Start code login failed", "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"})
+ }
return c.JSON(fiber.Map{"message": "QR Login Approved"})
}
@@ -1209,6 +1523,366 @@ func (h *AuthHandler) ProxyToDescope(c *fiber.Ctx, path string, payload interfac
return c.Status(501).SendString("Descope Proxy Disabled")
}
+type kratosCourierRequest struct {
+ Recipient string `json:"recipient"`
+ TemplateType string `json:"template_type"`
+ TemplateData map[string]interface{} `json:"template_data"`
+ Subject string `json:"subject"`
+ Body string `json:"body"`
+}
+
+// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다.
+func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
+ var req kratosCourierRequest
+ if err := c.BodyParser(&req); err != nil {
+ slog.Error("[Kratos Courier] Body parsing failed", "error", err)
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
+ }
+
+ if req.Recipient == "" {
+ slog.Warn("[Kratos Courier] Missing recipient")
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing recipient"})
+ }
+
+ loginID := req.Recipient
+ if !strings.Contains(loginID, "@") {
+ loginID = normalizePhoneForLoginID(loginID)
+ }
+ if pendingRef, _ := h.RedisService.Get(prefixLoginCodeQrPending + loginID); pendingRef != "" {
+ code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code"))
+ if code == "" {
+ slog.Error("[QR] Missing login code in courier", "loginID", loginID)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Missing login code"})
+ }
+ flowID, _ := h.RedisService.Get(prefixLoginCode + loginID)
+ if flowID == "" {
+ slog.Error("[QR] Missing login flow for code verify", "loginID", loginID)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Login flow expired"})
+ }
+ authInfo, err := h.IdpProvider.VerifyLoginCode(loginID, flowID, code)
+ if err != nil || authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
+ slog.Error("[QR] Code verify failed", "loginID", loginID, "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify login code"})
+ }
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": statusSuccess,
+ "jwt": authInfo.SessionToken.JWT,
+ })
+ h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
+ h.RedisService.Delete(prefixLoginCodeQrPending + loginID)
+ h.RedisService.Delete(prefixLoginCode + loginID)
+ h.RedisService.Delete(prefixLoginCodeQr + pendingRef)
+ slog.Info("[QR] Code verified and session issued", "loginID", loginID, "pendingRef", pendingRef)
+ return c.JSON(fiber.Map{"status": "ok"})
+ }
+ if pendingRef, _ := h.RedisService.Get(prefixQrPending + loginID); pendingRef != "" {
+ code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code"))
+ if code == "" {
+ slog.Error("[QR] Missing login code in courier", "loginID", loginID)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Missing login code"})
+ }
+ flowID, _ := h.RedisService.Get(prefixLoginCode + loginID)
+ if flowID == "" {
+ slog.Error("[QR] Missing login flow for code verify", "loginID", loginID)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Login flow expired"})
+ }
+ authInfo, err := h.IdpProvider.VerifyLoginCode(loginID, flowID, code)
+ if err != nil || authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
+ slog.Error("[QR] Code verify failed", "loginID", loginID, "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify login code"})
+ }
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": statusSuccess,
+ "jwt": authInfo.SessionToken.JWT,
+ })
+ h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
+ h.RedisService.Delete(prefixQrPending + loginID)
+ h.RedisService.Delete(prefixLoginCode + loginID)
+ h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID)
+ h.RedisService.Delete(prefixLoginCodeSmsLookup + loginID)
+ slog.Info("[QR] Code verified and session issued", "loginID", loginID, "pendingRef", pendingRef)
+ return c.JSON(fiber.Map{"status": "ok"})
+ }
+
+ subject, body := h.buildKratosCourierMessage(&req)
+ if strings.TrimSpace(body) == "" {
+ slog.Warn("[Kratos Courier] Empty body", "recipient", req.Recipient, "template", req.TemplateType)
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Empty message"})
+ }
+
+ if strings.Contains(req.Recipient, "@") {
+ if target, _ := h.RedisService.Get(prefixLoginCodeSmsTarget + req.Recipient); target != "" {
+ phone := sanitizePhoneForSms(target)
+ smsBody := h.buildKratosShortSmsBody(&req, req.Recipient, phone)
+ if smsBody == "" {
+ smsBody = body
+ }
+ if err := h.SmsService.SendSms(phone, smsBody); err != nil {
+ slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
+ }
+ slog.Info("[Kratos Courier] SMS sent (email relay)", "to", phone, "template", req.TemplateType)
+ return c.JSON(fiber.Map{"status": "ok"})
+ }
+ }
+
+ if strings.Contains(req.Recipient, "@") {
+ if h.EmailService == nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
+ }
+ if shortSubject, shortBody := h.buildKratosShortEmailBody(&req, req.Recipient); shortBody != "" {
+ subject = shortSubject
+ body = shortBody
+ }
+ if err := h.EmailService.SendEmail(req.Recipient, subject, body); err != nil {
+ slog.Error("[Kratos Courier] Email send failed", "to", req.Recipient, "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send email"})
+ }
+ slog.Info("[Kratos Courier] Email sent", "to", req.Recipient, "template", req.TemplateType)
+ return c.JSON(fiber.Map{"status": "ok"})
+ }
+
+ if h.SmsService == nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SMS service not configured"})
+ }
+ phone := sanitizePhoneForSms(req.Recipient)
+ smsLoginID := req.Recipient
+ if !strings.Contains(smsLoginID, "@") {
+ lookup := normalizePhoneForLoginID(smsLoginID)
+ if email, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + lookup); email != "" {
+ smsLoginID = email
+ } else {
+ smsLoginID = lookup
+ }
+ }
+ smsBody := h.buildKratosShortSmsBody(&req, smsLoginID, phone)
+ if smsBody == "" {
+ smsBody = body
+ }
+ if err := h.SmsService.SendSms(phone, smsBody); err != nil {
+ slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
+ }
+ slog.Info("[Kratos Courier] SMS sent", "to", phone, "template", req.TemplateType)
+ return c.JSON(fiber.Map{"status": "ok"})
+}
+
+func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (string, string) {
+ subject := strings.TrimSpace(req.Subject)
+ body := strings.TrimSpace(req.Body)
+ if body != "" || subject != "" {
+ if subject == "" {
+ subject = "[Baron 통합로그인] 알림"
+ }
+ return subject, body
+ }
+
+ templateType := strings.ToLower(req.TemplateType)
+ loginCode := extractFirstString(req.TemplateData, "login_code")
+ verificationCode := extractFirstString(req.TemplateData, "verification_code")
+ recoveryCode := extractFirstString(req.TemplateData, "recovery_code")
+ code := firstNonEmpty(loginCode, verificationCode, recoveryCode, extractFirstString(req.TemplateData, "code"))
+
+ label := "알림"
+ if loginCode != "" || strings.Contains(templateType, "login") {
+ label = "로그인"
+ } else if verificationCode != "" || strings.Contains(templateType, "verification") {
+ label = "인증"
+ } else if recoveryCode != "" || strings.Contains(templateType, "recovery") {
+ label = "복구"
+ } else if strings.Contains(templateType, "code") {
+ label = "인증"
+ }
+
+ if subject == "" {
+ if label == "알림" {
+ subject = "[Baron 통합로그인] 알림"
+ } else {
+ subject = fmt.Sprintf("[Baron 통합로그인] %s 코드", label)
+ }
+ }
+
+ if code == "" {
+ return subject, fmt.Sprintf("[Baron 통합로그인] %s 요청이 도착했습니다", label)
+ }
+
+ message := fmt.Sprintf("[Baron 통합로그인] %s 코드: %s", label, code)
+ if label == "로그인" {
+ baseURL := os.Getenv("USERFRONT_URL")
+ if baseURL == "" {
+ baseURL = "http://localhost:5000"
+ }
+ baseURL = strings.TrimRight(baseURL, "/")
+ loginID := req.Recipient
+ if !strings.Contains(loginID, "@") {
+ loginID = normalizePhoneForLoginID(loginID)
+ }
+ pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID)
+ if pendingRef != "" {
+ message = fmt.Sprintf("%s | 링크: %s/verify?loginId=%s&code=%s&pendingRef=%s",
+ message,
+ baseURL,
+ url.QueryEscape(req.Recipient),
+ url.QueryEscape(code),
+ url.QueryEscape(pendingRef),
+ )
+ return subject, message
+ }
+ link := fmt.Sprintf("%s/verify?loginId=%s&code=%s",
+ baseURL,
+ url.QueryEscape(req.Recipient),
+ url.QueryEscape(code),
+ )
+ message = fmt.Sprintf("%s | 링크: %s", message, link)
+ }
+
+ return subject, message
+}
+
+type shortLoginCodePayload struct {
+ LoginID string `json:"loginId"`
+ Code string `json:"code"`
+ PendingRef string `json:"pendingRef"`
+}
+
+func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID, phone string) string {
+ shortCode, link, ok := h.prepareKratosShortLogin(req, loginID)
+ if !ok {
+ return ""
+ }
+ if h.isSmsCodeOnly(loginID) {
+ return fmt.Sprintf("[Baron 통합로그인] 로그인 코드: %s", shortCode)
+ }
+ return fmt.Sprintf("[Baron 통합로그인] %s", link)
+}
+
+func (h *AuthHandler) buildKratosShortEmailBody(req *kratosCourierRequest, loginID string) (string, string) {
+ shortCode, link, ok := h.prepareKratosShortLogin(req, loginID)
+ if !ok {
+ return "", ""
+ }
+ subject := "[Baron 통합로그인] 로그인 링크"
+ body := fmt.Sprintf(`
+
+
Baron SSO 로그인
+
아래 버튼을 클릭하여 로그인을 완료해 주세요.
+
+
간편 코드: %s
+
링크가 열리지 않으면 위 간편 코드를 입력해 로그인할 수 있습니다.
+
+ `, link, shortCode)
+ return subject, body
+}
+
+func (h *AuthHandler) prepareKratosShortLogin(req *kratosCourierRequest, loginID string) (string, string, bool) {
+ if req == nil || loginID == "" {
+ return "", "", false
+ }
+ code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code"))
+ if code == "" {
+ return "", "", false
+ }
+ shortCode := h.generateShortCode(code)
+ if shortCode == "" {
+ return "", "", false
+ }
+
+ pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID)
+ payload := shortLoginCodePayload{
+ LoginID: loginID,
+ Code: code,
+ PendingRef: pendingRef,
+ }
+ raw, _ := json.Marshal(payload)
+ _ = h.RedisService.Set(prefixLoginCodeShort+shortCode, string(raw), loginCodeExpiration)
+
+ baseURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
+ if baseURL == "" {
+ baseURL = "http://localhost:5000"
+ }
+
+ link := fmt.Sprintf("%s/l/%s", baseURL, shortCode)
+ return shortCode, link, true
+}
+
+func (h *AuthHandler) isSmsCodeOnly(loginID string) bool {
+ if loginID == "" {
+ return false
+ }
+ val, _ := h.RedisService.Get(prefixLoginCodeSmsOnly + loginID)
+ return val != ""
+}
+
+func (h *AuthHandler) generateShortCode(code string) string {
+ const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ for i := 0; i < 10; i++ {
+ b := make([]byte, 2)
+ if _, err := crand.Read(b); err != nil {
+ break
+ }
+ prefix := string(letters[int(b[0])%len(letters)]) + string(letters[int(b[1])%len(letters)])
+ shortCode := prefix + code
+ if val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode); val == "" {
+ return shortCode
+ }
+ }
+ return ""
+}
+
+func normalizeLoginCode(code string) string {
+ if code == "" {
+ return ""
+ }
+ digits := make([]rune, 0, len(code))
+ for _, ch := range code {
+ if ch >= '0' && ch <= '9' {
+ digits = append(digits, ch)
+ }
+ }
+ if len(digits) < 6 {
+ return ""
+ }
+ if len(digits) > 6 {
+ digits = digits[:6]
+ }
+ return string(digits)
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func extractFirstString(data map[string]interface{}, keys ...string) string {
+ if data == nil {
+ return ""
+ }
+ for _, key := range keys {
+ if val, ok := data[key]; ok {
+ if str, ok := val.(string); ok && str != "" {
+ return str
+ }
+ }
+ }
+ return ""
+}
+
+func sanitizePhoneForSms(phone string) string {
+ sanitized := strings.TrimSpace(phone)
+ if strings.HasPrefix(sanitized, "+82") {
+ sanitized = "0" + sanitized[3:]
+ }
+ sanitized = strings.ReplaceAll(sanitized, "-", "")
+ sanitized = strings.ReplaceAll(sanitized, " ", "")
+ return sanitized
+}
+
// HandleDescopeSmsRelay
func (h *AuthHandler) HandleDescopeSmsRelay(c *fiber.Ctx) error {
var req struct {
@@ -1305,123 +1979,632 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string {
// GetMe - Returns current user's profile with 010 phone format
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
token := h.getBearerToken(c)
- if token == "" {
+ if token != "" {
+ if looksLikeJWT(token) && h.DescopeClient != nil {
+ authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
+ if err == nil && authorized {
+ userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"})
+ }
+
+ dept, _ := userResponse.CustomAttributes["department"].(string)
+ affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
+ compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
+
+ resp := domain.UserProfileResponse{
+ ID: userResponse.UserID,
+ Email: userResponse.Email,
+ Name: userResponse.Name,
+ Phone: h.formatPhoneForDisplay(userResponse.Phone),
+ Department: dept,
+ AffiliationType: affType,
+ CompanyCode: compCode,
+ }
+ return c.JSON(resp)
+ }
+ }
+
+ profile, err := h.getKratosProfile(token)
+ if err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
+ }
+ return c.JSON(profile)
+ }
+
+ cookie := c.Get("Cookie")
+ if cookie == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"})
}
-
- authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
- if err != nil || !authorized {
+ profile, err := h.getKratosProfileWithCookie(cookie)
+ if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
}
+ return c.JSON(profile)
+}
- userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
+func looksLikeJWT(token string) bool {
+ return strings.Count(token, ".") == 2
+}
+
+func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) {
+ if looksLikeJWT(token) && h.DescopeClient != nil {
+ authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
+ if err == nil && authorized {
+ return userToken.ID, nil
+ }
+ }
+ id, _, err := h.getKratosIdentity(token)
+ return id, err
+}
+
+func (h *AuthHandler) tryIssueDescopeQrSession(c *fiber.Ctx, token string) (string, error) {
+ if !looksLikeJWT(token) || h.DescopeClient == nil {
+ return "", nil
+ }
+ authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
+ if err != nil || !authorized {
+ return "", nil
+ }
+ loginID, err := h.resolveDescopeLoginID(c.Context(), userToken)
if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"})
+ return "", err
+ }
+ authInfo, err := h.IdpProvider.IssueSession(loginID)
+ if err != nil {
+ return "", err
+ }
+ if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
+ return "", fmt.Errorf("descope issue session returned empty token")
+ }
+ return authInfo.SessionToken.JWT, nil
+}
+
+func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) {
+ _, traits, err := h.getKratosIdentity(token)
+ if err != nil {
+ return "", err
+ }
+ loginID := pickLoginIDFromTraits(traits)
+ if loginID == "" {
+ return "", fmt.Errorf("kratos login id missing")
+ }
+ if !strings.Contains(loginID, "@") {
+ loginID = normalizePhoneForLoginID(loginID)
+ }
+ return loginID, nil
+}
+
+func pickLoginIDFromTraits(traits map[string]interface{}) string {
+ if traits == nil {
+ return ""
+ }
+ keys := []string{"email", "phone", "phone_number", "phoneNumber", "mobile", "mobile_number"}
+ for _, key := range keys {
+ if raw, ok := traits[key]; ok {
+ if value, ok := raw.(string); ok && value != "" {
+ return value
+ }
+ }
+ }
+ return ""
+}
+
+func (h *AuthHandler) resolveQrPendingRef(raw string) (string, error) {
+ ref := strings.TrimSpace(raw)
+ if ref == "" {
+ return "", fmt.Errorf("empty ref")
+ }
+ if strings.HasPrefix(ref, "http") {
+ if parsed, err := url.Parse(ref); err == nil {
+ if value := parsed.Query().Get("ref"); value != "" {
+ ref = value
+ } else if len(parsed.Path) > 0 {
+ segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
+ if len(segments) >= 2 && segments[0] == "ql" {
+ ref = segments[1]
+ }
+ }
+ }
+ }
+ if ref == "" {
+ return "", fmt.Errorf("invalid ref")
+ }
+ if mapped, _ := h.RedisService.Get(prefixQrRef + ref); mapped != "" {
+ return mapped, nil
+ }
+ return ref, nil
+}
+
+func (h *AuthHandler) resolveQrRef(raw string) string {
+ ref := strings.TrimSpace(raw)
+ if ref == "" {
+ return ""
+ }
+ if strings.HasPrefix(ref, "http") {
+ if parsed, err := url.Parse(ref); err == nil {
+ if value := parsed.Query().Get("ref"); value != "" {
+ return value
+ }
+ if len(parsed.Path) > 0 {
+ segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
+ if len(segments) >= 2 && segments[0] == "ql" {
+ return segments[1]
+ }
+ }
+ }
+ }
+ return ref
+}
+
+func (h *AuthHandler) startQrCodeLogin(loginID, pendingRef string) error {
+ if h.IdpProvider == nil {
+ return fmt.Errorf("identity provider unavailable")
+ }
+ userfrontURL := os.Getenv("USERFRONT_URL")
+ if userfrontURL == "" {
+ userfrontURL = "http://sso.hmac.kr"
+ }
+ _ = h.RedisService.Set(prefixQrPending+loginID, pendingRef, loginCodeExpiration)
+ init, err := h.IdpProvider.InitiateLinkLogin(loginID, userfrontURL)
+ if err != nil {
+ h.RedisService.Delete(prefixQrPending + loginID)
+ if errors.Is(err, domain.ErrNotSupported) {
+ return fmt.Errorf("login method not supported")
+ }
+ return err
+ }
+ effectiveLoginID := loginID
+ if init != nil && init.LoginID != "" {
+ effectiveLoginID = init.LoginID
+ }
+ if effectiveLoginID != loginID {
+ _ = h.RedisService.Set(prefixQrPending+effectiveLoginID, pendingRef, loginCodeExpiration)
+ }
+ if init != nil && init.FlowID != "" {
+ _ = h.RedisService.Set(prefixLoginCode+effectiveLoginID, init.FlowID, loginCodeExpiration)
+ }
+ return nil
+}
+
+func (h *AuthHandler) startQrCodeLoginForQr(loginID, pendingRef, rawRef string) error {
+ if h.IdpProvider == nil {
+ return fmt.Errorf("identity provider unavailable")
+ }
+ userfrontURL := os.Getenv("USERFRONT_URL")
+ if userfrontURL == "" {
+ userfrontURL = "http://sso.hmac.kr"
}
- dept, _ := userResponse.CustomAttributes["department"].(string)
- affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
- compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
+ init, err := h.IdpProvider.InitiateLinkLogin(loginID, userfrontURL)
+ if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ return fmt.Errorf("login method not supported")
+ }
+ return err
+ }
- resp := domain.UserProfileResponse{
- ID: userResponse.UserID,
- Email: userResponse.Email,
- Name: userResponse.Name,
- Phone: h.formatPhoneForDisplay(userResponse.Phone),
+ effectiveLoginID := loginID
+ if init != nil && init.LoginID != "" {
+ effectiveLoginID = init.LoginID
+ }
+ if init == nil || init.FlowID == "" {
+ return fmt.Errorf("login flow missing")
+ }
+
+ qrRef := h.resolveQrRef(rawRef)
+ qrPayload, _ := json.Marshal(map[string]string{
+ "pendingRef": pendingRef,
+ "qrRef": qrRef,
+ "loginId": effectiveLoginID,
+ "approvedAt": time.Now().UTC().Format(time.RFC3339),
+ })
+ _ = h.RedisService.Set(prefixLoginCodeQr+pendingRef, string(qrPayload), loginCodeExpiration)
+ _ = h.RedisService.Set(prefixLoginCodeQrPending+effectiveLoginID, pendingRef, loginCodeExpiration)
+ _ = h.RedisService.Set(prefixLoginCode+effectiveLoginID, init.FlowID, loginCodeExpiration)
+ return nil
+}
+
+func (h *AuthHandler) resolveDescopeLoginID(ctx context.Context, token *descope.Token) (string, error) {
+ if token == nil {
+ return "", fmt.Errorf("descope token is nil")
+ }
+
+ if loginID := extractLoginIDFromClaims(token.Claims); loginID != "" {
+ return loginID, nil
+ }
+
+ if h.DescopeClient == nil {
+ return "", fmt.Errorf("descope client is nil")
+ }
+
+ user, err := h.DescopeClient.Management.User().Load(ctx, token.ID)
+ if err != nil {
+ return "", err
+ }
+ if user == nil {
+ return "", fmt.Errorf("descope user not found")
+ }
+ if loginID := pickPrimaryLoginID(user.LoginIDs); loginID != "" {
+ return loginID, nil
+ }
+ if user.Email != "" {
+ return user.Email, nil
+ }
+ if user.Phone != "" {
+ return user.Phone, nil
+ }
+ return "", fmt.Errorf("descope login id not found")
+}
+
+func pickPrimaryLoginID(loginIDs []string) string {
+ for _, id := range loginIDs {
+ if strings.Contains(id, "@") {
+ return id
+ }
+ }
+ if len(loginIDs) > 0 {
+ return loginIDs[0]
+ }
+ return ""
+}
+
+func extractLoginIDFromClaims(claims map[string]any) string {
+ if claims == nil {
+ return ""
+ }
+
+ candidateKeys := []string{"loginId", "login_id", "email", "phone_number", "phone", "phoneNumber"}
+ for _, key := range candidateKeys {
+ if raw, ok := claims[key]; ok {
+ if value, ok := raw.(string); ok && value != "" {
+ return value
+ }
+ }
+ }
+
+ if raw, ok := claims["loginIds"]; ok {
+ switch ids := raw.(type) {
+ case []string:
+ return pickPrimaryLoginID(ids)
+ case []any:
+ casted := make([]string, 0, len(ids))
+ for _, item := range ids {
+ if value, ok := item.(string); ok && value != "" {
+ casted = append(casted, value)
+ }
+ }
+ return pickPrimaryLoginID(casted)
+ }
+ }
+
+ return ""
+}
+
+func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
+ kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
+ if kratosURL == "" {
+ kratosURL = "http://kratos:4433"
+ }
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
+ if err != nil {
+ return "", nil, err
+ }
+ req.Header.Set("X-Session-Token", sessionToken)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
+ return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ Identity struct {
+ ID string `json:"id"`
+ Traits map[string]interface{} `json:"traits"`
+ } `json:"identity"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return "", nil, err
+ }
+
+ return result.Identity.ID, result.Identity.Traits, nil
+}
+
+func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) (string, error) {
+ if identityID == "" {
+ return "", fmt.Errorf("kratos identity id is empty")
+ }
+
+ kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
+ if kratosAdminURL == "" {
+ kratosAdminURL = "http://kratos:4434"
+ }
+
+ payload := map[string]interface{}{
+ "identity_id": identityID,
+ }
+ body, _ := json.Marshal(payload)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, kratosAdminURL+"/admin/sessions", bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ if resp.StatusCode >= 300 {
+ return "", fmt.Errorf("kratos admin create session failed status=%d body=%s", resp.StatusCode, string(respBody))
+ }
+
+ var parsed struct {
+ SessionToken string `json:"session_token"`
+ }
+ if err := json.Unmarshal(respBody, &parsed); err != nil {
+ return "", err
+ }
+ if parsed.SessionToken == "" {
+ return "", fmt.Errorf("kratos admin session token missing: %s", string(respBody))
+ }
+ return parsed.SessionToken, nil
+}
+
+func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) {
+ kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
+ if kratosURL == "" {
+ kratosURL = "http://kratos:4433"
+ }
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
+ if err != nil {
+ return "", nil, err
+ }
+ req.Header.Set("Cookie", cookie)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
+ return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ Identity struct {
+ ID string `json:"id"`
+ Traits map[string]interface{} `json:"traits"`
+ } `json:"identity"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return "", nil, err
+ }
+
+ return result.Identity.ID, result.Identity.Traits, nil
+}
+
+func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]interface{}) error {
+ kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
+ if kratosAdminURL == "" {
+ kratosAdminURL = "http://kratos:4434"
+ }
+
+ payload := map[string]interface{}{
+ "schema_id": "default",
+ "traits": traits,
+ }
+ body, _ := json.Marshal(payload)
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", kratosAdminURL, identityID), bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 {
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
+ return fmt.Errorf("kratos admin update failed status=%d body=%s", resp.StatusCode, string(respBody))
+ }
+ return nil
+}
+
+func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
+ identityID, traits, err := h.getKratosIdentity(sessionToken)
+ if err != nil {
+ return nil, err
+ }
+
+ email, _ := traits["email"].(string)
+ name, _ := traits["name"].(string)
+ phone, _ := traits["phone_number"].(string)
+ dept, _ := traits["department"].(string)
+ affType, _ := traits["affiliationType"].(string)
+ compCode, _ := traits["companyCode"].(string)
+
+ profile := &domain.UserProfileResponse{
+ ID: identityID,
+ Email: email,
+ Name: name,
+ Phone: h.formatPhoneForDisplay(phone),
Department: dept,
AffiliationType: affType,
CompanyCode: compCode,
}
+ return profile, nil
+}
- return c.JSON(resp)
+func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
+ identityID, traits, err := h.getKratosIdentityWithCookie(cookie)
+ if err != nil {
+ return nil, err
+ }
+
+ email, _ := traits["email"].(string)
+ name, _ := traits["name"].(string)
+ phone, _ := traits["phone_number"].(string)
+ dept, _ := traits["department"].(string)
+ affType, _ := traits["affiliationType"].(string)
+ compCode, _ := traits["companyCode"].(string)
+
+ profile := &domain.UserProfileResponse{
+ ID: identityID,
+ Email: email,
+ Name: name,
+ Phone: h.formatPhoneForDisplay(phone),
+ Department: dept,
+ AffiliationType: affType,
+ CompanyCode: compCode,
+ }
+ return profile, nil
}
// UpdateMe - Updates current user's profile with phone verification check
func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
token := h.getBearerToken(c)
- if token == "" {
- return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"})
- }
-
- authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
- if err != nil || !authorized {
- return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
- }
-
var req domain.UpdateUserRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
- // 1. Load current user to check changes
- currentUser, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
- if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load current user"})
+ if token != "" && looksLikeJWT(token) && h.DescopeClient != nil {
+ authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
+ if err == nil && authorized {
+ // 1. Load current user to check changes
+ currentUser, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load current user"})
+ }
+
+ newPhoneStorage := h.formatPhoneForStorage(req.Phone)
+ oldPhoneStorage := currentUser.Phone
+
+ slog.Info("[UpdateMe] Checking changes", "userID", userToken.ID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name)
+
+ // 2. Handle Phone Number Change
+ if newPhoneStorage != "" && newPhoneStorage != oldPhoneStorage {
+ // Check verification status in Redis
+ verifyKey := "verify_update_phone:" + userToken.ID + ":" + newPhoneStorage
+ val, _ := h.RedisService.Get(verifyKey)
+ if val != "verified" {
+ slog.Warn("[UpdateMe] Phone verification missing", "key", verifyKey)
+ return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다."})
+ }
+
+ // Update Phone in Descope and mark as verified
+ slog.Info("[UpdateMe] Updating phone number", "userID", userToken.ID, "newPhone", newPhoneStorage)
+ _, err = h.DescopeClient.Management.User().UpdatePhone(c.Context(), userToken.ID, newPhoneStorage, true, false)
+ if err != nil {
+ slog.Error("Failed to update phone in Descope", "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "전화번호 업데이트에 실패했습니다."})
+ }
+
+ // If the old phone was used as a LoginID, replace it with the new one
+ for _, loginID := range currentUser.LoginIDs {
+ // Normalize for comparison
+ normID := strings.ReplaceAll(loginID, "+82", "0")
+ normOld := strings.ReplaceAll(oldPhoneStorage, "+82", "0")
+
+ if loginID == oldPhoneStorage || (normOld != "" && normID == normOld) {
+ slog.Info("[UpdateMe] Updating LoginID", "old", loginID, "new", newPhoneStorage)
+ _, err = h.DescopeClient.Management.User().UpdateLoginID(c.Context(), loginID, newPhoneStorage)
+ if err != nil {
+ slog.Warn("Failed to update LoginID", "error", err)
+ }
+ break
+ }
+ }
+
+ // Clear verification after successful update
+ h.RedisService.Delete(verifyKey)
+ }
+
+ // 3. Update Name if changed
+ if req.Name != "" && req.Name != currentUser.Name {
+ slog.Info("[UpdateMe] Updating display name", "userID", userToken.ID, "newName", req.Name)
+ _, err = h.DescopeClient.Management.User().UpdateDisplayName(c.Context(), userToken.ID, req.Name)
+ if err != nil {
+ slog.Error("Failed to update user name", "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "이름 업데이트에 실패했습니다."})
+ }
+ }
+
+ // 4. Update Custom Attributes (Department)
+ if req.Department != "" {
+ slog.Info("[UpdateMe] Updating department", "userID", userToken.ID, "dept", req.Department)
+ if _, err := h.DescopeClient.Management.User().UpdateCustomAttribute(c.Context(), userToken.ID, "department", req.Department); err != nil {
+ slog.Error("Failed to update department", "error", err)
+ }
+ }
+
+ slog.Info("[UpdateMe] Profile update completed successfully", "userID", userToken.ID)
+
+ return c.JSON(fiber.Map{
+ "status": "success",
+ "updatedAt": time.Now().Format(time.RFC3339),
+ })
+ }
}
+ var (
+ identityID string
+ traits map[string]interface{}
+ err error
+ )
+ if token != "" {
+ identityID, traits, err = h.getKratosIdentity(token)
+ } else {
+ cookie := c.Get("Cookie")
+ if cookie == "" {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"})
+ }
+ identityID, traits, err = h.getKratosIdentityWithCookie(cookie)
+ }
+ if err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
+ }
+
+ currentPhone, _ := traits["phone_number"].(string)
newPhoneStorage := h.formatPhoneForStorage(req.Phone)
- oldPhoneStorage := currentUser.Phone
- slog.Info("[UpdateMe] Checking changes", "userID", userToken.ID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name)
+ slog.Info("[UpdateMe] Checking changes (Kratos)", "identityID", identityID, "oldPhone", currentPhone, "newPhone", newPhoneStorage, "newName", req.Name)
- // 2. Handle Phone Number Change
- if newPhoneStorage != "" && newPhoneStorage != oldPhoneStorage {
- // Check verification status in Redis
- verifyKey := "verify_update_phone:" + userToken.ID + ":" + newPhoneStorage
+ if newPhoneStorage != "" && newPhoneStorage != currentPhone {
+ verifyKey := "verify_update_phone:" + identityID + ":" + newPhoneStorage
val, _ := h.RedisService.Get(verifyKey)
if val != "verified" {
- slog.Warn("[UpdateMe] Phone verification missing", "key", verifyKey)
+ slog.Warn("[UpdateMe] Phone verification missing (Kratos)", "key", verifyKey)
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다."})
}
-
- // Update Phone in Descope and mark as verified
- slog.Info("[UpdateMe] Updating phone number", "userID", userToken.ID, "newPhone", newPhoneStorage)
- _, err = h.DescopeClient.Management.User().UpdatePhone(c.Context(), userToken.ID, newPhoneStorage, true, false)
- if err != nil {
- slog.Error("Failed to update phone in Descope", "error", err)
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "전화번호 업데이트에 실패했습니다."})
- }
-
- // If the old phone was used as a LoginID, replace it with the new one
- for _, loginID := range currentUser.LoginIDs {
- // Normalize for comparison
- normID := strings.ReplaceAll(loginID, "+82", "0")
- normOld := strings.ReplaceAll(oldPhoneStorage, "+82", "0")
-
- if loginID == oldPhoneStorage || (normOld != "" && normID == normOld) {
- slog.Info("[UpdateMe] Updating LoginID", "old", loginID, "new", newPhoneStorage)
- _, err = h.DescopeClient.Management.User().UpdateLoginID(c.Context(), loginID, newPhoneStorage)
- if err != nil {
- slog.Warn("Failed to update LoginID", "error", err)
- }
- break
- }
- }
-
- // Clear verification after successful update
+ traits["phone_number"] = newPhoneStorage
h.RedisService.Delete(verifyKey)
}
- // 3. Update Name if changed
- if req.Name != "" && req.Name != currentUser.Name {
- slog.Info("[UpdateMe] Updating display name", "userID", userToken.ID, "newName", req.Name)
- _, err = h.DescopeClient.Management.User().UpdateDisplayName(c.Context(), userToken.ID, req.Name)
- if err != nil {
- slog.Error("Failed to update user name", "error", err)
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "이름 업데이트에 실패했습니다."})
- }
+ if req.Name != "" {
+ traits["name"] = req.Name
}
-
- // 4. Update Custom Attributes (Department)
if req.Department != "" {
- slog.Info("[UpdateMe] Updating department", "userID", userToken.ID, "dept", req.Department)
- if _, err := h.DescopeClient.Management.User().UpdateCustomAttribute(c.Context(), userToken.ID, "department", req.Department); err != nil {
- slog.Error("Failed to update department", "error", err)
- }
+ traits["department"] = req.Department
}
- slog.Info("[UpdateMe] Profile update completed successfully", "userID", userToken.ID)
+ if err := h.updateKratosIdentity(identityID, traits); err != nil {
+ slog.Error("Failed to update profile in Kratos", "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "프로필 업데이트에 실패했습니다."})
+ }
+ slog.Info("[UpdateMe] Profile update completed successfully (Kratos)", "identityID", identityID)
return c.JSON(fiber.Map{
"status": "success",
"updatedAt": time.Now().Format(time.RFC3339),
@@ -1431,12 +2614,20 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
// SendUpdateCode - Sends OTP for phone number change
func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error {
token := h.getBearerToken(c)
- if token == "" {
- return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
+ var (
+ userID string
+ err error
+ )
+ if token != "" {
+ userID, err = h.resolveIdentityID(c, token)
+ } else {
+ cookie := c.Get("Cookie")
+ if cookie == "" {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
+ }
+ userID, _, err = h.getKratosIdentityWithCookie(cookie)
}
-
- authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
- if err != nil || !authorized {
+ if err != nil || userID == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
}
@@ -1451,11 +2642,11 @@ func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error {
code := fmt.Sprintf("%06d", rand.Intn(1000000))
// Store code in Redis
- key := "otp_update_phone:" + userToken.ID + ":" + phone
+ key := "otp_update_phone:" + userID + ":" + phone
h.RedisService.Set(key, code, 5*time.Minute)
// Send SMS
- content := fmt.Sprintf("[Baron SSO] 정보 수정 인증번호: [%s]", code)
+ content := fmt.Sprintf("[Baron 통합로그인] 정보 수정 인증번호: [%s]", code)
go h.SmsService.SendSms(phone, content)
return c.JSON(fiber.Map{"message": "인증번호가 전송되었습니다."})
@@ -1464,8 +2655,20 @@ func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error {
// VerifyUpdateCode - Verifies OTP for phone number change
func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error {
token := h.getBearerToken(c)
- authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
- if err != nil || !authorized {
+ var (
+ userID string
+ err error
+ )
+ if token != "" {
+ userID, err = h.resolveIdentityID(c, token)
+ } else {
+ cookie := c.Get("Cookie")
+ if cookie == "" {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
+ }
+ userID, _, err = h.getKratosIdentityWithCookie(cookie)
+ }
+ if err != nil || userID == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
}
@@ -1478,7 +2681,7 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error {
}
phone := h.formatPhoneForStorage(req.Phone)
- key := "otp_update_phone:" + userToken.ID + ":" + phone
+ key := "otp_update_phone:" + userID + ":" + phone
storedCode, _ := h.RedisService.Get(key)
if storedCode == "" || storedCode != req.Code {
@@ -1486,7 +2689,7 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error {
}
// Mark as verified for 10 minutes
- verifyKey := "verify_update_phone:" + userToken.ID + ":" + phone
+ verifyKey := "verify_update_phone:" + userID + ":" + phone
h.RedisService.Set(verifyKey, "verified", 10*time.Minute)
h.RedisService.Delete(key)
diff --git a/backend/internal/handler/auth_handler_test.go b/backend/internal/handler/auth_handler_test.go
index 746b82eb..1a06bcac 100644
--- a/backend/internal/handler/auth_handler_test.go
+++ b/backend/internal/handler/auth_handler_test.go
@@ -72,7 +72,7 @@ func TestCompletePasswordReset_InvalidPasswordPolicy(t *testing.T) {
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" {
+ if got["error"] != "비밀번호는 최소 12자 이상이어야 합니다" {
t.Fatalf("unexpected error message: %v", got["error"])
}
}
diff --git a/backend/internal/idp/factory.go b/backend/internal/idp/factory.go
index b05b812c..221e027a 100644
--- a/backend/internal/idp/factory.go
+++ b/backend/internal/idp/factory.go
@@ -124,43 +124,144 @@ func (c *chainedProvider) GetMetadata() (*domain.IDPMetadata, error) {
}
func (c *chainedProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
- var errs []error
- for idx, p := range c.providers {
+ for _, p := range c.providers {
id, err := p.CreateUser(user, password)
if err != nil {
- errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
+ if errors.Is(err, domain.ErrNotSupported) {
+ continue
+ }
slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "CreateUser", "error", err)
- continue
- }
- if idx > 0 {
- slog.Info("IDP fallback succeeded", "operation", "CreateUser", "provider", p.Name())
+ return "", err
}
return id, nil
}
- if len(errs) == 0 {
- return "", fmt.Errorf("no IDP providers available for CreateUser")
- }
- return "", fmt.Errorf("all IDP providers failed for CreateUser: %w", errors.Join(errs...))
+ return "", domain.ErrNotSupported
}
func (c *chainedProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
- var errs []error
- for idx, p := range c.providers {
+ for _, p := range c.providers {
info, err := p.SignIn(loginID, password)
if err != nil {
- errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
+ if errors.Is(err, domain.ErrNotSupported) {
+ continue
+ }
slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "SignIn", "error", err)
+ return nil, err
+ }
+ return info, nil
+ }
+ return nil, domain.ErrNotSupported
+}
+
+func (c *chainedProvider) UserExists(loginID string) (bool, error) {
+ var errs []error
+ for _, p := range c.providers {
+ exists, err := p.UserExists(loginID)
+ if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ continue
+ }
+ errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
+ continue
+ }
+ if exists {
+ return true, nil
+ }
+ }
+ if len(errs) == 0 {
+ return false, nil
+ }
+ return false, fmt.Errorf("all IDP providers failed for UserExists: %w", errors.Join(errs...))
+}
+
+func (c *chainedProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
+ var errs []error
+ for idx, p := range c.providers {
+ info, err := p.IssueSession(loginID)
+ if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ continue
+ }
+ errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
+ slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "IssueSession", "error", err)
continue
}
if idx > 0 {
- slog.Info("IDP fallback succeeded", "operation", "SignIn", "provider", p.Name())
+ slog.Info("IDP fallback succeeded", "operation", "IssueSession", "provider", p.Name())
}
return info, nil
}
if len(errs) == 0 {
- return nil, fmt.Errorf("no IDP providers available for SignIn")
+ return nil, domain.ErrNotSupported
}
- return nil, fmt.Errorf("all IDP providers failed for SignIn: %w", errors.Join(errs...))
+ return nil, fmt.Errorf("all IDP providers failed for IssueSession: %w", errors.Join(errs...))
+}
+
+func (c *chainedProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
+ var errs []error
+ for idx, p := range c.providers {
+ info, err := p.InitiateLinkLogin(loginID, returnTo)
+ if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ continue
+ }
+ errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
+ slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "InitiateLinkLogin", "error", err)
+ continue
+ }
+ if idx > 0 {
+ slog.Info("IDP fallback succeeded", "operation", "InitiateLinkLogin", "provider", p.Name())
+ }
+ return info, nil
+ }
+ if len(errs) == 0 {
+ return nil, domain.ErrNotSupported
+ }
+ return nil, fmt.Errorf("all IDP providers failed for InitiateLinkLogin: %w", errors.Join(errs...))
+}
+
+func (c *chainedProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
+ var errs []error
+ for idx, p := range c.providers {
+ info, err := p.VerifyLoginCode(loginID, flowID, code)
+ if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ continue
+ }
+ errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
+ slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "VerifyLoginCode", "error", err)
+ continue
+ }
+ if idx > 0 {
+ slog.Info("IDP fallback succeeded", "operation", "VerifyLoginCode", "provider", p.Name())
+ }
+ return info, nil
+ }
+ if len(errs) == 0 {
+ return nil, domain.ErrNotSupported
+ }
+ return nil, fmt.Errorf("all IDP providers failed for VerifyLoginCode: %w", errors.Join(errs...))
+}
+
+func (c *chainedProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
+ var errs []error
+ for _, p := range c.providers {
+ policy, err := p.GetPasswordPolicy()
+ if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ continue
+ }
+ errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
+ continue
+ }
+ if policy != nil {
+ return policy, nil
+ }
+ }
+ if len(errs) == 0 {
+ return nil, domain.ErrNotSupported
+ }
+ return nil, fmt.Errorf("all IDP providers failed for GetPasswordPolicy: %w", errors.Join(errs...))
}
func (c *chainedProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
diff --git a/backend/internal/idp/factory_test.go b/backend/internal/idp/factory_test.go
index 923c2083..9778d29d 100644
--- a/backend/internal/idp/factory_test.go
+++ b/backend/internal/idp/factory_test.go
@@ -10,19 +10,31 @@ import (
)
type stubProvider struct {
- name string
- metadata []string
- createErr error
- initiateErr error
- verifyErr error
- updateErr error
- signInErr error
- initiateCalls int
- verifyCalls int
- updateCalls int
- signInCalls int
- createCalls int
- verifyResponse *domain.AuthInfo
+ name string
+ metadata []string
+ createErr error
+ initiateErr error
+ verifyErr error
+ updateErr error
+ signInErr error
+ userExistsErr error
+ issueErr error
+ linkInitErr error
+ verifyCodeErr error
+ policyErr error
+ initiateCalls int
+ verifyCalls int
+ updateCalls int
+ signInCalls int
+ createCalls int
+ userExistsCalls int
+ issueCalls int
+ linkInitCalls int
+ verifyCodeCalls int
+ policyCalls int
+ verifyResponse *domain.AuthInfo
+ userExists bool
+ policy *domain.PasswordPolicy
}
func (s *stubProvider) Name() string { return s.name }
@@ -47,6 +59,46 @@ func (s *stubProvider) SignIn(loginID, password string) (*domain.AuthInfo, error
return &domain.AuthInfo{Subject: "subject-123"}, nil
}
+func (s *stubProvider) UserExists(loginID string) (bool, error) {
+ s.userExistsCalls++
+ if s.userExistsErr != nil {
+ return false, s.userExistsErr
+ }
+ return s.userExists, nil
+}
+
+func (s *stubProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
+ s.issueCalls++
+ if s.issueErr != nil {
+ return nil, s.issueErr
+ }
+ return &domain.AuthInfo{Subject: "issue-subject"}, nil
+}
+
+func (s *stubProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
+ s.linkInitCalls++
+ if s.linkInitErr != nil {
+ return nil, s.linkInitErr
+ }
+ return &domain.LinkLoginInit{FlowID: "flow-123", Mode: "cookie"}, nil
+}
+
+func (s *stubProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
+ s.verifyCodeCalls++
+ if s.verifyCodeErr != nil {
+ return nil, s.verifyCodeErr
+ }
+ return &domain.AuthInfo{Subject: "verify-code-subject"}, nil
+}
+
+func (s *stubProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
+ s.policyCalls++
+ if s.policyErr != nil {
+ return nil, s.policyErr
+ }
+ return s.policy, nil
+}
+
func (s *stubProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
s.initiateCalls++
return s.initiateErr
diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go
new file mode 100644
index 00000000..a010b8eb
--- /dev/null
+++ b/backend/internal/middleware/audit_middleware.go
@@ -0,0 +1,192 @@
+package middleware
+
+import (
+ "baron-sso-backend/internal/domain"
+ "baron-sso-backend/internal/utils"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "reflect"
+ "sync"
+ "time"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/google/uuid"
+)
+
+type AuditConfig struct {
+ Repo domain.AuditRepository
+ ExcludePaths map[string]struct{}
+ BodyDump bool
+ WorkerCount int
+ QueueSize int
+}
+
+func isNil(i any) bool {
+ if i == nil {
+ return true
+ }
+ v := reflect.ValueOf(i)
+ return v.Kind() == reflect.Ptr && v.IsNil()
+}
+
+// AuditMiddleware provides comprehensive audit logging for all requests.
+// It enforces strict logging for state-changing commands (POST, PUT, DELETE, PATCH)
+// and best-effort logging for queries (GET, HEAD, OPTIONS).
+func AuditMiddleware(config AuditConfig) fiber.Handler {
+ // 0. Initialize Worker Pool for Async Logging
+ if config.WorkerCount <= 0 {
+ config.WorkerCount = 5 // Default workers
+ }
+ if config.QueueSize <= 0 {
+ config.QueueSize = 1000 // Default queue size
+ }
+
+ auditQueue := make(chan *domain.AuditLog, config.QueueSize)
+ var once sync.Once
+
+ // Start workers only once
+ once.Do(func() {
+ for i := 0; i < config.WorkerCount; i++ {
+ go func(workerID int) {
+ slog.Debug("Audit worker started", "id", workerID)
+ for log := range auditQueue {
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("Audit worker panic recovery", "reason", r, "req_id", log.EventID)
+ }
+ }()
+ if err := config.Repo.Create(log); err != nil {
+ slog.Warn("Failed to write async audit log", "error", err, "req_id", log.EventID)
+ }
+ }()
+ }
+ }(i)
+ }
+ })
+
+ // Default methods classification
+ writeMethods := map[string]struct{}{
+ fiber.MethodPost: {},
+ fiber.MethodPut: {},
+ fiber.MethodPatch: {},
+ fiber.MethodDelete: {},
+ }
+
+ if config.ExcludePaths == nil {
+ config.ExcludePaths = map[string]struct{}{}
+ }
+
+ return func(c *fiber.Ctx) error {
+ // 1. Check exclusions
+ if _, excluded := config.ExcludePaths[c.Path()]; excluded {
+ return c.Next()
+ }
+
+ // 2. Setup context variables
+ start := time.Now()
+ reqID := c.Get("X-Request-Id")
+ if reqID == "" {
+ reqID = uuid.New().String()
+ c.Set("X-Request-Id", reqID)
+ }
+
+ // 3. Process Request
+ err := c.Next()
+
+ // 4. Gather Metrics & Context
+ latency := time.Since(start)
+ status := c.Response().StatusCode()
+
+ // If Fiber handler returned an error, status might default to 500 or be in the error
+ if err != nil {
+ if fiberErr, ok := err.(*fiber.Error); ok {
+ status = fiberErr.Code
+ } else {
+ status = fiber.StatusInternalServerError
+ }
+ }
+
+ statusText := "success"
+ if status >= fiber.StatusBadRequest {
+ statusText = "failure"
+ }
+
+ // 5. Extract User Context (populated by AuthMiddleware/TenantGuard)
+ userID, _ := c.Locals("user_id").(string)
+ loginID, _ := c.Locals("login_id").(string)
+ tenantID, _ := c.Locals("tenant_id").(string)
+
+ // 6. Capture & Mask Body
+ var maskedBody string
+ if config.BodyDump {
+ if c.Method() != fiber.MethodGet && c.Method() != fiber.MethodHead {
+ bodyBytes := c.Body()
+ if len(bodyBytes) > 0 {
+ maskedBytes := utils.MaskSensitiveJSON(bodyBytes)
+ maskedBody = string(maskedBytes)
+ }
+ }
+ }
+
+ // 7. Construct Details JSON
+ details := map[string]any{
+ "request_id": reqID,
+ "method": c.Method(),
+ "path": c.Path(),
+ "status": status,
+ "latency_ms": latency.Milliseconds(),
+ "login_id": loginID,
+ "tenant_id": tenantID,
+ "request_body": maskedBody,
+ }
+ if err != nil {
+ details["error"] = err.Error()
+ }
+
+ detailsJSON, _ := json.Marshal(details)
+
+ // 8. Create Audit Log Object
+ auditLog := &domain.AuditLog{
+ EventID: reqID,
+ Timestamp: start,
+ UserID: userID,
+ EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()),
+ Status: statusText,
+ IPAddress: c.IP(),
+ UserAgent: c.Get("User-Agent"),
+ Details: string(detailsJSON),
+ }
+
+ // 9. Store Log (Policy Enforcement)
+ _, isWrite := writeMethods[c.Method()]
+
+ if isNil(config.Repo) {
+ if isWrite {
+ slog.Error("Audit repository missing for command", "req_id", reqID)
+ return fiber.NewError(fiber.StatusServiceUnavailable, "Audit system unavailable")
+ }
+ return err
+ }
+
+ if isWrite {
+ // Strict Mode: Synchronous write
+ if createErr := config.Repo.Create(auditLog); createErr != nil {
+ slog.Error("Failed to write audit log (sync)", "error", createErr, "req_id", reqID)
+ return fiber.NewError(fiber.StatusServiceUnavailable, "Audit logging failed")
+ }
+ } else {
+ // Best Effort: Load Shedding via Buffered Channel
+ select {
+ case auditQueue <- auditLog:
+ // Successfully queued
+ default:
+ // Queue full -> DROP (Load Shedding)
+ slog.Warn("Audit queue full, dropping log (load shedding)", "req_id", reqID, "path", c.Path())
+ }
+ }
+
+ return err
+ }
+}
diff --git a/backend/internal/middleware/audit_middleware_test.go b/backend/internal/middleware/audit_middleware_test.go
new file mode 100644
index 00000000..4042dd72
--- /dev/null
+++ b/backend/internal/middleware/audit_middleware_test.go
@@ -0,0 +1,117 @@
+package middleware
+
+import (
+ "baron-sso-backend/internal/domain"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+// MockAuditRepository is a mock implementation of AuditRepository
+type MockAuditRepository struct {
+ mock.Mock
+}
+
+func (m *MockAuditRepository) Create(log *domain.AuditLog) error {
+ args := m.Called(log)
+ return args.Error(0)
+}
+
+func (m *MockAuditRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor) ([]domain.AuditLog, error) {
+ args := m.Called(ctx, limit, cursor)
+ return args.Get(0).([]domain.AuditLog), args.Error(1)
+}
+
+func (m *MockAuditRepository) Ping(ctx context.Context) error {
+ args := m.Called(ctx)
+ return args.Error(0)
+}
+
+func TestAuditMiddleware(t *testing.T) {
+ t.Run("POST request - Sync Success", func(t *testing.T) {
+ app := fiber.New()
+ mockRepo := new(MockAuditRepository)
+
+ app.Use(AuditMiddleware(AuditConfig{
+ Repo: mockRepo,
+ BodyDump: true,
+ }))
+
+ app.Post("/test", func(c *fiber.Ctx) error {
+ return c.SendStatus(fiber.StatusOK)
+ })
+
+ mockRepo.On("Create", mock.MatchedBy(func(log *domain.AuditLog) bool {
+ var details map[string]any
+ json.Unmarshal([]byte(log.Details), &details)
+ return log.Status == "success" &&
+ details["method"] == "POST" &&
+ details["request_body"] == `{"password":"*****","user":"test"}`
+ })).Return(nil)
+
+ req := httptest.NewRequest("POST", "/test", strings.NewReader(`{"user": "test", "password": "mypassword"}`))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, _ := app.Test(req)
+ assert.Equal(t, fiber.StatusOK, resp.StatusCode)
+ mockRepo.AssertExpectations(t)
+ })
+
+ t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) {
+ app := fiber.New()
+ mockRepo := new(MockAuditRepository)
+
+ app.Use(AuditMiddleware(AuditConfig{
+ Repo: mockRepo,
+ }))
+
+ app.Post("/test", func(c *fiber.Ctx) error {
+ return c.SendStatus(fiber.StatusOK)
+ })
+
+ mockRepo.On("Create", mock.Anything).Return(errors.New("db error"))
+
+ req := httptest.NewRequest("POST", "/test", nil)
+ resp, _ := app.Test(req)
+
+ // Should return 503 because Audit failed on a Write method
+ assert.Equal(t, fiber.StatusServiceUnavailable, resp.StatusCode)
+ })
+
+ t.Run("GET request - Async Load Shedding", func(t *testing.T) {
+ app := fiber.New()
+ mockRepo := new(MockAuditRepository)
+
+ // Set very small queue and no workers to force load shedding
+ app.Use(AuditMiddleware(AuditConfig{
+ Repo: mockRepo,
+ QueueSize: 1,
+ WorkerCount: 0, // This will be defaulted to 5 by the code, so let's use another way or just small queue
+ }))
+
+ app.Get("/test", func(c *fiber.Ctx) error {
+ return c.SendStatus(fiber.StatusOK)
+ })
+
+ // 1. First request fills the queue
+ mockRepo.On("Create", mock.Anything).Return(nil)
+
+ req1 := httptest.NewRequest("GET", "/test", nil)
+ resp1, _ := app.Test(req1)
+ assert.Equal(t, fiber.StatusOK, resp1.StatusCode)
+
+ // 2. Second request should be dropped (load shedding) if workers are slow
+ // Since we can't easily pause workers without modifying code,
+ // this test mostly ensures the non-blocking send doesn't hang.
+ req2 := httptest.NewRequest("GET", "/test", nil)
+ resp2, _ := app.Test(req2)
+ assert.Equal(t, fiber.StatusOK, resp2.StatusCode)
+ })
+}
\ No newline at end of file
diff --git a/backend/internal/middleware/audit_required.go b/backend/internal/middleware/audit_required.go
deleted file mode 100644
index 1fe18280..00000000
--- a/backend/internal/middleware/audit_required.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package middleware
-
-import (
- "baron-sso-backend/internal/domain"
- "encoding/json"
- "fmt"
- "log/slog"
- "reflect"
- "time"
-
- "github.com/gofiber/fiber/v2"
- "github.com/google/uuid"
-)
-
-type AuditRequiredConfig struct {
- Repo domain.AuditRepository
- ExcludePaths map[string]struct{}
- CommandMethods map[string]struct{}
-}
-
-func isNil(i any) bool {
- if i == nil {
- return true
- }
- v := reflect.ValueOf(i)
- return v.Kind() == reflect.Ptr && v.IsNil()
-}
-
-func RequireAudit(config AuditRequiredConfig) fiber.Handler {
- commandMethods := config.CommandMethods
- if len(commandMethods) == 0 {
- commandMethods = map[string]struct{}{
- fiber.MethodPost: {},
- fiber.MethodPut: {},
- fiber.MethodPatch: {},
- fiber.MethodDelete: {},
- }
- }
-
- excludePaths := config.ExcludePaths
- if excludePaths == nil {
- excludePaths = map[string]struct{}{}
- }
-
- return func(c *fiber.Ctx) error {
- if _, ok := commandMethods[c.Method()]; !ok {
- return c.Next()
- }
- if _, excluded := excludePaths[c.Path()]; excluded {
- return c.Next()
- }
-
- if isNil(config.Repo) {
- slog.Warn("audit repository is nil, skipping audit log creation", "path", c.Path())
- return c.Next() // Don't block the request, just skip audit
- }
-
- start := time.Now()
- reqID := c.Get("X-Request-Id")
- if reqID == "" {
- reqID = uuid.New().String()
- c.Set("X-Request-Id", reqID)
- }
-
- err := c.Next()
- latency := time.Since(start)
-
- status := c.Response().StatusCode()
- if err != nil {
- if fiberErr, ok := err.(*fiber.Error); ok {
- status = fiberErr.Code
- } else {
- status = fiber.StatusInternalServerError
- }
- }
-
- statusText := "success"
- if status >= fiber.StatusBadRequest {
- statusText = "failure"
- }
-
- details := map[string]any{
- "request_id": reqID,
- "method": c.Method(),
- "path": c.Path(),
- "status": status,
- "latency_ms": latency.Milliseconds(),
- }
- if err != nil {
- details["error"] = err.Error()
- }
-
- detailsJSON, jsonErr := json.Marshal(details)
- if jsonErr != nil {
- slog.Warn("failed to marshal audit details", "error", jsonErr, "req_id", reqID)
- }
-
- auditLog := &domain.AuditLog{
- EventID: reqID,
- Timestamp: time.Now(),
- UserID: "",
- EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()),
- Status: statusText,
- IPAddress: c.IP(),
- UserAgent: c.Get("User-Agent"),
- DeviceID: "",
- Details: string(detailsJSON),
- }
-
- if createErr := config.Repo.Create(auditLog); createErr != nil {
- slog.Error("audit log write failed", "error", createErr, "req_id", reqID, "path", c.Path())
- return fiber.NewError(fiber.StatusServiceUnavailable, "audit logging unavailable")
- }
-
- return err
- }
-}
diff --git a/backend/internal/service/descope_service.go b/backend/internal/service/descope_service.go
index e9f44889..42d0bab5 100644
--- a/backend/internal/service/descope_service.go
+++ b/backend/internal/service/descope_service.go
@@ -145,6 +145,101 @@ func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, er
return res, nil
}
+// UserExists는 loginID(이메일/전화번호) 기준으로 사용자가 있는지 확인합니다.
+func (d *DescopeProvider) UserExists(loginID string) (bool, error) {
+ if d.Client == nil {
+ return false, fmt.Errorf("descope provider: client is nil")
+ }
+
+ ctx := context.Background()
+ if strings.Contains(loginID, "@") {
+ user, err := d.Client.Management.User().Load(ctx, loginID)
+ if err != nil {
+ if isDescopeNotFound(err) {
+ return false, nil
+ }
+ return false, err
+ }
+ return user != nil, nil
+ }
+
+ phone := normalizePhone(loginID)
+ searchOptions := &descope.UserSearchOptions{
+ Phones: []string{phone},
+ Limit: 1,
+ }
+ users, _, err := d.Client.Management.User().SearchAll(ctx, searchOptions)
+ if err != nil {
+ return false, err
+ }
+ return len(users) > 0, nil
+}
+
+// IssueSession은 비밀번호 없이 로그인 세션을 발급합니다.
+func (d *DescopeProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
+ if d.Client == nil {
+ return nil, fmt.Errorf("descope provider: client is nil")
+ }
+ ctx := context.Background()
+
+ targetLoginID, err := d.resolveLoginID(loginID)
+ if err != nil {
+ return nil, err
+ }
+
+ embeddedToken, err := d.Client.Management.User().GenerateEmbeddedLink(ctx, targetLoginID, nil, 0)
+ if err != nil {
+ return nil, fmt.Errorf("descope provider: generate embedded link failed: %w", err)
+ }
+
+ authInfo, err := d.Client.Auth.MagicLink().Verify(ctx, embeddedToken, nil)
+ if err != nil {
+ return nil, fmt.Errorf("descope provider: magic link verify failed: %w", err)
+ }
+
+ res := &domain.AuthInfo{
+ SessionToken: &domain.Token{
+ JWT: authInfo.SessionToken.JWT,
+ Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
+ },
+ Subject: authInfo.User.UserID,
+ }
+ 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) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
+ return nil, domain.ErrNotSupported
+}
+
+func (d *DescopeProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
+ return nil, domain.ErrNotSupported
+}
+
+// GetPasswordPolicy는 Descope 비밀번호 정책을 반환합니다.
+func (d *DescopeProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
+ if d.Client == nil {
+ return nil, fmt.Errorf("descope provider: client is nil")
+ }
+ policy, err := d.Client.Auth.Password().GetPasswordPolicy(context.Background())
+ if err != nil {
+ return nil, err
+ }
+ return &domain.PasswordPolicy{
+ MinLength: int(policy.MinLength),
+ Lowercase: policy.Lowercase,
+ Uppercase: policy.Uppercase,
+ Number: policy.Number,
+ NonAlphanumeric: policy.NonAlphanumeric,
+ MinCharacterTypes: 0,
+ }, nil
+}
+
func (d *DescopeProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
ctx := context.Background()
err := d.Client.Auth.Password().SendPasswordReset(ctx, loginID, redirectUrl, nil)
@@ -197,3 +292,57 @@ func (d *DescopeProvider) UpdateUserPassword(loginID, newPassword string, r *htt
ctx := context.Background()
return d.Client.Auth.Password().UpdateUserPassword(ctx, loginID, newPassword, r)
}
+
+func (d *DescopeProvider) resolveLoginID(loginID string) (string, error) {
+ if strings.Contains(loginID, "@") {
+ return loginID, nil
+ }
+
+ phone := normalizePhone(loginID)
+ searchOptions := &descope.UserSearchOptions{
+ Phones: []string{phone},
+ Limit: 1,
+ }
+ users, _, err := d.Client.Management.User().SearchAll(context.Background(), searchOptions)
+ if err != nil {
+ return "", fmt.Errorf("descope provider: user search failed: %w", err)
+ }
+ if len(users) == 0 {
+ return "", fmt.Errorf("descope provider: user not found")
+ }
+ if len(users[0].LoginIDs) > 0 {
+ return users[0].LoginIDs[0], nil
+ }
+ if users[0].UserID != "" {
+ return users[0].UserID, nil
+ }
+ return "", fmt.Errorf("descope provider: user found but login id missing")
+}
+
+func normalizePhone(phone string) string {
+ normalized := strings.ReplaceAll(phone, "-", "")
+ normalized = strings.ReplaceAll(normalized, " ", "")
+ if strings.HasPrefix(normalized, "010") {
+ return "+82" + normalized[1:]
+ }
+ if strings.HasPrefix(normalized, "82") {
+ return "+" + normalized
+ }
+ return normalized
+}
+
+func isDescopeNotFound(err error) bool {
+ if de, ok := err.(*descope.Error); ok {
+ if rawStatus, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok {
+ switch v := rawStatus.(type) {
+ case int:
+ return v == http.StatusNotFound
+ case float64:
+ return int(v) == http.StatusNotFound
+ case string:
+ return v == fmt.Sprintf("%d", http.StatusNotFound)
+ }
+ }
+ }
+ return false
+}
diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go
index bdbc6afe..9bc54e4f 100644
--- a/backend/internal/service/ory_service.go
+++ b/backend/internal/service/ory_service.go
@@ -12,6 +12,7 @@ import (
"net/http"
"net/url"
"os"
+ "strings"
"time"
)
@@ -63,6 +64,15 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
if existingID != "" {
return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email)
}
+ if user.PhoneNumber != "" {
+ existingPhoneID, err := o.findIdentityID(user.PhoneNumber)
+ if err != nil {
+ return "", fmt.Errorf("ory provider: search identity failed: %w", err)
+ }
+ if existingPhoneID != "" {
+ return "", fmt.Errorf("ory provider: identity already exists for phone=%s", user.PhoneNumber)
+ }
+ }
traits := map[string]interface{}{
"email": user.Email,
@@ -84,6 +94,27 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri
},
},
}
+ verifiable := []map[string]interface{}{
+ {
+ "value": user.Email,
+ "verified": true,
+ "via": "email",
+ },
+ }
+ if user.PhoneNumber != "" {
+ verifiable = append(verifiable, map[string]interface{}{
+ "value": user.PhoneNumber,
+ "verified": true,
+ "via": "sms",
+ })
+ }
+ payload["verifiable_addresses"] = verifiable
+ payload["recovery_addresses"] = []map[string]interface{}{
+ {
+ "value": user.Email,
+ "via": "email",
+ },
+ }
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, fmt.Sprintf("%s/admin/identities", o.KratosAdminURL), bytes.NewReader(body))
@@ -119,7 +150,7 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error)
return nil, fmt.Errorf("ory provider: loginID and password are required")
}
- flowID, err := o.startLoginFlow()
+ flowID, err := o.startLoginFlow("")
if err != nil {
return nil, err
}
@@ -178,6 +209,462 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error)
}, nil
}
+// UserExists는 Kratos Admin API로 loginID 존재 여부를 확인합니다.
+func (o *OryProvider) UserExists(loginID string) (bool, error) {
+ if loginID == "" {
+ return false, fmt.Errorf("ory provider: loginID is empty")
+ }
+ identityID, err := o.findIdentityID(loginID)
+ if err != nil {
+ return false, fmt.Errorf("ory provider: find identity failed: %w", err)
+ }
+ return identityID != "", nil
+}
+
+// IssueSession은 Ory에서 별도 세션 발급이 필요할 때 사용합니다. (현재 미지원)
+func (o *OryProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
+ return nil, domain.ErrNotSupported
+}
+
+// InitiateLinkLogin은 Kratos Public API로 링크 로그인 플로우를 시작하고 이메일 전송을 트리거합니다.
+func (o *OryProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
+ if loginID == "" {
+ return nil, fmt.Errorf("ory provider: loginID is required")
+ }
+
+ effectiveLoginID, err := o.resolveEffectiveLoginID(loginID)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := o.ensureCodeLoginIdentifier(effectiveLoginID); err != nil {
+ return nil, err
+ }
+
+ init, err := o.submitLoginCodeInit(effectiveLoginID, returnTo)
+ if err == nil {
+ init.LoginID = effectiveLoginID
+ return init, nil
+ }
+
+ if shouldBootstrapCodeLogin(err) {
+ if ensureErr := o.ensureCodeLoginIdentifier(effectiveLoginID); ensureErr == nil {
+ init, initErr := o.submitLoginCodeInit(effectiveLoginID, returnTo)
+ if initErr == nil {
+ init.LoginID = effectiveLoginID
+ }
+ return init, initErr
+ } else {
+ slog.Warn("Ory code login bootstrap failed", "loginID", effectiveLoginID, "error", ensureErr)
+ }
+ }
+
+ return nil, err
+}
+
+func (o *OryProvider) resolveEffectiveLoginID(loginID string) (string, error) {
+ if strings.Contains(loginID, "@") {
+ return loginID, nil
+ }
+
+ identityID, err := o.findIdentityID(loginID)
+ if err != nil {
+ return "", err
+ }
+ if identityID == "" {
+ return "", fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
+ }
+
+ fullIdentity, err := o.fetchIdentityFull(identityID)
+ if err != nil {
+ return "", err
+ }
+ if fullIdentity != nil {
+ if emailRaw, ok := fullIdentity.Traits["email"]; ok {
+ if email, ok := emailRaw.(string); ok && email != "" {
+ return email, nil
+ }
+ }
+ }
+
+ return "", fmt.Errorf("ory provider: email trait missing for loginID=%s", loginID)
+}
+
+func (o *OryProvider) submitLoginCodeInit(loginID, returnTo string) (*domain.LinkLoginInit, error) {
+ flowID, err := o.startLoginFlow(returnTo)
+ if err != nil {
+ return nil, err
+ }
+
+ body, _ := json.Marshal(map[string]string{
+ "method": "code",
+ "identifier": loginID,
+ })
+ loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID)
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("ory provider: build link login request failed: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := o.httpClient().Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("ory provider: link login request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ if resp.StatusCode >= 300 {
+ init, ok := parseKratosLinkLoginResponse(flowID, respBody)
+ if ok {
+ slog.Info("Ory link login initiated with non-2xx response", "loginID", loginID, "flow_id", flowID, "status", resp.StatusCode)
+ return init, nil
+ }
+ return nil, fmt.Errorf("ory provider: link login failed status=%d body=%s", resp.StatusCode, string(respBody))
+ }
+
+ var result struct {
+ ExpiresAt time.Time `json:"expires_at"`
+ }
+ _ = json.Unmarshal(respBody, &result)
+
+ slog.Info("Ory link login initiated", "loginID", loginID, "flow_id", flowID)
+
+ return &domain.LinkLoginInit{
+ FlowID: flowID,
+ ExpiresAt: result.ExpiresAt,
+ Mode: "link",
+ }, nil
+}
+
+func parseKratosLinkLoginResponse(flowID string, body []byte) (*domain.LinkLoginInit, bool) {
+ if len(body) == 0 {
+ return nil, false
+ }
+ var parsed struct {
+ ExpiresAt time.Time `json:"expires_at"`
+ State string `json:"state"`
+ Active string `json:"active"`
+ }
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return nil, false
+ }
+ state := strings.ToLower(parsed.State)
+ active := strings.ToLower(parsed.Active)
+ if strings.Contains(state, "sent") || active == "code" {
+ return &domain.LinkLoginInit{
+ FlowID: flowID,
+ ExpiresAt: parsed.ExpiresAt,
+ Mode: "link",
+ }, true
+ }
+ return nil, false
+}
+
+func shouldBootstrapCodeLogin(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := strings.ToLower(err.Error())
+ return strings.Contains(msg, "has not setup sign in with code") ||
+ strings.Contains(msg, "4000035")
+}
+
+type kratosVerifiableAddress struct {
+ Value string `json:"value"`
+ Via string `json:"via"`
+ Verified bool `json:"verified"`
+ Status string `json:"status,omitempty"`
+}
+
+func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
+ identityID, err := o.findIdentityID(loginID)
+ if err != nil {
+ return fmt.Errorf("ory provider: find identity failed: %w", err)
+ }
+ if identityID == "" {
+ return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
+ }
+
+ identity, err := o.fetchIdentity(identityID)
+ if err != nil {
+ return err
+ }
+
+ via := "sms"
+ if strings.Contains(loginID, "@") {
+ via = "email"
+ }
+
+ exists := false
+ existingIndex := -1
+ addresses := make([]kratosVerifiableAddress, 0, len(identity.VerifiableAddresses)+1)
+ for idx, addr := range identity.VerifiableAddresses {
+ addresses = append(addresses, kratosVerifiableAddress{
+ Value: addr.Value,
+ Via: addr.Via,
+ Verified: addr.Verified,
+ Status: addr.Status,
+ })
+ if addr.Value == loginID && addr.Via == via {
+ exists = true
+ existingIndex = idx
+ }
+ }
+ ops := make([]map[string]interface{}, 0, 2)
+ if !exists {
+ ops = append(ops, map[string]interface{}{
+ "op": "add",
+ "path": "/verifiable_addresses/-",
+ "value": map[string]interface{}{
+ "value": loginID,
+ "via": via,
+ "verified": true,
+ "status": "completed",
+ },
+ })
+ } else {
+ addr := identity.VerifiableAddresses[existingIndex]
+ if !addr.Verified {
+ ops = append(ops, map[string]interface{}{
+ "op": "replace",
+ "path": fmt.Sprintf("/verifiable_addresses/%d/verified", existingIndex),
+ "value": true,
+ })
+ }
+ if addr.Status != "" && addr.Status != "completed" {
+ ops = append(ops, map[string]interface{}{
+ "op": "replace",
+ "path": fmt.Sprintf("/verifiable_addresses/%d/status", existingIndex),
+ "value": "completed",
+ })
+ }
+ }
+
+ if len(ops) == 0 {
+ slog.Info("Ory identity verifiable address already ready", "identity_id", identityID, "loginID", loginID, "via", via)
+ return nil
+ }
+
+ if err := o.patchIdentity(identityID, ops); err != nil {
+ slog.Warn("Ory identity patch failed, trying full update", "identity_id", identityID, "error", err)
+ }
+
+ fullIdentity, err := o.fetchIdentityFull(identityID)
+ if err != nil {
+ return err
+ }
+
+ addresses = make([]kratosVerifiableAddress, 0, len(fullIdentity.VerifiableAddresses)+1)
+ found := false
+ for _, addr := range fullIdentity.VerifiableAddresses {
+ addresses = append(addresses, kratosVerifiableAddress{
+ Value: addr.Value,
+ Via: addr.Via,
+ Verified: addr.Verified,
+ Status: addr.Status,
+ })
+ if addr.Value == loginID && addr.Via == via {
+ found = true
+ }
+ }
+ if !found {
+ addresses = append(addresses, kratosVerifiableAddress{
+ Value: loginID,
+ Via: via,
+ Verified: true,
+ Status: "completed",
+ })
+ }
+
+ payload := map[string]interface{}{
+ "schema_id": fullIdentity.SchemaID,
+ "traits": fullIdentity.Traits,
+ "verifiable_addresses": addresses,
+ }
+ if len(fullIdentity.RecoveryAddresses) > 0 {
+ payload["recovery_addresses"] = fullIdentity.RecoveryAddresses
+ }
+
+ body, _ := json.Marshal(payload)
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("ory provider: build identity update failed: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := o.httpClient().Do(req)
+ if err != nil {
+ return fmt.Errorf("ory provider: identity update failed: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 {
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ return fmt.Errorf("ory provider: identity update failed status=%d body=%s", resp.StatusCode, string(respBody))
+ }
+
+ slog.Info("Ory identity updated with verifiable address", "identity_id", identityID, "loginID", loginID, "via", via)
+ return nil
+}
+
+type kratosIdentity struct {
+ VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
+}
+
+type kratosRecoveryAddress struct {
+ Value string `json:"value"`
+ Via string `json:"via"`
+}
+
+type kratosIdentityFull struct {
+ SchemaID string `json:"schema_id"`
+ Traits map[string]interface{} `json:"traits"`
+ VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
+ RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"`
+}
+
+func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error {
+ body, _ := json.Marshal(ops)
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("ory provider: build identity patch failed: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json-patch+json")
+
+ resp, err := o.httpClient().Do(req)
+ if err != nil {
+ return fmt.Errorf("ory provider: identity patch failed: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 {
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ return fmt.Errorf("ory provider: identity patch failed status=%d body=%s", resp.StatusCode, string(respBody))
+ }
+
+ slog.Info("Ory identity patched", "identity_id", identityID, "ops", len(ops))
+ return nil
+}
+
+func (o *OryProvider) fetchIdentity(identityID string) (*kratosIdentity, error) {
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil)
+ if err != nil {
+ return nil, fmt.Errorf("ory provider: build identity get failed: %w", err)
+ }
+
+ resp, err := o.httpClient().Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("ory provider: identity get failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ return nil, fmt.Errorf("ory provider: identity get failed status=%d body=%s", resp.StatusCode, string(body))
+ }
+
+ var identity kratosIdentity
+ if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
+ return nil, fmt.Errorf("ory provider: decode identity failed: %w", err)
+ }
+ return &identity, nil
+}
+
+func (o *OryProvider) fetchIdentityFull(identityID string) (*kratosIdentityFull, error) {
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil)
+ if err != nil {
+ return nil, fmt.Errorf("ory provider: build identity get failed: %w", err)
+ }
+
+ resp, err := o.httpClient().Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("ory provider: identity get failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ return nil, fmt.Errorf("ory provider: identity get failed status=%d body=%s", resp.StatusCode, string(body))
+ }
+
+ var identity kratosIdentityFull
+ if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
+ return nil, fmt.Errorf("ory provider: decode identity failed: %w", err)
+ }
+ return &identity, nil
+}
+
+// VerifyLoginCode는 Kratos 로그인 코드 제출로 세션을 발급합니다.
+func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
+ if loginID == "" || flowID == "" || code == "" {
+ return nil, fmt.Errorf("ory provider: loginID, flowID and code are required")
+ }
+
+ body, _ := json.Marshal(map[string]string{
+ "method": "code",
+ "identifier": loginID,
+ "code": code,
+ })
+ loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID)
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("ory provider: build login code request failed: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := o.httpClient().Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("ory provider: login code request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 300 {
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
+ return nil, fmt.Errorf("ory provider: login code failed status=%d body=%s", resp.StatusCode, string(respBody))
+ }
+
+ var result struct {
+ SessionToken string `json:"session_token"`
+ SessionTokenExpiresAt time.Time `json:"session_token_expires_at"`
+ Session struct {
+ Identity struct {
+ ID string `json:"id"`
+ } `json:"identity"`
+ } `json:"session"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("ory provider: decode login code response failed: %w", err)
+ }
+ if result.SessionToken == "" {
+ return nil, fmt.Errorf("ory provider: empty session token returned")
+ }
+
+ slog.Info("Ory login code successful",
+ "identity_id", result.Session.Identity.ID,
+ "loginID", loginID,
+ "expires_at", result.SessionTokenExpiresAt,
+ )
+
+ return &domain.AuthInfo{
+ SessionToken: &domain.Token{
+ JWT: result.SessionToken,
+ Expiration: result.SessionTokenExpiresAt,
+ },
+ Subject: result.Session.Identity.ID,
+ }, nil
+}
+
+// GetPasswordPolicy는 Ory 환경에서 사용하는 기본 정책을 반환합니다.
+func (o *OryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
+ return &domain.PasswordPolicy{
+ MinLength: 12,
+ Lowercase: true,
+ Uppercase: false,
+ Number: true,
+ NonAlphanumeric: true,
+ MinCharacterTypes: 0,
+ }, nil
+}
+
// InitiatePasswordReset는 현재 내부 토큰/메일 흐름을 사용하고 있으므로 NO-OP로 둡니다.
func (o *OryProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
slog.Info("Ory InitiatePasswordReset bypassed (handled by app internal flow)", "loginID", loginID, "redirect", redirectUrl)
@@ -301,8 +788,12 @@ func (o *OryProvider) httpClient() *http.Client {
}
// startLoginFlow는 Kratos Public API에서 login flow ID를 발급받습니다.
-func (o *OryProvider) startLoginFlow() (string, error) {
- req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/self-service/login/api", o.KratosPublicURL), nil)
+func (o *OryProvider) startLoginFlow(returnTo string) (string, error) {
+ loginURL := fmt.Sprintf("%s/self-service/login/api", o.KratosPublicURL)
+ if returnTo != "" {
+ loginURL = loginURL + "?return_to=" + url.QueryEscape(returnTo)
+ }
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, loginURL, nil)
if err != nil {
return "", fmt.Errorf("ory provider: build login flow request failed: %w", err)
}
diff --git a/backend/internal/utils/masking.go b/backend/internal/utils/masking.go
new file mode 100644
index 00000000..ab83f5ca
--- /dev/null
+++ b/backend/internal/utils/masking.go
@@ -0,0 +1,79 @@
+package utils
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+var sensitiveKeys = map[string]struct{}{
+ "password": {},
+ "newpassword": {},
+ "oldpassword": {},
+ "token": {},
+ "accesstoken": {},
+ "access_token": {},
+ "refreshtoken": {},
+ "refresh_token": {},
+ "secret": {},
+ "clientsecret": {},
+ "client_secret": {},
+ "authorization": {},
+ "cookie": {},
+ "set-cookie": {},
+ "verificationcode": {},
+ "verification_code": {},
+ "code": {}, // Auth code (sensitive)
+}
+
+// MaskSensitiveJSON parses a JSON byte slice and masks values of sensitive keys.
+// Returns the original data if it's not valid JSON.
+func MaskSensitiveJSON(data []byte) []byte {
+ if len(data) == 0 {
+ return data
+ }
+
+ var obj interface{}
+ if err := json.Unmarshal(data, &obj); err != nil {
+ // Not a JSON object/array, return as is
+ return data
+ }
+
+ masked := maskValue(obj)
+
+ result, err := json.Marshal(masked)
+ if err != nil {
+ return data
+ }
+ return result
+}
+
+func maskValue(v interface{}) interface{} {
+ switch val := v.(type) {
+ case map[string]interface{}:
+ newMap := make(map[string]interface{}, len(val))
+ for k, v := range val {
+ if isSensitive(k) {
+ newMap[k] = "*****"
+ } else {
+ newMap[k] = maskValue(v)
+ }
+ }
+ return newMap
+ case []interface{}:
+ newArr := make([]interface{}, len(val))
+ for i, v := range val {
+ newArr[i] = maskValue(v)
+ }
+ return newArr
+ default:
+ return val
+ }
+}
+
+func isSensitive(key string) bool {
+ // Check case-insensitive
+ // Remove common separators for looser matching? No, stick to lowercase check for now.
+ k := strings.ToLower(key)
+ _, ok := sensitiveKeys[k]
+ return ok
+}
diff --git a/backend/internal/utils/masking_test.go b/backend/internal/utils/masking_test.go
new file mode 100644
index 00000000..6db6c30c
--- /dev/null
+++ b/backend/internal/utils/masking_test.go
@@ -0,0 +1,59 @@
+package utils
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestMaskSensitiveJSON(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string // We'll check containment or specific structure
+ }{
+ {
+ name: "Flat object with password",
+ input: `{"username": "user", "password": "secret123"}`,
+ expected: `{"password":"*****","username":"user"}`,
+ },
+ {
+ name: "Nested object with token",
+ input: `{"data": {"token": "abc-def", "id": 123}}`,
+ expected: `{"data":{"id":123,"token":"*****"}}`,
+ },
+ {
+ name: "Case insensitive key",
+ input: `{"NewPassword": "changed"}`,
+ expected: `{"NewPassword":"*****"}`,
+ },
+ {
+ name: "Array of objects",
+ input: `[{"secret": "s1"}, {"secret": "s2"}]`,
+ expected: `[{"secret":"*****"},{"secret":"*****"}]`,
+ },
+ {
+ name: "Invalid JSON",
+ input: `not-json`,
+ expected: `not-json`,
+ },
+ {
+ name: "Empty JSON",
+ input: ``,
+ expected: ``,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := MaskSensitiveJSON([]byte(tt.input))
+ // Since JSON map order is undefined, exact string match might fail if keys are reordered.
+ // Ideally we should unmarshal and compare maps, or use assert.JSONEq
+ if tt.name == "Invalid JSON" || tt.name == "Empty JSON" {
+ assert.Equal(t, tt.expected, string(result))
+ } else {
+ assert.JSONEq(t, tt.expected, string(result))
+ }
+ })
+ }
+}
diff --git a/backend/internal/validator/schema_validator_test.go b/backend/internal/validator/schema_validator_test.go
index 75ebcbc7..5bcf405f 100644
--- a/backend/internal/validator/schema_validator_test.go
+++ b/backend/internal/validator/schema_validator_test.go
@@ -29,6 +29,26 @@ func (m *MockProvider) SignIn(loginID, password string) (*domain.AuthInfo, error
return &domain.AuthInfo{}, nil
}
+func (m *MockProvider) UserExists(loginID string) (bool, error) {
+ return false, nil
+}
+
+func (m *MockProvider) IssueSession(loginID string) (*domain.AuthInfo, error) {
+ return nil, domain.ErrNotSupported
+}
+
+func (m *MockProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
+ return nil, domain.ErrNotSupported
+}
+
+func (m *MockProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
+ return nil, domain.ErrNotSupported
+}
+
+func (m *MockProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
+ return nil, domain.ErrNotSupported
+}
+
// Stub implementations to satisfy the IdentityProvider interface for this unit test.
func (m *MockProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
return nil
diff --git a/compose.infra.yaml b/compose.infra.yaml
index f53bfe4e..2baa1405 100644
--- a/compose.infra.yaml
+++ b/compose.infra.yaml
@@ -27,6 +27,7 @@ services:
clickhouse:
image: clickhouse/clickhouse-server:latest
container_name: baron_clickhouse
+ restart: always
environment:
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-baron}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-password}
diff --git a/compose.ory.yaml b/compose.ory.yaml
index 3baa589f..1743a1c5 100644
--- a/compose.ory.yaml
+++ b/compose.ory.yaml
@@ -71,27 +71,9 @@ services:
- ory-net
- kratosnet
- kratos-mcp-server:
- build:
- context: ./mcp/kratos-mcp
- container_name: mcp_ory_kratos
- profiles:
- - mcp
- stdin_open: true
- tty: true
- init: true
- environment:
- - KRATOS_ADMIN_URL=http://kratos:4434
- depends_on:
- - kratos
- networks:
- - ory-net
-
kratos-ui:
image: oryd/kratos-selfservice-ui-node:${KRATOS_UI_NODE_VERSION:-v25.4.0}
container_name: ory_kratos_ui
- ports:
- - "${KRATOS_UI_PORT:-4455}:4455"
environment:
- KRATOS_PUBLIC_URL=${KRATOS_PUBLIC_URL:-http://kratos:4433/}
- KRATOS_BROWSER_URL=${KRATOS_BROWSER_URL:-http://localhost:${KRATOS_PUBLIC_PORT:-4433}}
@@ -119,8 +101,6 @@ services:
hydra:
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
container_name: ory_hydra
- ports:
- - "${HYDRA_PUBLIC_PORT:-4441}:4444"
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20
- URLS_SELF_ISSUER=${BACKEND_URL:-http://127.0.0.1:3000}
@@ -137,39 +117,7 @@ services:
- ory-net
- hydranet
- hydra-mcp-server:
- build:
- context: ./mcp/hydra-mcp
- container_name: mcp_ory_hydra
- profiles:
- - mcp
- stdin_open: true
- tty: true
- init: true
- environment:
- - HYDRA_PUBLIC_URL=http://hydra:4444
- - HYDRA_ADMIN_URL=http://hydra:4445
- depends_on:
- - hydra
- networks:
- - ory-net
- keto-mcp-server:
- build:
- context: ./mcp/keto-mcp
- container_name: mcp_ory_keto
- profiles:
- - mcp
- stdin_open: true
- tty: true
- init: true
- environment:
- - KETO_READ_URL=http://keto:4466
- - KETO_WRITE_URL=http://keto:4467
- depends_on:
- - keto
- networks:
- - ory-net
# --- Keto ---
keto-migrate:
@@ -188,9 +136,6 @@ services:
keto:
image: oryd/keto:${KETO_VERSION:-v25.4.0}
container_name: ory_keto
- ports:
- - "${KETO_READ_PORT:-4466}:4466"
- - "${KETO_WRITE_PORT:-4467}:4467"
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20
volumes:
@@ -204,16 +149,43 @@ services:
# --- Oathkeeper ---
oathkeeper:
- image: oryd/oathkeeper:v0.40.6
+ image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v25.4.0}
container_name: ory_oathkeeper
+ user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}"
ports:
- - "4456:4456" # API
- "4457:4455" # Proxy
environment:
- - LOG_LEVEL=debug
+ - APP_ENV=${APP_ENV:-development}
volumes:
- ./docker/ory/oathkeeper:/etc/config/oathkeeper
- command: serve proxy -c /etc/config/oathkeeper/oathkeeper.yml
+ - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper
+ entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"]
+ networks:
+ - ory-net
+ - public_net
+
+ ory_clickhouse:
+ image: clickhouse/clickhouse-server:latest
+ container_name: ory_clickhouse
+ environment:
+ - CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory}
+ - CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass}
+ volumes:
+ - ory_clickhouse_data:/var/lib/clickhouse
+ - ./docker/ory/clickhouse:/docker-entrypoint-initdb.d
+ networks:
+ - ory-net
+
+ ory_vector:
+ image: timberio/vector:0.36.0-alpine
+ container_name: ory_vector
+ volumes:
+ - ./docker/ory/vector:/etc/vector
+ - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper
+ command: ["-c", "/etc/vector/vector.toml"]
+ depends_on:
+ - oathkeeper
+ - ory_clickhouse
networks:
- ory-net
@@ -268,6 +240,7 @@ services:
volumes:
ory_postgres_data:
+ ory_clickhouse_data:
networks:
ory-net:
@@ -279,3 +252,6 @@ networks:
kratosnet:
external: true
name: kratosnet
+ public_net:
+ external: true
+ name: public_net
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 9cb73a97..8ff21446 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -36,7 +36,7 @@ services:
- ory-net
volumes:
- ./backend:/app
- command: ["go", "run", "./cmd/server/main.go"]
+ command: ["go", "run", "./cmd/server"]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
@@ -80,7 +80,6 @@ services:
- /app/node_modules
networks:
- baron_net
-
userfront:
build:
context: ./userfront
@@ -97,6 +96,8 @@ services:
- "${USERFRONT_PORT:-5000}:5000"
networks:
- baron_net
+ - public_net
+
depends_on:
backend:
condition: service_healthy
@@ -126,3 +127,6 @@ networks:
ory-net:
external: true
name: ory-net
+ public_net:
+ external: true
+ name: public_net
diff --git a/docker/ory/clickhouse/init.sql b/docker/ory/clickhouse/init.sql
new file mode 100644
index 00000000..37dc8d1e
--- /dev/null
+++ b/docker/ory/clickhouse/init.sql
@@ -0,0 +1,22 @@
+CREATE DATABASE IF NOT EXISTS ory;
+
+CREATE TABLE IF NOT EXISTS ory.oathkeeper_access_logs (
+ timestamp DateTime64(3) DEFAULT now64(3),
+ request_id String DEFAULT '',
+ method String DEFAULT '',
+ path String DEFAULT '',
+ status UInt16 DEFAULT 0,
+ latency_ms UInt32 DEFAULT 0,
+ rp String DEFAULT '',
+ action String DEFAULT '',
+ target String DEFAULT '',
+ subject String DEFAULT '',
+ client_ip String DEFAULT '',
+ user_agent String DEFAULT '',
+ decision String DEFAULT '',
+ trace_id String DEFAULT '',
+ span_id String DEFAULT '',
+ raw String DEFAULT ''
+) ENGINE = MergeTree()
+ORDER BY (timestamp, request_id)
+TTL timestamp + INTERVAL 30 DAY;
diff --git a/docker/ory/kratos/courier-http.jsonnet b/docker/ory/kratos/courier-http.jsonnet
new file mode 100644
index 00000000..10cf3f72
--- /dev/null
+++ b/docker/ory/kratos/courier-http.jsonnet
@@ -0,0 +1,8 @@
+// Kratos courier HTTP payload을 backend로 전달하는 템플릿입니다.
+function(ctx)
+ local data = if std.objectHas(ctx, "template_data") && ctx.template_data != null then ctx.template_data else {};
+ {
+ recipient: ctx.recipient,
+ template_type: ctx.template_type,
+ template_data: data,
+ }
diff --git a/docker/ory/kratos/courier-templates/login_code/valid/email.body.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/email.body.gotmpl
new file mode 100644
index 00000000..2d2b1c20
--- /dev/null
+++ b/docker/ory/kratos/courier-templates/login_code/valid/email.body.gotmpl
@@ -0,0 +1,17 @@
+
+
+
+
Baron SSO 로그인
+
아래 버튼을 클릭하면 로그인이 완료됩니다.
+
+
+
+ 로그인 완료하기
+
+
+
또는 아래 로그인 코드를 입력해도 됩니다.
+
{{ .LoginCode }}
+
요청하지 않았다면 이 메일을 무시해 주세요.
+
+
diff --git a/docker/ory/kratos/courier-templates/login_code/valid/email.body.plaintext.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/email.body.plaintext.gotmpl
new file mode 100644
index 00000000..0f08b2a7
--- /dev/null
+++ b/docker/ory/kratos/courier-templates/login_code/valid/email.body.plaintext.gotmpl
@@ -0,0 +1,10 @@
+Baron SSO 로그인
+
+# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }}
+
+아래 링크를 클릭하면 로그인이 완료됩니다.
+http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }}
+
+로그인 코드: {{ .LoginCode }}
+
+요청하지 않았다면 이 메일을 무시해 주세요.
diff --git a/docker/ory/kratos/courier-templates/login_code/valid/email.subject.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/email.subject.gotmpl
new file mode 100644
index 00000000..2ca5dc3b
--- /dev/null
+++ b/docker/ory/kratos/courier-templates/login_code/valid/email.subject.gotmpl
@@ -0,0 +1 @@
+Baron SSO 로그인 링크
diff --git a/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl
new file mode 100644
index 00000000..cacba938
--- /dev/null
+++ b/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl
@@ -0,0 +1,4 @@
+[Baron 통합로그인] 로그인 링크
+# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }}
+http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }}
+코드: {{ .LoginCode }}
diff --git a/docker/ory/kratos/identity.schema.json b/docker/ory/kratos/identity.schema.json
index 16bcbadf..d967d074 100644
--- a/docker/ory/kratos/identity.schema.json
+++ b/docker/ory/kratos/identity.schema.json
@@ -16,6 +16,10 @@
"credentials": {
"password": {
"identifier": true
+ },
+ "code": {
+ "identifier": true,
+ "via": "email"
}
},
"recovery": {
@@ -27,17 +31,68 @@
}
},
"name": {
- "type": "object",
- "properties": {
- "first": {
- "type": "string",
- "title": "First Name"
- },
- "last": {
- "type": "string",
- "title": "Last Name"
+ "type": "string",
+ "title": "Name"
+ },
+ "phone_number": {
+ "type": "string",
+ "title": "Phone Number",
+ "minLength": 7,
+ "ory.sh/kratos": {
+ "credentials": {
+ "password": {
+ "identifier": true
+ },
+ "code": {
+ "identifier": true,
+ "via": "sms"
+ }
}
}
+ },
+ "department": {
+ "type": "string",
+ "title": "Department"
+ },
+ "affiliationType": {
+ "type": "string",
+ "title": "Affiliation Type"
+ },
+ "companyCode": {
+ "type": "string",
+ "title": "Company Code"
+ },
+ "displayname": {
+ "type": "string",
+ "title": "Display Name"
+ },
+ "completeForm": {
+ "type": "boolean",
+ "title": "Complete Form"
+ },
+ "team": {
+ "type": "string",
+ "title": "Team"
+ },
+ "taxCode": {
+ "type": "string",
+ "title": "Tax Code"
+ },
+ "familyCompany": {
+ "type": "string",
+ "title": "Family Company"
+ },
+ "familyUniqueKey": {
+ "type": "string",
+ "title": "Family Unique Key"
+ },
+ "personal": {
+ "type": "boolean",
+ "title": "Personal"
+ },
+ "grade": {
+ "type": "string",
+ "title": "Grade"
}
},
"required": [
@@ -46,4 +101,4 @@
"additionalProperties": false
}
}
-}
\ No newline at end of file
+}
diff --git a/docker/ory/kratos/kratos.yml b/docker/ory/kratos/kratos.yml
index 27ed71a2..277dc1d0 100644
--- a/docker/ory/kratos/kratos.yml
+++ b/docker/ory/kratos/kratos.yml
@@ -3,74 +3,90 @@ version: v1.3.0
dsn: memory
serve:
- public:
- base_url: http://localhost:4433/
- cors:
- enabled: true
- admin:
- base_url: http://localhost:4434/
+ public:
+ base_url: http://localhost:4433/
+ cors:
+ enabled: true
+ admin:
+ base_url: http://localhost:4434/
selfservice:
- default_browser_return_url: http://localhost:4455/
- allowed_return_urls:
- - http://localhost:4455
- - http://localhost:5000
+ default_browser_return_url: http://localhost:4455/
+ allowed_return_urls:
+ - http://localhost:4455
+ - http://localhost:5000
+ - https://sss.hmac.kr
+ - https://sss.hmac.kr/
+ - https://sso.hmac.kr
+ - https://sso.hmac.kr/
+ - https://app.hmac.kr
+ - https://app.hmac.kr/
- methods:
- password:
- enabled: true
- link:
- enabled: true
- code:
- enabled: true
+ methods:
+ password:
+ enabled: true
+ link:
+ enabled: true
+ code:
+ enabled: true
+ passwordless_enabled: true
- flows:
- error:
- ui_url: http://localhost:4455/error
- settings:
- ui_url: http://localhost:4455/settings
- privileged_session_max_age: 15m
- recovery:
- ui_url: http://localhost:4455/recovery
- use: code
- verification:
- ui_url: http://localhost:4455/verification
- use: code
- logout:
- after:
- default_browser_return_url: http://localhost:4455/login
- login:
- ui_url: http://localhost:4455/login
- lifespan: 10m
- registration:
- ui_url: http://localhost:4455/registration
- lifespan: 10m
+ flows:
+ error:
+ ui_url: http://localhost:4455/error
+ settings:
+ ui_url: http://localhost:4455/settings
+ privileged_session_max_age: 15m
+ recovery:
+ ui_url: http://localhost:4455/recovery
+ use: code
+ verification:
+ ui_url: http://localhost:4455/verification
+ use: code
+ logout:
+ after:
+ default_browser_return_url: http://localhost:4455/login
+ login:
+ ui_url: http://localhost:4455/login
+ lifespan: 10m
+ registration:
+ ui_url: http://localhost:4455/registration
+ lifespan: 10m
log:
- level: debug
- format: text
- leak_sensitive_values: true
+ level: debug
+ format: text
+ leak_sensitive_values: true
secrets:
- cookie:
- - PLEASE-CHANGE-ME-I-AM-VERY-INSECURE
- cipher:
- - 32-LONG-SECRET-NOT-SECURE-AT-ALL
+ cookie:
+ - PLEASE-CHANGE-ME-I-AM-VERY-INSECURE
+ cipher:
+ - 32-LONG-SECRET-NOT-SECURE-AT-ALL
ciphers:
- algorithm: xchacha20-poly1305
+ algorithm: xchacha20-poly1305
hashers:
- algorithm: bcrypt
- bcrypt:
- cost: 8
+ algorithm: bcrypt
+ bcrypt:
+ cost: 8
identity:
- default_schema_id: default
- schemas:
- - id: default
- url: file:///etc/config/kratos/identity.schema.json
+ default_schema_id: default
+ schemas:
+ - id: default
+ url: file:///etc/config/kratos/identity.schema.json
courier:
- smtp:
- connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true
+ template_override_path: /etc/config/kratos/courier-templates
+ delivery_strategy: http
+ http:
+ request_config:
+ url: http://baron_backend:3000/api/v1/auth/webhooks/kratos-courier
+ method: POST
+ body: file:///etc/config/kratos/courier-http.jsonnet
+ headers:
+ Content-Type: application/json
+ smtp:
+ connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true
diff --git a/docker/ory/oathkeeper/entrypoint.sh b/docker/ory/oathkeeper/entrypoint.sh
new file mode 100755
index 00000000..506af8cd
--- /dev/null
+++ b/docker/ory/oathkeeper/entrypoint.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env sh
+set -eu
+
+APP_ENV_VALUE="${APP_ENV:-}"
+
+case "$APP_ENV_VALUE" in
+ production|prod)
+ RULES_FILE="/etc/config/oathkeeper/rules.prod.json"
+ ;;
+ stage|staging)
+ RULES_FILE="/etc/config/oathkeeper/rules.stage.json"
+ ;;
+ *)
+ RULES_FILE="/etc/config/oathkeeper/rules.json"
+ ;;
+ esac
+
+export RULES_FILE
+
+echo "[oathkeeper] APP_ENV=$APP_ENV_VALUE rules=$RULES_FILE"
+
+RULES_ACTIVE="/etc/config/oathkeeper/rules.active.json"
+if [ ! -f "$RULES_FILE" ]; then
+ echo "[oathkeeper] rules file not found: $RULES_FILE"
+ exit 1
+fi
+cp "$RULES_FILE" "$RULES_ACTIVE"
+
+LOG_DIR="/var/log/oathkeeper"
+LOG_FILE="${LOG_DIR}/access.log"
+mkdir -p "$LOG_DIR"
+if ! touch "$LOG_FILE" 2>/dev/null; then
+ echo "[oathkeeper] log file not writable: $LOG_FILE"
+ ls -ld "$LOG_DIR" || true
+ exit 1
+fi
+
+exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml 2>&1 | tee \"$LOG_FILE\""
diff --git a/docker/ory/oathkeeper/oathkeeper.yml b/docker/ory/oathkeeper/oathkeeper.yml
index 044d7f21..7e30286c 100644
--- a/docker/ory/oathkeeper/oathkeeper.yml
+++ b/docker/ory/oathkeeper/oathkeeper.yml
@@ -4,13 +4,17 @@ serve:
api:
port: 4456
+log:
+ level: info
+ format: json
+
errors:
fallback:
- json
access_rules:
repositories:
- - file:///etc/config/oathkeeper/rules.json
+ - file:///etc/config/oathkeeper/rules.active.json
authenticators:
noop:
@@ -30,6 +34,13 @@ authorizers:
enabled: true
config:
remote: http://keto:4466/check
+ payload: |
+ {
+ "namespace": "permissions",
+ "object": "{{ print .Request.URL.Path }}",
+ "relation": "access",
+ "subject_id": "{{ print .Subject }}"
+ }
mutators:
noop:
diff --git a/docker/ory/oathkeeper/rules.active.json b/docker/ory/oathkeeper/rules.active.json
new file mode 100644
index 00000000..e02c3382
--- /dev/null
+++ b/docker/ory/oathkeeper/rules.active.json
@@ -0,0 +1,92 @@
+[
+ {
+ "id": "public-health",
+ "description": "공개 헬스체크",
+ "match": {
+ "url": "http://<.*>/health",
+ "methods": ["GET"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "public-preflight",
+ "description": "CORS preflight",
+ "match": {
+ "url": "http://<.*>/api/v1/<.*>",
+ "methods": ["OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "public-auth",
+ "description": "인증/회원가입 등 공개 엔드포인트",
+ "match": {
+ "url": "http://<.*>/api/v1/auth/<.*>",
+ "methods": ["GET", "POST", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "backend-command",
+ "description": "Command 요청은 Backend로 전달 (Audit 강제)",
+ "match": {
+ "url": "http://<.*>/api/v1/<.*>",
+ "methods": ["POST", "PUT", "PATCH", "DELETE"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "cookie_session" }
+ ],
+ "authorizer": { "handler": "remote_json" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "backend-query",
+ "description": "Backend Query (admin/dev 포함)",
+ "match": {
+ "url": "http://<.*>/api/v1/<.*>",
+ "methods": ["GET"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "cookie_session" }
+ ],
+ "authorizer": { "handler": "remote_json" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ }
+]
diff --git a/docker/ory/oathkeeper/rules.draft.json b/docker/ory/oathkeeper/rules.draft.json
new file mode 100644
index 00000000..835689ec
--- /dev/null
+++ b/docker/ory/oathkeeper/rules.draft.json
@@ -0,0 +1,112 @@
+[
+ {
+ "id": "public-health",
+ "description": "공개 헬스체크 (TODO: 도메인 제한)",
+ "match": {
+ "url": "http://<.*>/health",
+ "methods": ["GET"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "public-auth",
+ "description": "인증/회원가입 등 공개 엔드포인트",
+ "match": {
+ "url": "http://<.*>/api/v1/auth/<.*>",
+ "methods": ["GET", "POST", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "backend-command",
+ "description": "Command 요청은 Backend로 전달 (Audit 강제)",
+ "match": {
+ "url": "http://<.*>/api/v1/<.*>",
+ "methods": ["POST", "PUT", "PATCH", "DELETE"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "cookie_session" }
+ ],
+ "authorizer": { "handler": "remote_json" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "backend-query",
+ "description": "Backend Query (admin/dev 포함)",
+ "match": {
+ "url": "http://<.*>/api/v1/<.*>",
+ "methods": ["GET"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "cookie_session" }
+ ],
+ "authorizer": { "handler": "remote_json" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "kratos-public",
+ "description": "Kratos Public API를 /kratos로 노출",
+ "match": {
+ "url": "http://<.*>/kratos/<.*>",
+ "methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://kratos:4433",
+ "strip_path": "/kratos"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "hydra-public",
+ "description": "Hydra Public API를 /hydra로 노출",
+ "match": {
+ "url": "http://<.*>/hydra/<.*>",
+ "methods": ["GET", "POST", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://hydra:4444",
+ "strip_path": "/hydra"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ }
+]
diff --git a/docker/ory/oathkeeper/rules.json b/docker/ory/oathkeeper/rules.json
index 0637a088..e02c3382 100644
--- a/docker/ory/oathkeeper/rules.json
+++ b/docker/ory/oathkeeper/rules.json
@@ -1 +1,92 @@
-[]
\ No newline at end of file
+[
+ {
+ "id": "public-health",
+ "description": "공개 헬스체크",
+ "match": {
+ "url": "http://<.*>/health",
+ "methods": ["GET"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "public-preflight",
+ "description": "CORS preflight",
+ "match": {
+ "url": "http://<.*>/api/v1/<.*>",
+ "methods": ["OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "public-auth",
+ "description": "인증/회원가입 등 공개 엔드포인트",
+ "match": {
+ "url": "http://<.*>/api/v1/auth/<.*>",
+ "methods": ["GET", "POST", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "backend-command",
+ "description": "Command 요청은 Backend로 전달 (Audit 강제)",
+ "match": {
+ "url": "http://<.*>/api/v1/<.*>",
+ "methods": ["POST", "PUT", "PATCH", "DELETE"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "cookie_session" }
+ ],
+ "authorizer": { "handler": "remote_json" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "backend-query",
+ "description": "Backend Query (admin/dev 포함)",
+ "match": {
+ "url": "http://<.*>/api/v1/<.*>",
+ "methods": ["GET"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "cookie_session" }
+ ],
+ "authorizer": { "handler": "remote_json" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ }
+]
diff --git a/docker/ory/oathkeeper/rules.prod.json b/docker/ory/oathkeeper/rules.prod.json
new file mode 100644
index 00000000..d6537966
--- /dev/null
+++ b/docker/ory/oathkeeper/rules.prod.json
@@ -0,0 +1,92 @@
+[
+ {
+ "id": "public-health",
+ "description": "공개 헬스체크 (PROD 도메인)",
+ "match": {
+ "url": "https://app.brsw.kr/health",
+ "methods": ["GET"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "public-preflight",
+ "description": "CORS preflight (PROD 도메인)",
+ "match": {
+ "url": "https://app.brsw.kr/api/v1/<.*>",
+ "methods": ["OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "public-auth",
+ "description": "인증/회원가입 등 공개 엔드포인트 (PROD 도메인)",
+ "match": {
+ "url": "https://app.brsw.kr/api/v1/auth/<.*>",
+ "methods": ["GET", "POST", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "backend-command",
+ "description": "Command 요청은 Backend로 전달 (Audit 강제)",
+ "match": {
+ "url": "https://app.brsw.kr/api/v1/<.*>",
+ "methods": ["POST", "PUT", "PATCH", "DELETE"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "cookie_session" }
+ ],
+ "authorizer": { "handler": "remote_json" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "backend-query",
+ "description": "Backend Query (admin/dev 포함)",
+ "match": {
+ "url": "https://app.brsw.kr/api/v1/<.*>",
+ "methods": ["GET"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "cookie_session" }
+ ],
+ "authorizer": { "handler": "remote_json" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ }
+]
diff --git a/docker/ory/oathkeeper/rules.stage.json b/docker/ory/oathkeeper/rules.stage.json
new file mode 100644
index 00000000..3dabd9a0
--- /dev/null
+++ b/docker/ory/oathkeeper/rules.stage.json
@@ -0,0 +1,92 @@
+[
+ {
+ "id": "public-health",
+ "description": "공개 헬스체크 (STAGE 도메인)",
+ "match": {
+ "url": "https://sso.hmac.kr/health",
+ "methods": ["GET"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "public-preflight",
+ "description": "CORS preflight (STAGE 도메인)",
+ "match": {
+ "url": "https://sso.hmac.kr/api/v1/<.*>",
+ "methods": ["OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "public-auth",
+ "description": "인증/회원가입 등 공개 엔드포인트 (STAGE 도메인)",
+ "match": {
+ "url": "https://sso.hmac.kr/api/v1/auth/<.*>",
+ "methods": ["GET", "POST", "OPTIONS"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "noop" }
+ ],
+ "authorizer": { "handler": "allow" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "backend-command",
+ "description": "Command 요청은 Backend로 전달 (Audit 강제)",
+ "match": {
+ "url": "https://sso.hmac.kr/api/v1/<.*>",
+ "methods": ["POST", "PUT", "PATCH", "DELETE"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "cookie_session" }
+ ],
+ "authorizer": { "handler": "remote_json" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ },
+ {
+ "id": "backend-query",
+ "description": "Backend Query (admin/dev 포함)",
+ "match": {
+ "url": "https://sso.hmac.kr/api/v1/<.*>",
+ "methods": ["GET"]
+ },
+ "upstream": {
+ "url": "http://baron_backend:3000"
+ },
+ "authenticators": [
+ { "handler": "cookie_session" }
+ ],
+ "authorizer": { "handler": "remote_json" },
+ "mutators": [
+ { "handler": "noop" }
+ ]
+ }
+]
diff --git a/docker/ory/vector/vector.toml b/docker/ory/vector/vector.toml
new file mode 100644
index 00000000..c237bece
--- /dev/null
+++ b/docker/ory/vector/vector.toml
@@ -0,0 +1,52 @@
+[sources.oathkeeper_file]
+ type = "file"
+ include = ["/var/log/oathkeeper/access.log"]
+ read_from = "beginning"
+
+[transforms.oathkeeper_parse]
+ type = "remap"
+ inputs = ["oathkeeper_file"]
+ source = '''
+ .raw = .message
+ parsed = parse_json(.message) ?? {}
+
+ .timestamp = to_timestamp(.timestamp) ?? now()
+ .request_id = parsed.request_id ?? parsed.req_id ?? ""
+ request_method = get(parsed, ["request", "method"]) ?? ""
+ request_path = get(parsed, ["request", "path"]) ?? ""
+ request_url = get(parsed, ["request", "url"]) ?? ""
+ .method = parsed.method ?? parsed.http_method ?? request_method ?? ""
+ .path = parsed.path ?? parsed.http_path ?? request_path ?? request_url ?? ""
+ response_status = get(parsed, ["response", "status"]) ?? 0
+ .status = to_int(parsed.status ?? parsed.status_code ?? response_status ?? 0) ?? 0
+ .latency_ms = to_int(parsed.latency_ms ?? parsed.duration_ms ?? parsed.took ?? 0) ?? 0
+ identity_id = get(parsed, ["identity", "id"]) ?? ""
+ .subject = parsed.subject ?? identity_id ?? ""
+ .client_ip = parsed.client_ip ?? parsed.remote_ip ?? parsed.ip ?? ""
+ headers = get(parsed, ["headers"]) ?? {}
+ .user_agent = parsed.user_agent
+ if is_null(.user_agent) { .user_agent = get(headers, ["User-Agent"]) }
+ if is_null(.user_agent) { .user_agent = "" }
+
+ .decision = parsed.decision
+ if is_null(.decision) { .decision = parsed.result }
+ if is_null(.decision) { .decision = "" }
+
+ .trace_id = parsed.trace_id
+ if is_null(.trace_id) { .trace_id = "" }
+
+ .span_id = parsed.span_id
+ if is_null(.span_id) { .span_id = "" }
+
+ .rp = ""
+ .action = ""
+ .target = ""
+ '''
+
+[sinks.clickhouse]
+ type = "clickhouse"
+ inputs = ["oathkeeper_parse"]
+ endpoint = "http://ory_clickhouse:8123"
+ database = "ory"
+ table = "oathkeeper_access_logs"
+ compression = "gzip"
diff --git a/docs/auth-flow.md b/docs/auth-flow.md
new file mode 100644
index 00000000..45ba01e0
--- /dev/null
+++ b/docs/auth-flow.md
@@ -0,0 +1,89 @@
+# 인증/로그인 플로우 정리 (Backend IDP 추상화 기준)
+
+이 문서는 **Backend IDP 추상화(IdentityProvider)**를 기준으로, 현재 지원하는 로그인 방식과 UserFront 연동 API, 그리고 **Kratos 세션 공유 방식**을 정리합니다.
+
+> 목적: ID/Password 방식부터 시작해, 현재 지원 중인 로그인 플로우를 **IDP 추상화를 해치지 않는 범위**에서 일관되게 구현하고, Front/Backend/Oathkeeper 간 세션 전달 방식을 명확히 한다.
+
+---
+
+## 1) 지원 로그인 방식 요약
+
+| 방식 | Backend 엔드포인트 | 세션 토큰 반환 | 비고 |
+|---|---|---|---|
+| ID/Password | `POST /api/v1/auth/password/login` | `sessionJwt` | IDP 추상화 사용 (Ory/Descope) |
+| Enchanted Link (Email/SMS) | `POST /api/v1/auth/enchanted-link/init` → `POST /api/v1/auth/enchanted-link/poll` | `sessionJwt` | 링크 클릭 시 `POST /api/v1/auth/magic-link/verify` 호출 |
+| Magic Link Verify | `POST /api/v1/auth/magic-link/verify` | `token` | Polling 세션 갱신용 |
+| SMS 코드 | `POST /api/v1/auth/sms` → `POST /api/v1/auth/verify-sms` | `token` | 현재는 내부 토큰(placeholder). Kratos 세션 교환 필요 |
+| QR 로그인 | `POST /api/v1/auth/qr/init` → `POST /api/v1/auth/qr/poll` | `sessionJwt` | 모바일 승인: `POST /api/v1/auth/qr/approve` |
+
+---
+
+## 2) UserFront 연동 API 매핑
+
+### 2.1 ID/Password 로그인
+1. `POST /api/v1/auth/password/login`
+2. 응답의 `sessionJwt` 사용
+
+### 2.2 Enchanted Link (Email/SMS)
+1. `POST /api/v1/auth/enchanted-link/init` → `pendingRef` 수신
+2. `POST /api/v1/auth/enchanted-link/poll`로 폴링
+3. 사용자가 링크 클릭하면 UserFront가 `POST /api/v1/auth/magic-link/verify` 호출
+4. Polling 응답에서 `sessionJwt` 수신
+
+### 2.3 QR 로그인
+1. `POST /api/v1/auth/qr/init` → `qrCode`, `pendingRef` 수신
+2. 웹은 `POST /api/v1/auth/qr/poll`로 폴링
+3. 모바일 앱은 `POST /api/v1/auth/qr/approve`로 승인 (모바일 세션 토큰은 승인 검증용)
+4. Polling 응답에서 `sessionJwt` 수신
+
+### 2.4 SMS 코드 로그인
+1. `POST /api/v1/auth/sms`로 코드 발송
+2. `POST /api/v1/auth/verify-sms`로 코드 검증
+3. 현재는 내부 토큰 반환 (IDP 세션 교환은 TODO)
+
+---
+
+## 3) Kratos 세션 생성/공유 방식
+
+### 3.1 생성 (ID/Password 기준)
+- Backend가 IDP 추상화(`IdentityProvider.SignIn`)를 호출해 `sessionJwt`를 발급
+- **Ory(Kratos)**의 경우:
+ - Kratos Login API를 통해 `session_token`을 반환
+ - 이 값이 `sessionJwt`로 응답됨
+
+### 3.2 공유 (Backend → UserFront / Oathkeeper)
+현재 공유 방식은 **두 가지 선택지**가 있습니다.
+
+**A) Backend가 쿠키로 전달 (권장 방향)**
+- `sessionJwt`가 Kratos `session_token`인 경우 `ory_kratos_session` 쿠키로 `Set-Cookie`
+- Oathkeeper `cookie_session` authenticator가 Kratos `/sessions/whoami`로 검증 가능
+
+**B) UserFront가 토큰을 보관/전달 (현재 동작)**
+- `sessionJwt`를 로컬에 저장 후 Backend 호출 시 `Authorization: Bearer
`로 전달
+- Oathkeeper 경유 경로에서는 쿠키가 필요하므로, 별도 토큰 교환 또는 Oathkeeper 인증기 추가가 필요
+
+> 현재 구현은 **B 방식에 가깝고**, Oathkeeper 통과를 위한 쿠키 전달은 추가 구현이 필요합니다.
+
+---
+
+## 4) IDP 추상화 관점에서의 구현 상태
+
+- **ID/Password 로그인**: IDP 추상화 사용 (Ory/Descope) — 정상
+- **Enchanted/Magic Link**: 현재는 Descope 기반 로직이 포함됨. Ory 전환 시 Kratos `code/link` 플로우로 교체 필요
+- **SMS 코드**: 내부 토큰(placeholder). Kratos 세션 교환 로직 추가 필요
+- **QR 로그인**: 모바일 세션 토큰은 승인 검증용으로만 사용하고, 백엔드에서 웹 전용 세션을 새로 발급
+
+---
+
+## 5) UserFront 주의사항
+
+- `sessionJwt`가 **JWT 형식이 아닐 수 있음** (Kratos session token은 opaque 가능)
+- 현재 UserFront는 Descope SDK 기반 세션 처리 로직이 포함되어 있어, Ory 사용 시 이 부분은 분리/대체가 필요함
+
+---
+
+## 6) 다음 액션 제안
+
+1. **Kratos 세션 쿠키 전달 방식(A) 구현**
+2. Enchanted/Magic Link의 Ory 대응(로그인 코드/링크 방식) 설계
+3. SMS 코드/QR 플로우의 Kratos 세션 교환 정책 확정
diff --git a/docs/kratos-integration-report.md b/docs/kratos-integration-report.md
new file mode 100644
index 00000000..2fd69fb7
--- /dev/null
+++ b/docs/kratos-integration-report.md
@@ -0,0 +1,34 @@
+# Ory Kratos 인증 엔진 전환 작업 보고서
+
+## 1. 개요
+기존 Descope SaaS 기반 인증 시스템을 자가 호스팅(Self-hosted) IDP인 **Ory Kratos**로 전환하고, 이를 백엔드(Go Fiber)와 연동하는 작업을 수행하였습니다.
+
+## 2. 주요 작업 내용
+
+### 2.1 인프라 및 SDK 설정
+* **SDK 설치**: Ory Kratos Go SDK (`github.com/ory/kratos-client-go`)를 백엔드 프로젝트에 추가.
+* **클라이언트 초기화**: `AuthHandler` 내부에 Kratos Public API 통신을 위한 API Client 주입 및 환경 변수 연동.
+
+### 2.2 인증 Flow 핸들러 구현 (`auth_handler.go`)
+Ory Kratos의 API-first 방식(Native Flow)에 맞춘 신규 핸들러 구현:
+* **InitializeLoginFlow**: 로그인 프로세스 시작을 위한 `flow_id` 발급 API.
+* **InitializeRegistrationFlow**: 회원가입 프로세스 시작을 위한 `flow_id` 발급 API.
+* **LoginSubmit**: 사용자의 ID/PW를 Kratos에 제출하고 성공 시 세션 쿠키를 클라이언트에 전달.
+* **RegistrationSubmit**: 커스텀 Traits(사용자 정보)와 비밀번호를 Kratos에 전달하여 계정 생성.
+
+### 2.3 라우팅 설정 (`main.go`)
+신규 인증 엔진을 위한 전용 엔드포인트 그룹 등록:
+* `GET /api/v1/auth/ory/login/initialize`
+* `POST /api/v1/auth/ory/login/submit`
+* `GET /api/v1/auth/ory/registration/initialize`
+* `POST /api/v1/auth/ory/registration/submit`
+
+### 2.4 보안 및 감사 (Security & Audit)
+* **세션 관리**: Kratos에서 발급한 `Set-Cookie` 헤더를 추출하여 클라이언트에 투명하게 전달(Pass-through).
+* **감사 로그**: 로그인 시도 및 성공 시 시각, IP, 대상 아이디 등을 ClickHouse 감사 로그 시스템에 기록.
+* **타입 오류 해결**: Kratos SDK의 구조체 타입 미스매치 이슈 해결(`result.Session` nil 비교 로직 수정).
+
+## 3. 향후 과제 (Next Steps)
+1. **UI 연동**: `userfront` (Flutter)의 API 엔드포인트를 기존 Descope에서 신규 Ory 경로로 전환.
+2. **계정 복구**: 비밀번호 찾기(Recovery) 및 이메일 확인(Verification) Flow 추가 연동.
+3. **관리자 기능**: `adminfront`에서 Kratos Identities를 직접 조회/삭제하는 관리 API 연결.
diff --git a/docs/kratos-todo-list.md b/docs/kratos-todo-list.md
new file mode 100644
index 00000000..8e331c4f
--- /dev/null
+++ b/docs/kratos-todo-list.md
@@ -0,0 +1,27 @@
+# Kratos 기반 SSO 추가 기능 구현 로드맵
+
+Ory Kratos의 표준 기능을 바탕으로 우리 프로젝트에 추가해야 할 핵심 기능 목록입니다.
+
+## 1. 인증 수단 고도화
+- [ ] **소셜 로그인 연동**: Google, GitHub, Apple 등 주요 OIDC 제공자 연결.
+- [ ] **Passkeys (WebAuthn)**: 생체 인증을 통한 Passwordless 로그인 구현.
+- [ ] **MFA (Multi-Factor Authentication)**: TOTP(Authenticator 앱), Lookup Secret(복구 코드) 지원.
+
+## 2. 사용자 셀프 서비스 (Self-Service)
+- [ ] **계정 복구 Flow**: 비밀번호 분실 시 이메일/SMS 링크를 통한 재설정 기능.
+- [ ] **계정 확인 (Verification)**: 가입 시 이메일/전화번호 점유 인증 절차.
+- [ ] **프로필 및 설정 화면**: 사용자가 직접 자신의 정보(Traits)와 비밀번호를 수정하는 화면.
+
+## 3. 세션 보안 관리
+- [ ] **기기별 세션 관리**: 현재 로그인된 모든 브라우저/기기 목록 조회 및 특정 세션 강제 종료 기능.
+- [ ] **보안 로그 제공**: 사용자 본인의 최근 로그인 기록 및 보안 이벤트 확인 기능.
+
+## 4. 관리자 기능 (Admin Operations)
+- [ ] **커스텀 아이덴티티 스키마**: 테넌트 요구사항에 맞춘 사용자 필드(부서, 직번 등) 동적 정의.
+- [ ] **사용자 일괄 마이그레이션**: 외부 데이터 대량 Import/Export API 및 도구.
+- [ ] **계정 상태 강제 제어**: 관리자에 의한 계정 잠금(Ban) 및 활성화 처리.
+
+## 5. 시스템 연동 및 브랜딩
+- [ ] **메시지 템플릿 관리**: 이메일/SMS 발송 템플릿의 커스텀 HTML 에디터 및 미리보기.
+- [ ] **Webhook 이벤트 연동**: 가입/로그인 등 주요 이벤트 발생 시 외부 시스템으로 실시간 데이터 전송.
+- [ ] **멀티테넌시 브랜딩**: 접속 도메인이나 테넌트에 따른 로그인 화면 로고/컬러 동적 적용.
diff --git a/docs/ory-stack-guide.md b/docs/ory-stack-guide.md
new file mode 100644
index 00000000..51438e26
--- /dev/null
+++ b/docs/ory-stack-guide.md
@@ -0,0 +1,84 @@
+# Ory Stack 상세 가이드 (Baron SSO)
+
+이 문서는 Baron SSO의 핵심 엔진인 Ory Stack의 구성 요소와 전체적인 인증/인가 플로우를 설명합니다.
+
+## 1. 구성 요소별 상세 역할
+
+
+| 구성 요소 | 별칭 | 주요 역할 | 핵심 기능 |
+| :------------- | :------------ | :--------------- | :-------------------------------------------- |
+| **Kratos** | Identity | **사용자 관리** | 회원가입, 로그인, MFA, 프로필 수정, 계정 복구 |
+| | | | |
+| **Hydra** | OAuth2/OIDC | **연동 및 토큰** | Access/ID 토큰 발급, 외부 서비스 SSO 연동 |
+| **Keto** | Authorization | **권한 제어** | RBAC, ACL, "누가 무엇을 할 수 있는가" 판별 |
+| **Oathkeeper** | Proxy/Gateway | **접근 통제** | 요청 검증, 세션 확인, 헤더 변환, API 보호 |
+
+---
+
+## 2. 시스템 플로우 (System Flow)
+
+사용자가 보호된 백엔드 리소스에 접근할 때의 일반적인 흐름입니다.
+
+### [인증 및 접근 흐름]
+
+1. **Request**: 사용자가 API 요청을 보냄 (예: `GET /api/data`).
+2. **Intercept (Oathkeeper)**: Oathkeeper가 요청을 가로챔.
+3. **Authenticate (Kratos)**: Oathkeeper가 Kratos에게 사용자의 세션 쿠키가 유효한지 확인.
+4. **Authorize (Keto)**: Oathkeeper가 Keto에게 해당 사용자가 `/api/data`를 볼 권한이 있는지 확인.
+5. **Transform**: 모든 검증이 끝나면 Oathkeeper가 사용자 정보를 헤더(예: `X-User-ID`)에 담아 백엔드로 전달.
+6. **Response**: 백엔드가 로직을 수행하고 결과를 반환.
+
+### [SSO 연동 흐름 (OIDC)]
+
+1. **Discovery**: 외부 서비스(App A)가 로그인 필요 시 Hydra로 인증 요청을 보냄.
+2. **Login Challenge**: Hydra가 로그인 UI(`userfront`)로 리다이렉트하며 챌린지를 보냄.
+3. **Auth (Kratos)**: 사용자가 `userfront`에서 로그인(Kratos 사용).
+4. **Accept**: `userfront`가 로그인 성공 시 Hydra에게 챌린지 수락을 알림.
+5. **Token Issuance**: Hydra가 App A에게 Auth Code를 주고, App A는 이를 Access/ID Token으로 교환.
+
+---
+
+## 3. 아키텍처 다이어그램
+
+```mermaid
+graph TD
+ User((사용자))
+
+ subgraph "Edge / Gateway"
+ OK[Ory Oathkeeper]
+ end
+
+ subgraph "Identity & Access Layer"
+ KR[Ory Kratos]
+ HY[Ory Hydra]
+ KE[Ory Keto]
+ end
+
+ subgraph "Application Layer"
+ BE[Backend API]
+ AF[Admin Front]
+ UF[User Front]
+ end
+
+ User -->|API Request| OK
+ User -->|Login/Register| UF
+ UF --> KR
+
+ OK -->|1. 세션 확인| KR
+ OK -->|2. 권한 확인| KE
+ OK -->|3. 요청 전달| BE
+
+ AF -->|관리 작업| BE
+ BE -->|Admin API 호출| KR & HY & KE
+
+ HY -->|SSO 토큰 발급| User
+```
+
+---
+
+## 4. 요약
+
+- **Kratos**는 사용자의 정보를 알고 있습니다.
+- **Keto**는 사용자의 권한을 알고 있습니다.
+- **Hydra**는 사용자를 외부 서비스에 증명합니다.
+- **Oathkeeper**는 위 서비스들을 이용해 입구를 지킵니다.
diff --git a/docs/ory-usage.md b/docs/ory-usage.md
index ed3ecf12..fa3367ec 100644
--- a/docs/ory-usage.md
+++ b/docs/ory-usage.md
@@ -95,3 +95,4 @@ docker run --rm --network baron_net curlimages/curl:8.10.1 -fsS http://kratos:44
- `compose.ory.yaml`
- `docker/ory/kratos/kratos.yml`
- `.env.sample`
+- `docs/auth-flow.md`
diff --git a/mcp/compose.mcp.ory.yaml b/mcp/compose.mcp.ory.yaml
new file mode 100644
index 00000000..695a9214
--- /dev/null
+++ b/mcp/compose.mcp.ory.yaml
@@ -0,0 +1,43 @@
+services:
+ kratos-mcp-server:
+ build:
+ context: ./kratos-mcp
+ container_name: mcp_ory_kratos
+ stdin_open: true
+ tty: true
+ init: true
+ environment:
+ - KRATOS_ADMIN_URL=http://kratos:4434
+ networks:
+ - ory-net
+
+ hydra-mcp-server:
+ build:
+ context: ./hydra-mcp
+ container_name: mcp_ory_hydra
+ stdin_open: true
+ tty: true
+ init: true
+ environment:
+ - HYDRA_PUBLIC_URL=http://hydra:4444
+ - HYDRA_ADMIN_URL=http://hydra:4445
+ networks:
+ - ory-net
+
+ keto-mcp-server:
+ build:
+ context: ./keto-mcp
+ container_name: mcp_ory_keto
+ stdin_open: true
+ tty: true
+ init: true
+ environment:
+ - KETO_READ_URL=http://keto:4466
+ - KETO_WRITE_URL=http://keto:4467
+ networks:
+ - ory-net
+
+networks:
+ ory-net:
+ external: true
+ name: ory-net
\ No newline at end of file
diff --git a/test/test_sms.py b/test/test_sms.py
index 3ce0c0bb..a8cc4520 100644
--- a/test/test_sms.py
+++ b/test/test_sms.py
@@ -61,7 +61,7 @@ def main():
"contentType": "COMM",
"countryCode": "82",
"from": sender_phone,
- "content": "[Baron SSO] Test message from Python script.",
+ "content": "[Baron 통합로그인] Test message from Python script.",
"messages": [
{
"to": recipient_phone
diff --git a/userfront/.env.sample b/userfront/.env.sample
deleted file mode 100644
index 5317d76e..00000000
--- a/userfront/.env.sample
+++ /dev/null
@@ -1,34 +0,0 @@
-# ==========================================
-# Baron SSO - Unified Environment Configuration
-# ==========================================
-
-# --- General System ---
-APP_ENV=development
-TZ=Asia/Seoul
-
-# --- Infrastructure Ports ---
-DB_PORT=5432
-CLICKHOUSE_PORT_HTTP=8123
-CLICKHOUSE_PORT_NATIVE=9000
-BACKEND_PORT=3000
-USERFRONT_PORT=5000
-
-# --- Database Credentials (PostgreSQL) ---
-DB_USER=baron
-DB_PASSWORD=password
-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
-
-# --- Frontend Configuration ---
-# Descope Project ID (Required for Auth)
-DESCOPE_PROJECT_ID=P2t...your_descope_project_id
-
-# --- Naver Cloud Services ---
-NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
-NAVER_CLOUD_SECRET_KEY=ncp_iam_...
-NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:...:...
-NAVER_SENDER_PHONE_NUMBER=...
diff --git a/userfront/Dockerfile b/userfront/Dockerfile
index 1e8a7b95..47052a28 100644
--- a/userfront/Dockerfile
+++ b/userfront/Dockerfile
@@ -1,5 +1,6 @@
# Stage 1: Build Flutter
FROM ghcr.io/cirruslabs/flutter:stable AS build
+ENV RUN_FLUTTER_AS_ROOT=true
# ENV RUN_FLUTTER_AS_ROOT=true
WORKDIR /app
COPY . .
diff --git a/userfront/lib/core/services/audit_service.dart b/userfront/lib/core/services/audit_service.dart
index 082d6a6d..5c37ed76 100644
--- a/userfront/lib/core/services/audit_service.dart
+++ b/userfront/lib/core/services/audit_service.dart
@@ -29,7 +29,6 @@ class AuditService {
'event_type': eventType,
'status': status,
'details': details,
- 'timestamp': DateTime.now().toIso8601String(),
}),
);
diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart
index ced64e33..2fb3897b 100644
--- a/userfront/lib/core/services/auth_proxy_service.dart
+++ b/userfront/lib/core/services/auth_proxy_service.dart
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
+import 'http_client.dart';
class AuthProxyService {
static String _envOrDefault(String key, String fallback) {
@@ -22,17 +23,42 @@ class AuthProxyService {
}
}
- static Future