package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "context" crand "crypto/rand" "encoding/hex" "encoding/json" "fmt" "log/slog" "math/rand" "os" "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:" 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 ) type AuthHandler struct { ProjectID string SmsService domain.SmsService EmailService domain.EmailService RedisService *service.RedisService DescopeClient *client.DescopeClient } 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 NewAuthHandler(redisService *service.RedisService) *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, } } // --- Signup Flow Handlers --- // CheckEmail - Checks if email is available (not registered in Descope) 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.DescopeClient == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) } // Search in Descope // Note: Descope doesn't have a direct "exists" check, we use Load or Search. // Since we are checking availability for signup, we want "User not found". exists, err := h.DescopeClient.Management.User().Load(context.Background(), req.Email) // If err is nil and exists is not nil, user exists. if err == nil && exists != nil { return c.JSON(fiber.Map{"available": false, "message": "Email already registered"}) } // Check if specific error is "not found" or just assume if Load fails it might be free. // Typically Descope Load returns error if not found? Let's assume so or check error message. // Actually, strictly speaking, we should handle specific errors, but for MVP: 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 SSO] 회원가입 인증코드" 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 SSO] 인증번호 [%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"}) } // 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)"}) } // 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"}) } // 3. Create User in Descope if h.DescopeClient == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"}) } // Normalize Phone for Descope (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 } descopeUser := &descope.UserRequest{} descopeUser.Email = req.Email descopeUser.Phone = normalizedPhone descopeUser.Name = req.Name descopeUser.CustomAttributes = map[string]any{ "affiliationType": req.AffiliationType, "companyCode": req.CompanyCode, "department": req.Department, "termsAccepted": req.TermsAccepted, "createdAt": time.Now().Format(time.RFC3339), } // Create user // Note: Descope `Create` does not set password. We usually need `Create` then `Password().Update` // or use a specialized signup flow. // `Management.User().Create` creates a user but doesn't set a password credential immediately unless specified? // Actually `User().Create` creates the identity. // To set password, we use `h.DescopeClient.Management.User().SetPassword(...)` // Check if user exists (Double check) exists, _ := h.DescopeClient.Management.User().Load(context.Background(), req.Email) if exists != nil { return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"}) } // Create _, err := h.DescopeClient.Management.User().Create(context.Background(), req.Email, descopeUser) if err != nil { slog.Error("[Signup] Failed to create user in Descope", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create user"}) } // Set Password err = h.DescopeClient.Management.User().SetPassword(context.Background(), req.Email, req.Password) if err != nil { slog.Error("[Signup] Failed to set password", "error", err) // Rollback? Delete user? h.DescopeClient.Management.User().Delete(context.Background(), req.Email) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": fmt.Sprintf("Failed to set password: %v", err)}) } // 4. Cleanup Redis h.RedisService.Delete(emailKey) h.RedisService.Delete(phoneKey) slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType) return c.JSON(fiber.Map{"success": true, "message": "User registered successfully"}) } // --- Helpers --- 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) } // 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 SSO] 인증번호: %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) // 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" return c.JSON(fiber.Map{"token": token}) } // 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, " ", "") // Generate secure tokens 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, loginID), defaultExpiration) // Generate Link frontendURL := os.Getenv("FRONTEND_URL") if frontendURL == "" { frontendURL = "http://sso.hmac.kr" } link := fmt.Sprintf("%s/verify/%s", frontendURL, 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 SSO] 로그인 링크" body := fmt.Sprintf(`

Baron SSO 로그인

안녕하세요,

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

로그인 완료하기

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

`, link) 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 SSO] 로그인 링크: %s", link) 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, }) } // 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"}) } val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { return c.JSON(fiber.Map{"status": statusPending}) } 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{"status": statusPending}) } // 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) // 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"}) } // [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 { // Not found, or search error. Fallback to using the phone as LoginID. // Use the normalized phone number to ensure consistency (+82...) targetLoginID = searchPhone slog.Info("[Verify] User not found by phone, will use/create", "loginID", targetLoginID) } slog.Info("[Verify] Generating embedded link", "loginID", targetLoginID) embeddedToken, err := h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0) if err != nil { if strings.Contains(err.Error(), "User not found") || strings.Contains(err.Error(), "E062108") { slog.Info("[Verify] User not found, creating...", "loginID", targetLoginID) // Create User with Explicit Phone Attribute userObj := &descope.UserRequest{} if strings.Contains(targetLoginID, "@") { userObj.Email = targetLoginID } else { userObj.Phone = targetLoginID // Must be E.164 } _, errCreate := h.DescopeClient.Management.User().Create(context.Background(), targetLoginID, userObj) if errCreate != nil { slog.Error("[Verify] Failed to create user", "error", errCreate) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create new user"}) } embeddedToken, err = h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0) if err != nil { slog.Error("[Verify] Failed to generate token after creation", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate upstream token"}) } } else { slog.Error("[Verify] Descope Error", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate upstream token"}) } } 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"}) } 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", }) } // PasswordLogin - Authenticate a user with login ID and password. func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { var req struct { LoginID string `json:"loginId"` Password string `json:"password"` } if err := c.BodyParser(&req); err != nil { slog.Error("[PasswordLogin] Body parse error", "error", err) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } slog.Info("[PasswordLogin] Attempting to login", "loginID", req.LoginID) if h.DescopeClient == nil { slog.Error("[PasswordLogin] Descope Client is nil!") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) } // Sign in using Descope authInfo, err := h.DescopeClient.Auth.Password().SignIn(context.Background(), req.LoginID, req.Password, nil) if err != nil { slog.Warn("[PasswordLogin] Descope sign-in failed", "loginID", req.LoginID, "error", err) // It's good practice to return a generic error message for security. return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"}) } slog.Info("[PasswordLogin] Success", "loginID", req.LoginID) return c.JSON(fiber.Map{ "sessionJwt": authInfo.SessionToken.JWT, "status": "ok", }) } // InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다. func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { pendingRef := GenerateSecureToken(16) // QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다. frontendURL := os.Getenv("FRONTEND_URL") if frontendURL == "" { frontendURL = "https://sso.hmac.kr" } qrPayload := fmt.Sprintf("%s/approve?ref=%s", frontendURL, pendingRef) slog.Info("[QR] Init", "pendingRef", pendingRef, "url", qrPayload) // Redis에 초기 상태 저장 (5분 만료) h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute) return c.JSON(fiber.Map{ "qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환 "pendingRef": pendingRef, "expiresIn": 300, }) } // 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"}) } val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { return c.JSON(fiber.Map{"status": "expired"}) } 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.JSON(fiber.Map{"status": statusPending}) } // 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"}) } slog.Info("[QR] Scan & Approve", "pendingRef", req.PendingRef) // 1. Redis에서 세션 확인 val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Session expired or not found"}) } // 2. 모바일 유저의 토큰으로 새 세션 토큰(웹용)을 발행하거나 그대로 전달 sessionData, _ := json.Marshal(map[string]string{ "status": statusSuccess, "jwt": req.Token, }) h.RedisService.Set(prefixSession+req.PendingRef, string(sessionData), 5*time.Minute) 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") } // 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"}) }