package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/logger" "baron-sso-backend/internal/service" "bytes" "context" crand "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "math/rand" "net/http" "net/url" "os" "regexp" "strconv" "strings" "time" "github.com/descope/go-sdk/descope" "github.com/descope/go-sdk/descope/client" "github.com/gofiber/fiber/v2" ) const ( // Redis Key Prefixes prefixSession = "enchanted_session:" prefixToken = "enchanted_token:" prefixLoginCode = "login_code_flow:" prefixLoginCodePending = "login_code_pending:" prefixLoginCodeSmsTarget = "login_code_sms_target:" prefixLoginCodeSmsLookup = "login_code_sms_lookup:" prefixLoginCodeShort = "login_code_short:" prefixLoginCodeSmsOnly = "login_code_sms_only:" prefixLoginCodeQrPending = "login_code_qr_pending:" prefixLoginCodeQr = "login_code_qr:" prefixPollMeta = "poll_meta:" prefixQrRef = "qr_ref:" prefixQrPending = "qr_pending:" prefixSignupEmail = "signup:email:" prefixSignupPhone = "signup:phone:" // Session Statuses statusPending = "pending" statusSuccess = "success" // Durations defaultExpiration = 5 * time.Minute signupStateExpiration = 10 * time.Minute signupBlockDuration = 10 * time.Minute maxSignupFailures = 5 emailCodeTTL = 5 * time.Minute smsCodeTTL = 3 * time.Minute prefixPwdResetToken = "pwdreset_token:" pwdResetExpiration = 15 * time.Minute minPollInterval = 2 * time.Second loginCodeExpiration = 10 * time.Minute linkResendCooldown = 60 * time.Second ) type AuthHandler struct { ProjectID string SmsService domain.SmsService EmailService domain.EmailService RedisService *service.RedisService DescopeClient *client.DescopeClient IdpProvider domain.IdentityProvider } type signupState struct { Code string `json:"code"` Verified bool `json:"verified"` FailCount int `json:"fail_count"` ExpiresAt int64 `json:"expires_at"` // Unix timestamp } // GenerateSecureToken - Helper to generate secure random strings func GenerateSecureToken(length int) string { b := make([]byte, length) if _, err := crand.Read(b); err != nil { return "" } return hex.EncodeToString(b) } func GenerateSecureAlnumToken(length int) string { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" if length <= 0 { return "" } buf := make([]byte, length) if _, err := crand.Read(buf); err != nil { return "" } for i := range buf { buf[i] = charset[int(buf[i])%len(charset)] } return string(buf) } func GenerateUserCode() string { const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ" return fmt.Sprintf("%c%c-%03d", letters[rand.Intn(len(letters))], letters[rand.Intn(len(letters))], rand.Intn(1000), ) } func checkPollInterval(redis *service.RedisService, key string, interval time.Duration) (bool, int) { now := time.Now().UnixMilli() val, err := redis.Get(key) if err == nil && val != "" { if last, parseErr := strconv.ParseInt(val, 10, 64); parseErr == nil { if now-last < interval.Milliseconds() { _ = redis.Set(key, fmt.Sprintf("%d", now), defaultExpiration) return true, int(interval.Seconds()) + 1 } } } _ = redis.Set(key, fmt.Sprintf("%d", now), defaultExpiration) return false, int(interval.Seconds()) } func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider) *AuthHandler { projectID := os.Getenv("DESCOPE_PROJECT_ID") managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY") var descopeClient *client.DescopeClient var err error if projectID != "" { descopeClient, err = client.NewWithConfig(&client.Config{ ProjectID: projectID, ManagementKey: managementKey, }) if err != nil { slog.Warn("Failed to initialize Descope Client", "error", err) } } return &AuthHandler{ ProjectID: projectID, SmsService: service.NewSmsService(), EmailService: service.NewEmailService(), RedisService: redisService, DescopeClient: descopeClient, IdpProvider: idpProvider, } } // --- Signup Flow Handlers --- // CheckEmail - 이메일 사용 가능 여부를 확인합니다. func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error { var req domain.CheckEmailRequest if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"}) } // Email Format Validation if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid email format"}) } if h.IdpProvider == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) } exists, err := h.IdpProvider.UserExists(req.Email) if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) } if exists { return c.JSON(fiber.Map{"available": false, "message": "Email already registered"}) } return c.JSON(fiber.Map{"available": true}) } // SendSignupEmailCode - Sends verification code to email func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error { var req domain.SendSignupCodeRequest if err := c.BodyParser(&req); err != nil || req.Target == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid email"}) } req.Type = "email" // Enforce type key := prefixSignupEmail + req.Target // 1. Check existing state (Rate Limit / Block) state, _ := h.getSignupState(key) if state != nil && state.FailCount > maxSignupFailures { // Check if block expired // Simple block implementation: if FailCount > 5, user is blocked until TTL expires // Since we refresh TTL on each update, we rely on Redis TTL. return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "Too many failed attempts. Try again later."}) } // 2. Generate Code rand.Seed(time.Now().UnixNano()) code := fmt.Sprintf("%06d", rand.Intn(1000000)) // 3. Update State newState := &signupState{ Code: code, Verified: false, FailCount: 0, // Reset fail count on new code generation? Or keep it? // Requirement says "Auth fail > 5 -> block". New code usually resets or continues? // Usually getting a new code doesn't reset verify failure count if we want strict blocking. // But for simplicity let's say "fail count" applies to verification attempts. // If we are issuing a new code, it's a new attempt cycle usually. // However, spamming "send code" is also an attack. // Let's keep FailCount if exists, or 0. ExpiresAt: time.Now().Add(emailCodeTTL).Unix(), } if state != nil { newState.FailCount = state.FailCount } h.saveSignupState(key, newState, signupStateExpiration) // 4. Send Email if h.EmailService == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } subject := "[Baron 통합로그인] 회원가입 인증코드" body := fmt.Sprintf(`

이메일 인증

아래 인증코드를 입력하여 회원가입을 진행해 주세요.

%s

이 코드는 5분간 유효합니다.

`, code) go h.EmailService.SendEmail(req.Target, subject, body) return c.JSON(fiber.Map{"message": "Verification code sent"}) } // SendSignupSmsCode - Sends verification code to phone func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error { var req domain.SendSignupCodeRequest if err := c.BodyParser(&req); err != nil || req.Target == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid phone number"}) } req.Type = "phone" // Sanitize phone phone := strings.ReplaceAll(req.Target, "-", "") key := prefixSignupPhone + phone // 1. Check existing state state, _ := h.getSignupState(key) if state != nil && state.FailCount > maxSignupFailures { return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "Too many failed attempts. Try again later."}) } // 2. Generate Code rand.Seed(time.Now().UnixNano()) code := fmt.Sprintf("%06d", rand.Intn(1000000)) // 3. Save State newState := &signupState{ Code: code, Verified: false, FailCount: 0, ExpiresAt: time.Now().Add(smsCodeTTL).Unix(), } if state != nil { newState.FailCount = state.FailCount } h.saveSignupState(key, newState, signupStateExpiration) // 4. Send SMS content := fmt.Sprintf("[Baron 통합로그인] 인증번호 [%s]를 입력해주세요.", code) go h.SmsService.SendSms(phone, content) return c.JSON(fiber.Map{"message": "Verification code sent"}) } // VerifySignupCode - Verifies the code for email or phone func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { var req domain.VerifySignupCodeRequest if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"}) } var key string if req.Type == "email" { key = prefixSignupEmail + req.Target } else if req.Type == "phone" { phone := strings.ReplaceAll(req.Target, "-", "") key = prefixSignupPhone + phone } else { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid type"}) } state, err := h.getSignupState(key) if err != nil || state == nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Verification session expired or not found"}) } // Check Verified if state.Verified { return c.JSON(fiber.Map{"success": true, "message": "Already verified"}) } // Check Attempts if state.FailCount > maxSignupFailures { return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "Too many failed attempts"}) } // Check Code match if state.Code != req.Code { state.FailCount++ h.saveSignupState(key, state, signupStateExpiration) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code", "failCount": state.FailCount}) } // Check Expiry (Logic time vs stored time) if time.Now().Unix() > state.ExpiresAt { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Code expired"}) } // Success state.Verified = true h.saveSignupState(key, state, signupStateExpiration) return c.JSON(fiber.Map{"success": true}) } // Signup - Finalize registration func (h *AuthHandler) Signup(c *fiber.Ctx) error { var req domain.SignupRequest if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } // 1. Validate Fields (Simple validation) if req.Email == "" || req.Password == "" || req.Name == "" || req.Phone == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing required fields"}) } if !req.TermsAccepted { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Terms must be accepted"}) } // 비밀번호 정책 검증 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) emailKey := prefixSignupEmail + req.Email phoneKey := prefixSignupPhone + strings.ReplaceAll(req.Phone, "-", "") emailState, _ := h.getSignupState(emailKey) phoneState, _ := h.getSignupState(phoneKey) if emailState == nil || !emailState.Verified { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Email not verified"}) } if phoneState == nil || !phoneState.Verified { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Phone not verified"}) } if h.IdpProvider == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"}) } // Normalize Phone (E.164 형태로 보관) normalizedPhone := strings.ReplaceAll(req.Phone, "-", "") normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "") if strings.HasPrefix(normalizedPhone, "010") { normalizedPhone = "+82" + normalizedPhone[1:] } else if strings.HasPrefix(normalizedPhone, "82") { normalizedPhone = "+" + normalizedPhone } // IDP에 전달할 BrokerUser 스키마 구성 attributes := map[string]interface{}{ "department": req.Department, "affiliationType": req.AffiliationType, "companyCode": req.CompanyCode, // grade는 기존 스키마 필수 키이므로 기본값을 설정 "grade": "member", } brokerUser := &domain.BrokerUser{ Email: req.Email, Name: req.Name, PhoneNumber: normalizedPhone, Attributes: attributes, } 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"}) } return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create user"}) } // 4. Cleanup Redis h.RedisService.Delete(emailKey) h.RedisService.Delete(phoneKey) slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType, "provider", h.IdpProvider.Name(), "subject", providerID) return c.JSON(fiber.Map{ "success": true, "message": "User registered successfully", "provider": h.IdpProvider.Name(), "subject": providerID, }) } // --- Helpers --- func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string { authHeader := c.Get("Authorization") if authHeader == "" { return "" } parts := strings.Split(authHeader, " ") if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { return "" } 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 == "" { return nil, err } var state signupState if err := json.Unmarshal([]byte(val), &state); err != nil { return nil, err } return &state, nil } func (h *AuthHandler) saveSignupState(key string, state *signupState, ttl time.Duration) error { data, err := json.Marshal(state) if err != nil { return err } return h.RedisService.Set(key, string(data), ttl) } // 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) } 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, "minCharacterTypes": policy.MinCharacterTypes, }) } // SendSms sends a verification code via SMS. (Restored for completeness) func (h *AuthHandler) SendSms(c *fiber.Ctx) error { var req domain.SmsRequest if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } slog.Info("[SMS] Sending code", "phoneNumber", req.PhoneNumber) sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "") rand.Seed(time.Now().UnixNano()) code := fmt.Sprintf("%06d", rand.Intn(1000000)) content := fmt.Sprintf("[Baron 통합로그인] 인증번호: %s", code) h.RedisService.StoreVerificationCode(sanitizedPhone, code) if err := h.SmsService.SendSms(sanitizedPhone, content); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) } return c.JSON(fiber.Map{"message": "SMS sent successfully"}) } // VerifySms verifies the provided SMS code. (Restored) func (h *AuthHandler) VerifySms(c *fiber.Ctx) error { var req domain.SmsVerifyRequest if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "") storedCode, _ := h.RedisService.GetVerificationCode(sanitizedPhone) if storedCode == "" || storedCode != req.Code { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"}) } h.RedisService.DeleteVerificationCode(sanitizedPhone) if h.IdpProvider == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Authentication service not configured"}) } 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) func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { var req domain.EnchantedLinkInitRequest if err := c.BodyParser(&req); err != nil { slog.Error("[Enchanted] Body parse error", "error", err) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } loginID := strings.ReplaceAll(req.LoginID, "-", "") loginID = strings.ReplaceAll(loginID, " ", "") 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"}) } 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) slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef) // 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, lookupLoginID), defaultExpiration) // Generate Link slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL) link := fmt.Sprintf("%s/verify/%s", userfrontURL, token) // Route based on LoginID type if strings.Contains(loginID, "@") { // Send Email if h.EmailService == nil { slog.Error("[Enchanted] Email Service not configured") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } subject := "[Baron 통합로그인] 링크" body := fmt.Sprintf(`

Baron SSO 로그인

안녕하세요,

아래 버튼을 클릭하여 로그인을 완료해 주세요. 이 링크는 5분 동안 유효합니다.

로그인 완료하기

간편 코드: %s

만약 본인이 요청하지 않았다면 이 메일을 무시하셔도 됩니다.

`, link, userCode) slog.Info("[Enchanted] Sending Email via AWS SES", "loginID", loginID) if err := h.EmailService.SendEmail(loginID, subject, body); err != nil { slog.Error("[Enchanted] Email Failed", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send Email"}) } } else { // Send SMS 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 { slog.Error("[Enchanted] SMS Failed", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) } } return c.JSON(fiber.Map{ "linkId": "Sent", "pendingRef": pendingRef, "maskedEmail": loginID, "expiresIn": int(defaultExpiration.Seconds()), "interval": int(minPollInterval.Seconds()), "resendAfter": int(linkResendCooldown.Seconds()), "userCode": userCode, }) } // PollEnchantedLink - Check status (Restored) func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { var req domain.EnchantedLinkPollRequest if err := c.BodyParser(&req); err != nil { 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{"error": "expired_token"}) } var data map[string]string json.Unmarshal([]byte(val), &data) if data["status"] == statusSuccess { slog.Info("[Poll] Success", "pendingRef", req.PendingRef) return c.JSON(fiber.Map{ "sessionJwt": data["jwt"], "status": "ok", }) } return c.JSON(fiber.Map{ "error": "authorization_pending", "interval": int(minPollInterval.Seconds()), }) } // VerifyMagicLink - Validate token and login (Restored) func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { var req domain.MagicLinkVerifyRequest if err := c.BodyParser(&req); err != nil { slog.Error("[Verify] Body parse error", "error", err) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } slog.Info("[Verify] Attempting to verify token", "token", req.Token) tokenKey := prefixToken + req.Token val, err := h.RedisService.Get(tokenKey) if err != nil || val == "" { slog.Warn("[Verify] Token not found or expired in Redis", "token", req.Token) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"}) } var tokenData map[string]string json.Unmarshal([]byte(val), &tokenData) pendingRef := tokenData["pendingRef"] loginID := tokenData["loginId"] slog.Info("[Verify] Token valid", "loginID", loginID, "pendingRef", pendingRef) if h.IdpProvider == nil { slog.Error("[Verify] IDP Provider is nil") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) } authInfo, err := h.IdpProvider.IssueSession(loginID) if err != nil { 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"}) } 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 slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef) sessionData, _ := json.Marshal(map[string]string{ "status": statusSuccess, "jwt": sessionToken, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration) return c.JSON(fiber.Map{ "token": sessionToken, "message": "Login successful", }) } // 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() ale := logger.NewAuditLogEntry(c, "login") ale.Operation = "Auth.Password().SignIn" var req struct { LoginID string `json:"loginId"` Password string `json:"password"` } if err := c.BodyParser(&req); err != nil { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) ale.DescopeError = err.Error() ale.Log(slog.LevelError, "Body parse error") return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } loginID := strings.TrimSpace(req.LoginID) ale.LoginIDs["loginId"] = req.LoginID // 원문 ale.LoginIDs["loginId_normalized"] = loginID ale.NewPassword = req.Password // For test only, logging password (sensitive) ale.Log(slog.LevelInfo, "Attempting to login") if h.IdpProvider == nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = "IDP Provider is nil" ale.Log(slog.LevelError, "IDP Provider is nil") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) } 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() ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name())) if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "identity") { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"}) } return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"}) } ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) ale.SessionJwt = authInfo.SessionToken.JWT ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject)) resp := fiber.Map{ "sessionJwt": authInfo.SessionToken.JWT, "status": "ok", "provider": h.IdpProvider.Name(), } if authInfo.RefreshToken != nil { resp["refreshJwt"] = authInfo.RefreshToken.JWT } if authInfo.Subject != "" { resp["subject"] = authInfo.Subject } return c.JSON(resp) } // InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 Descope를 통해 이메일 또는 SMS를 보냅니다. func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { startTime := time.Now() ale := logger.NewAuditLogEntry(c, "initiate") var req domain.PasswordResetInitiateRequest if err := c.BodyParser(&req); err != nil { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) ale.DescopeError = err.Error() ale.Log(slog.LevelError, "Body parse error") return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } loginID := strings.TrimSpace(req.LoginID) ale.LoginIDs["loginId"] = req.LoginID // 원문 ale.LoginIDs["loginId_normalized"] = loginID if loginID == "" { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) ale.DescopeError = "Login ID is required" ale.Log(slog.LevelWarn, "Login ID missing") return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID is required"}) } if h.IdpProvider == nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = "IDP Provider is not initialized" ale.Log(slog.LevelError, "IDP Provider is not initialized") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) } userfrontURL := os.Getenv("USERFRONT_URL") if userfrontURL == "" { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = "USERFRONT_URL is not set" ale.Log(slog.LevelError, "USERFRONT_URL is not set") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "USERFRONT_URL environment variable is not set"}) } // [Changed] Point to Backend API for verification (which then redirects to Frontend) redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL) ale.RedirectTo = redirectURL // 내부 토큰 발급 + 우리 채널로 전송 resetToken := GenerateSecureToken(32) if resetToken == "" { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = "Failed to generate reset token" ale.Log(slog.LevelError, "Failed to generate reset token") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate reset token"}) } if err := h.RedisService.Set(prefixPwdResetToken+resetToken, loginID, pwdResetExpiration); err != nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = err.Error() ale.Log(slog.LevelError, "Failed to store reset token in Redis") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to store reset token"}) } resetLink := fmt.Sprintf("%s/reset-password?token=%s", userfrontURL, resetToken) ale.RedirectTo = resetLink ale.Operation = "SendPasswordReset" ale.Log(slog.LevelInfo, "Initiating password reset via internal token") if strings.Contains(loginID, "@") { if h.EmailService == nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = "Email service not configured" ale.Log(slog.LevelError, "Email service not configured") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } subject := "[Baron 통합로그인] 비밀번호 재설정" body := fmt.Sprintf(`

Baron SSO 비밀번호 재설정

아래 버튼을 클릭해 비밀번호 재설정을 진행해 주세요. 링크는 15분간 유효합니다.

비밀번호 재설정

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

`, resetLink) if err := h.EmailService.SendEmail(loginID, subject, body); err != nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = err.Error() ale.Log(slog.LevelError, "Failed to send reset email", slog.String("loginId", loginID)) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset email"}) } } else { if err := h.SmsService.SendSms(loginID, fmt.Sprintf("[Baron 통합로그인] 비밀번호 재설정 링크: %s", resetLink)); err != nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = err.Error() ale.Log(slog.LevelError, "Failed to send reset SMS", slog.String("loginId", loginID)) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset SMS"}) } } ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) ale.Log(slog.LevelInfo, "Password reset link sent successfully (internal token)") return c.JSON(fiber.Map{"message": "If an account with that login ID exists, a reset link has been sent."}) } // VerifyPasswordResetPage - Serves an interstitial page to prevent link scanners from consuming the token. func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error { token := c.Query("token") if token == "" { token = c.Query("t") } if token == "" { return c.Status(fiber.StatusBadRequest).SendString("Missing token") } // Simple HTML page with a form to trigger the POST request html := fmt.Sprintf(` Baron SSO - 비밀번호 재설정

비밀번호 재설정

아래 버튼을 클릭하여 비밀번호 재설정을 계속해 주세요.

`, token) c.Set("Content-Type", "text/html; charset=utf-8") return c.SendString(html) } // ProcessPasswordResetToken - Handles the POST request from the interstitial page. // Verifies the token, sets the refresh token cookie, and redirects to the userfront. func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error { startTime := time.Now() ale := logger.NewAuditLogEntry(c, "verify") ale.Operation = "Verify" // Token comes from Form Body in POST or query token := c.FormValue("token") if token == "" { token = c.Query("token") if token == "" { token = c.Query("t") } } ale.Token = token if token == "" { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) ale.DescopeError = "Missing token" ale.Log(slog.LevelWarn, "Missing token in request") return c.Status(fiber.StatusBadRequest).SendString("Missing token") } loginID, err := h.RedisService.Get(prefixPwdResetToken + token) if err != nil || loginID == "" { ale.Status = fiber.StatusUnauthorized ale.LatencyMs = time.Since(startTime) ale.DescopeError = "Invalid or expired reset token" ale.Log(slog.LevelWarn, "Reset token invalid or expired") return c.Status(fiber.StatusUnauthorized).SendString("Invalid or expired token") } ale.LoginIDs["loginId"] = loginID ale.LoginIDs["loginId_normalized"] = loginID redirectURL := fmt.Sprintf("%s/reset-password?loginId=%s&token=%s", os.Getenv("USERFRONT_URL"), loginID, token, ) ale.RedirectTo = redirectURL ale.Status = fiber.StatusFound ale.LatencyMs = time.Since(startTime) ale.Log(slog.LevelInfo, "Token verified, redirecting to userfront") return c.Redirect(redirectURL) } // CompletePasswordReset - 제공된 loginID와 새 비밀번호로 IDP 비밀번호를 업데이트합니다. // 리프레시 토큰은 요청 쿠키에 포함되어 있어야 합니다. func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { startTime := time.Now() ale := logger.NewAuditLogEntry(c, "complete") ale.Operation = "UpdateUserPassword" providerName := "unknown" if h.IdpProvider != nil { providerName = h.IdpProvider.Name() } var req struct { NewPassword string `json:"newPassword"` } if err := c.BodyParser(&req); err != nil { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) ale.DescopeError = err.Error() ale.Log(slog.LevelError, "Body parse error") return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } // loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다. loginID := c.Query("loginId") resetToken := c.Query("token") if loginID == "" && resetToken != "" { if val, err := h.RedisService.Get(prefixPwdResetToken + resetToken); err == nil && val != "" { loginID = val ale.Token = resetToken } } ale.LoginIDs["loginId"] = loginID ale.RequestBody = fmt.Sprintf("{\"newPassword\": \"%s\"}", req.NewPassword) // Log request body (for test only) ale.NewPassword = req.NewPassword // Log new password (for test only) // Request cookie logging (minimal) if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" { ale.Headers["Request-Cookie-Header"] = cookieHeader if dsrfCookie := c.Cookies("DSRF"); dsrfCookie != "" { ale.ParsedCookieDSRF = dsrfCookie ale.HasCookieDSRF = true } else { ale.HasCookieDSRF = false } } if loginID == "" || req.NewPassword == "" { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) ale.DescopeError = "Login ID and new password are required" ale.Log(slog.LevelWarn, "Login ID or new password missing") return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID and new password are required"}) } // 디버깅을 위해 요청된 새 비밀번호를 로그로 출력 ale.Log(slog.LevelInfo, "Received new password for reset") policy := h.resolvePasswordPolicy() if err := validatePasswordWithPolicy(policy, req.NewPassword); err != nil { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) 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)) if h.IdpProvider == nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = "IDP Provider is nil" ale.Log(slog.LevelError, "IDP Provider is nil") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) } if err := h.IdpProvider.UpdateUserPassword(loginID, req.NewPassword, nil); err != nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = err.Error() ale.Log(slog.LevelError, "Failed to update password via IDP") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update password"}) } ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID)) if resetToken != "" { _ = h.RedisService.Delete(prefixPwdResetToken + resetToken) } return c.JSON(fiber.Map{"message": "Password has been reset successfully."}) } // InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다. func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { pendingRef := GenerateSecureToken(16) 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/ql/%s", strings.TrimRight(userfrontURL, "/"), qrRef) 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()), }) } // PollQRLogin - Step 2: 웹에서 승인 여부를 폴링합니다. func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error { var req struct { PendingRef string `json:"pendingRef"` } if err := c.BodyParser(&req); err != nil { 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.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "expired_token"}) } var data map[string]string json.Unmarshal([]byte(val), &data) if data["status"] == statusSuccess { slog.Info("[QR] Poll Success", "pendingRef", req.PendingRef) return c.JSON(fiber.Map{ "status": "ok", "sessionJwt": data["jwt"], }) } return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "error": "authorization_pending", "interval": int(minPollInterval.Seconds()), }) } // ScanQRLogin - Step 3: 모바일 앱에서 QR 스캔 후 승인할 때 호출합니다. // (이미 로그인된 세션이 필요함) func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error { var req struct { PendingRef string `json:"pendingRef"` Token string `json:"token"` // 모바일 사용자의 세션 토큰 (검증용) } if err := c.BodyParser(&req); err != nil { slog.Error("[QR] Scan body parse error", "error", err) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"}) } 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 + pendingRef) if err != nil || val == "" { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Session expired or not found"}) } 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"}) } // 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"}) } // ProxyToDescope (Placeholder) func (h *AuthHandler) ProxyToDescope(c *fiber.Ctx, path string, payload interface{}) error { 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 { Recipient string `json:"recipient"` Body string `json:"body"` } if err := c.BodyParser(&req); err != nil { slog.Error("Webhook Body parsing failed", "error", err) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } if req.Recipient == "" || req.Body == "" { slog.Warn("Webhook missing recipient or body") return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing recipient or body"}) } slog.Info("Received SMS request", "recipient", req.Recipient) phone := req.Recipient if strings.HasPrefix(phone, "+82") { phone = "0" + phone[3:] } phone = strings.ReplaceAll(phone, "-", "") phone = strings.ReplaceAll(phone, " ", "") if err := h.SmsService.SendSms(phone, req.Body); err != nil { slog.Error("Failed to forward SMS to Naver", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS via Naver"}) } slog.Info("Successfully forwarded SMS", "phone", phone) return c.JSON(fiber.Map{"status": "ok"}) } // HandleDescopeEmailRelay - Webhook for Descope Generic Email Gateway // Used for "Fake Email Strategy" to support Polling with SMS. func (h *AuthHandler) HandleDescopeEmailRelay(c *fiber.Ctx) error { var req struct { To string `json:"to"` // e.g., 01012345678@sms.baron Subject string `json:"subject"` Text string `json:"text"` // Body containing the link } if err := c.BodyParser(&req); err != nil { slog.Error("[Email Webhook] Body parsing failed", "error", err) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } slog.Info("[Email Webhook] Received email request", "to", req.To) // Check if it's a Fake Email for SMS if strings.HasSuffix(req.To, "@sms.baron") { phone := strings.Split(req.To, "@")[0] // Sanitize Phone (Descope might sanitize or not, but let's be safe) if strings.HasPrefix(phone, "+82") { phone = "0" + phone[3:] } // Send SMS with the text body (Descope template should be optimized for SMS) if err := h.SmsService.SendSms(phone, req.Text); err != nil { slog.Error("[Email Webhook] Failed to forward Email-as-SMS", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) } slog.Info("[Email Webhook] Successfully converted Email to SMS", "phone", phone) return c.JSON(fiber.Map{"status": "ok"}) } // Real Email Handling (Not implemented in this Relay) // You would need an SMTP service here if you route ALL emails through this relay. slog.Warn("[Email Webhook] Real email skipped (Not implemented)", "to", req.To) return c.Status(501).JSON(fiber.Map{"error": "Real email sending not implemented"}) } // --- User Profile Handlers --- func (h *AuthHandler) formatPhoneForDisplay(phone string) string { if strings.HasPrefix(phone, "+8210") { return "010" + phone[5:] } return phone } func (h *AuthHandler) formatPhoneForStorage(phone string) string { phone = strings.ReplaceAll(phone, "-", "") if strings.HasPrefix(phone, "010") && len(phone) == 11 { return "+8210" + phone[3:] } return phone } // 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 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"}) } profile, err := h.getKratosProfileWithCookie(cookie) if err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) } return c.JSON(profile) } 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 "", 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" } 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 } 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 } 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) var req domain.UpdateUserRequest if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } 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) slog.Info("[UpdateMe] Checking changes (Kratos)", "identityID", identityID, "oldPhone", currentPhone, "newPhone", newPhoneStorage, "newName", req.Name) if newPhoneStorage != "" && newPhoneStorage != currentPhone { verifyKey := "verify_update_phone:" + identityID + ":" + newPhoneStorage val, _ := h.RedisService.Get(verifyKey) if val != "verified" { slog.Warn("[UpdateMe] Phone verification missing (Kratos)", "key", verifyKey) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다."}) } traits["phone_number"] = newPhoneStorage h.RedisService.Delete(verifyKey) } if req.Name != "" { traits["name"] = req.Name } if req.Department != "" { traits["department"] = req.Department } 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), }) } // SendUpdateCode - Sends OTP for phone number change func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { token := h.getBearerToken(c) 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) } if err != nil || userID == "" { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) } var req struct { Phone string `json:"phone"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid phone"}) } phone := h.formatPhoneForStorage(req.Phone) code := fmt.Sprintf("%06d", rand.Intn(1000000)) // Store code in Redis key := "otp_update_phone:" + userID + ":" + phone h.RedisService.Set(key, code, 5*time.Minute) // Send SMS content := fmt.Sprintf("[Baron 통합로그인] 정보 수정 인증번호: [%s]", code) go h.SmsService.SendSms(phone, content) return c.JSON(fiber.Map{"message": "인증번호가 전송되었습니다."}) } // VerifyUpdateCode - Verifies OTP for phone number change func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error { token := h.getBearerToken(c) 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"}) } var req struct { Phone string `json:"phone"` Code string `json:"code"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"}) } phone := h.formatPhoneForStorage(req.Phone) key := "otp_update_phone:" + userID + ":" + phone storedCode, _ := h.RedisService.Get(key) if storedCode == "" || storedCode != req.Code { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증번호가 일치하지 않거나 만료되었습니다."}) } // Mark as verified for 10 minutes verifyKey := "verify_update_phone:" + userID + ":" + phone h.RedisService.Set(verifyKey, "verified", 10*time.Minute) h.RedisService.Delete(key) return c.JSON(fiber.Map{"success": true}) }