package main import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/handler" "baron-sso-backend/internal/idp" "baron-sso-backend/internal/logger" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "baron-sso-backend/internal/validator" "fmt" "log/slog" "os" "strconv" "strings" "time" "github.com/bwmarrin/snowflake" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/encryptcookie" "github.com/gofiber/fiber/v2/middleware/recover" "github.com/gofiber/fiber/v2/middleware/requestid" "github.com/joho/godotenv" ) func getEnv(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { return value } return fallback } func main() { // Load .env file from possible paths // 1. .env (Current Directory) // 2. ../.env (Project Root when running from backend/) // 3. ../../.env (Project Root when running from backend/cmd/server/) if err := godotenv.Load(".env"); err != nil { if err := godotenv.Load("../.env"); err != nil { godotenv.Load("../../.env") } } // 0. Initialize Logger logger.Init(logger.Config{ ServiceName: "baron-sso", Environment: getEnv("GO_ENV", "dev"), }) // Initialize Snowflake Node (Node 2 for Baron) node, err := snowflake.NewNode(2) if err != nil { slog.Error("Failed to initialize snowflake node", "error", err) os.Exit(1) } // 1. Log Config on Startup fmt.Println("============================================================") fmt.Println(` |\__/,| (\ _.|o o |_ ) ) -(((---(((-------- `) fmt.Println("🚀 Baron SSO Backend Starting...") slog.Info("Service starting", "service", "baron-sso", "app_env", getEnv("APP_ENV", "dev"), "db_port", getEnv("DB_PORT", "5532"), "backend_port", getEnv("BACKEND_PORT", "3000"), "frontend_port", getEnv("FRONTEND_PORT", "5000"), "frontend_url", getEnv("FRONTEND_URL", "http://sso.hmac.kr"), "redis_addr", getEnv("REDIS_ADDR", "redis:6379"), ) // --- Fail-Fast Schema Validation --- // 팩토리를 사용하여 IDP 공급자를 초기화합니다. idpProvider, err := idp.InitializeProvider() if err != nil { slog.Error("❌ [CRITICAL] Failed to initialize IDP Provider", "error", err) os.Exit(1) } if err := validator.ValidateIDPCompatibility(domain.BrokerUser{}, idpProvider); err != nil { slog.Error("❌ [CRITICAL] Broker Schema Mismatch", "idp", idpProvider.Name(), "error", err, ) fmt.Printf("\n!!! CRITICAL ERROR: IDP Schema Mismatch !!!\n%v\n\n", err) os.Exit(1) // Break the build/deployment } slog.Info("✅ IDP Schema Validation Passed", "idp", idpProvider.Name()) // ----------------------------------- // 2. Initialize DB Connections chHost := getEnv("CLICKHOUSE_HOST", "localhost") chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000")) chUser := getEnv("CLICKHOUSE_USER", "default") chPass := getEnv("CLICKHOUSE_PASSWORD", "") chDB := getEnv("CLICKHOUSE_DB", "default") auditRepo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB) if err != nil { slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err) } redisService, err := service.NewRedisService() if err != nil { slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err) } // 2. Initialize Handlers auditHandler := handler.NewAuditHandler(auditRepo) authHandler := handler.NewAuthHandler(redisService) adminHandler := handler.NewAdminHandler() // 3. Initialize Fiber app := fiber.New(fiber.Config{ AppName: "Baron SSO Backend", DisableStartupMessage: true, // Clean logs }) // Middleware app.Use(requestid.New(requestid.Config{ Generator: func() string { return node.Generate().String() }, })) // [Standardized] HTTP Request Logger Middleware using slog app.Use(func(c *fiber.Ctx) error { start := time.Now() // Handle request err := c.Next() // Log after request latency := time.Since(start) status := c.Response().StatusCode() path := c.Path() // Skip logging for all successful requests (status < 400) if status < 400 { return err } msg := "http_request" if err != nil { msg = "http_request_error" } slog.Info(msg, "status", status, "method", c.Method(), "path", path, "latency", latency.String(), "ip", c.IP(), "req_id", c.GetRespHeader(fiber.HeaderXRequestID), ) return err }) app.Use(recover.New()) app.Use(cors.New(cors.Config{ AllowOrigins: "*", // Adjust in production AllowHeaders: "Origin, Content-Type, Accept, Authorization", AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS", })) // Ensure COOKIE_SECRET is exactly 32 bytes for AES-256 cookieSecret := getEnv("COOKIE_SECRET", "secret-key-must-be-32-bytes-long!") if len(cookieSecret) != 32 { slog.Warn("COOKIE_SECRET length is not 32 bytes. Adjusting...", "original_length", len(cookieSecret)) if len(cookieSecret) > 32 { cookieSecret = cookieSecret[:32] } else { // Pad with '0' if too short cookieSecret = fmt.Sprintf("%-32s", cookieSecret) } } app.Use(encryptcookie.New(encryptcookie.Config{ Key: cookieSecret, })) // Routes app.Get("/", func(c *fiber.Ctx) error { return c.SendString("Baron SSO Audit Backend Online") }) app.Get("/health", func(c *fiber.Ctx) error { status := "ok" checks := make(map[string]string) // Check ClickHouse if auditRepo != nil { if err := auditRepo.Ping(c.Context()); err != nil { checks["clickhouse"] = "error: " + err.Error() status = "error" } else { checks["clickhouse"] = "ok" } } else { checks["clickhouse"] = "not_initialized" status = "degraded" } // Check Redis if redisService != nil { if err := redisService.Ping(c.Context()); err != nil { checks["redis"] = "error: " + err.Error() status = "error" } else { checks["redis"] = "ok" } } else { checks["redis"] = "not_initialized" status = "degraded" } if status == "error" { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "status": status, "checks": checks, }) } return c.JSON(fiber.Map{ "status": status, "checks": checks, }) }) // API Group api := app.Group("/api/v1") api.Post("/audit", auditHandler.CreateLog) // Auth Proxy Routes auth := api.Group("/auth") auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) auth.Post("/password/login", authHandler.PasswordLogin) auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) // [Changed] Use Interstitial Page for GET to prevent Scanner consumption auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage) // [Added] Use POST for actual verification triggered by the user auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken) auth.Post("/password/reset/complete", authHandler.CompletePasswordReset) auth.Get("/password/policy", authHandler.GetPasswordPolicy) auth.Post("/sms", authHandler.SendSms) auth.Post("/verify-sms", authHandler.VerifySms) auth.Post("/qr/init", authHandler.InitQRLogin) auth.Post("/qr/poll", authHandler.PollQRLogin) auth.Post("/qr/approve", authHandler.ScanQRLogin) // Signup Routes signup := auth.Group("/signup") signup.Post("/check-email", authHandler.CheckEmail) signup.Post("/send-email-code", authHandler.SendSignupEmailCode) signup.Post("/send-sms-code", authHandler.SendSignupSmsCode) signup.Post("/verify-code", authHandler.VerifySignupCode) signup.Post("/", authHandler.Signup) // User Routes (My Page) user := api.Group("/user") user.Get("/me", authHandler.GetMe) user.Put("/me", authHandler.UpdateMe) user.Post("/me/send-code", authHandler.SendUpdateCode) user.Post("/me/verify-code", authHandler.VerifyUpdateCode) // Admin Routes admin := api.Group("/admin") admin.Get("/check", adminHandler.CheckAuth) // Webhook for Descope Generic SMS Gateway auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay) // Webhook for Descope Generic Email Gateway (Fake Email Strategy) auth.Post("/webhooks/descope-email", authHandler.HandleDescopeEmailRelay) // Client Logging Route (Standardized & Flattened) api.Post("/client-log", func(c *fiber.Ctx) error { type LogReq struct { Level string `json:"level"` Message string `json:"message"` Data map[string]interface{} `json:"data,omitempty"` } var req LogReq if err := c.BodyParser(&req); err != nil { return c.SendStatus(fiber.StatusBadRequest) } // Prepare attributes for flattening attrs := []any{ slog.String("source", "client"), } for k, v := range req.Data { // Skip svc if it's already set by the global logger to avoid confusion, // or keep it as client_svc if k == "svc" { attrs = append(attrs, slog.Any("client_svc", v)) } else { attrs = append(attrs, slog.Any(k, v)) } } // Map and log with correct level var level slog.Level switch req.Level { case "SEVERE", "ERROR": level = slog.LevelError case "WARNING", "WARN": level = slog.LevelWarn default: level = slog.LevelInfo } // Filter out noisy client navigation logs if level == slog.LevelInfo { msg := strings.ToLower(req.Message) if strings.Contains(msg, "navigating to") || strings.Contains(msg, "going to") || strings.Contains(msg, "redirecting to") || strings.Contains(msg, "full paths for routes") { return c.SendStatus(fiber.StatusOK) } } slog.Log(c.Context(), level, req.Message, attrs...) return c.SendStatus(fiber.StatusOK) }) // Start Server port := getEnv("BACKEND_PORT", "3000") slog.Info("Server listening", "port", port) fmt.Println("============================================================") if err := app.Listen(":" + port); err != nil { slog.Error("Server failed to start", "error", err) os.Exit(1) } }