package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" "encoding/json" "fmt" "io" "log" "math/rand" "net/http" "os" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/golang-jwt/jwt/v4" ) type AuthHandler struct { ProjectID string SmsService domain.SmsService RedisService *service.RedisService } func NewAuthHandler() *AuthHandler { pid := os.Getenv("DESCOPE_PROJECT_ID") if pid == "" { // Fallback for dev if not set pid = "P37DsGepBT6uDWb5TYYpb5RxUPuq" } redisService, err := service.NewRedisService() if err != nil { log.Fatalf("Failed to connect to Redis: %v", err) } return &AuthHandler{ ProjectID: pid, SmsService: service.NewSmsService(), RedisService: redisService, } } // SendSms sends a verification code via SMS. 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"}) } log.Printf("[SMS 발송 시작] 요청된 번호: %s", req.PhoneNumber) // Sanitize phone number: remove dashes sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "") log.Printf("[SMS 발송] 번호 정제 완료: %s", sanitizedPhone) // Generate a 6-digit verification code rand.Seed(time.Now().UnixNano()) code := fmt.Sprintf("%06d", rand.Intn(1000000)) content := fmt.Sprintf("[Baron SSO] Your verification code is %s", code) log.Printf("[SMS 발송] 인증 코드 생성 완료: %s", code) // Store the code in Redis before sending if err := h.RedisService.StoreVerificationCode(sanitizedPhone, code); err != nil { log.Printf("[SMS 발송 실패] Redis에 코드 저장 실패: %v", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to process request"}) } log.Printf("[SMS 발송] Redis에 인증 코드 저장 성공 (키: sms_verify:%s)", sanitizedPhone) if err := h.SmsService.SendSms(sanitizedPhone, content); err != nil { log.Printf("[SMS 발송 실패] SENS API 호출 실패: %v", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) } log.Printf("[SMS 발송 성공] SENS API를 통해 SMS 발송 완료") return c.JSON(fiber.Map{"message": "SMS sent successfully"}) } // VerifySms verifies the provided SMS code. 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"}) } log.Printf("[SMS 검증 시작] 요청된 번호: %s, 코드: %s", req.PhoneNumber, req.Code) sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "") log.Printf("[SMS 검증] 번호 정제 완료: %s", sanitizedPhone) storedCode, err := h.RedisService.GetVerificationCode(sanitizedPhone) if err != nil { log.Printf("[SMS 검증 실패] Redis에서 코드 조회 실패: %v", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal server error"}) } log.Printf("[SMS 검증] Redis에서 코드 조회 완료. 저장된 코드: '%s'", storedCode) if storedCode == "" || storedCode != req.Code { log.Printf("[SMS 검증 실패] 코드가 일치하지 않거나 만료됨 (요청된 코드: %s, 저장된 코드: %s)", req.Code, storedCode) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"}) } log.Printf("[SMS 검증] 코드 일치 확인") // Code is correct, delete it to prevent reuse if err := h.RedisService.DeleteVerificationCode(sanitizedPhone); err != nil { // Log the error but don't fail the request as the code was already verified log.Printf("[SMS 검증] 경고: Redis에서 코드 삭제 실패 (하지만 검증은 성공으로 처리됨): %v", err) } else { log.Printf("[SMS 검증] Redis에서 사용된 코드 삭제 완료") } // Generate JWT token claims := jwt.MapClaims{ "sub": sanitizedPhone, // Subject (user identifier) "exp": time.Now().Add(time.Hour * 24).Unix(), // Expiration time (24 hours) "iat": time.Now().Unix(), // Issued at } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) log.Printf("[SMS 검증] JWT 클레임 생성 완료") // Sign the token with the secret key secretKey := os.Getenv("COOKIE_SECRET") if secretKey == "" { log.Println("Warning: COOKIE_SECRET is not set. Using a default, insecure key.") secretKey = "default-insecure-secret-key-for-dev" } signedToken, err := token.SignedString([]byte(secretKey)) if err != nil { log.Printf("[SMS 검증 실패] JWT 토큰 서명 실패: %v", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate token"}) } log.Printf("[SMS 검증 성공] JWT 토큰 발급 완료") return c.JSON(fiber.Map{"token": signedToken}) } // getBaseURL extracts the region code from Project ID if present (e.g., P37... -> api.37ds.descope.com) // Default is api.descope.com func (h *AuthHandler) getBaseURL() string { if len(h.ProjectID) >= 32 { // Heuristic: Descope project IDs usually start with 'P' // If it's a region-specific project, the URL changes. // For P37DsGepBT6uDWb5TYYpb5RxUPuq, the region is likely '37ds'. // Actually, the safest bet is to use the standard API or check the logic. // The error log showed 'api.37ds.descope.com'. // Let's implement dynamic extraction or just use the standard one which redirects? // No, standard is safer if region is unsure, but let's try to match the error URL. // Region code is usually the first 4 chars after P? No. // Let's rely on standard logic: https://api.descope.com usually works and routes. // BUT the user specifically saw api.37ds.descope.com. // Let's try the generic endpoint first. return "https://api.descope.com" } return "https://api.descope.com" } // InitEnchantedLink proxies the sign-up/in request func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { var req domain.EnchantedLinkInitRequest if err := c.BodyParser(&req); err != nil { fmt.Printf("[DEBUG] BodyParser failed: %v\n", err) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } fmt.Printf("[DEBUG] InitEnchantedLink - Received LoginID: '%s', URI: '%s'\n", req.LoginID, req.URI) // Prepare Descope Request // Note: We are using the public API endpoint which expects Bearer // Determine endpoint type (email vs sms) // Default to Enchanted Link Email apiPath := "enchantedlink/signup-in/email" if req.Method == "sms" { apiPath = "magiclink/signup-in/sms" } else if len(req.LoginID) > 0 && req.LoginID[0] == '+' { // Auto-detect if starts with + apiPath = "magiclink/signup-in/sms" } url := fmt.Sprintf("%s/v1/auth/%s", h.getBaseURL(), apiPath) payload := map[string]string{ "loginId": req.LoginID, // "redirectUrl": req.URI, // Let Descope use default from console configuration } body, _ := json.Marshal(payload) r, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) if err != nil { return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) } r.Header.Set("Content-Type", "application/json") r.Header.Set("Authorization", "Bearer "+h.ProjectID) client := &http.Client{} resp, err := client.Do(r) if err != nil { return c.Status(fiber.StatusBadGateway).SendString(err.Error()) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { return c.Status(resp.StatusCode).Send(respBody) } return c.Send(respBody) } // PollEnchantedLink proxies the polling request 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"}) } url := fmt.Sprintf("%s/v1/auth/enchantedlink/pending-session", h.getBaseURL()) payload := map[string]string{ "pendingRef": req.PendingRef, } body, _ := json.Marshal(payload) r, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) if err != nil { return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) } r.Header.Set("Content-Type", "application/json") r.Header.Set("Authorization", "Bearer "+h.ProjectID) client := &http.Client{} resp, err := client.Do(r) if err != nil { return c.Status(fiber.StatusBadGateway).SendString(err.Error()) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { return c.Status(resp.StatusCode).Send(respBody) } return c.Send(respBody) } // VerifyMagicLink verifies the token (t) from the email link func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { var req domain.MagicLinkVerifyRequest if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } // Use Magic Link Verify API url := fmt.Sprintf("%s/v1/auth/magiclink/verify", h.getBaseURL()) payload := map[string]string{ "token": req.Token, } body, _ := json.Marshal(payload) r, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) if err != nil { return c.Status(fiber.StatusInternalServerError).SendString(err.Error()) } r.Header.Set("Content-Type", "application/json") r.Header.Set("Authorization", "Bearer "+h.ProjectID) client := &http.Client{} resp, err := client.Do(r) if err != nil { return c.Status(fiber.StatusBadGateway).SendString(err.Error()) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { return c.Status(resp.StatusCode).Send(respBody) } return c.Send(respBody) }