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" "errors" "fmt" "log" "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" "gorm.io/driver/postgres" "gorm.io/gorm" gormLogger "gorm.io/gorm/logger" "baron-sso-backend/internal/bootstrap" ) 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"), "userfront_port", getEnv("USERFRONT_PORT", "5000"), "userfront_url", getEnv("USERFRONT_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 // ClickHouse 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) } // PostgreSQL (Meta Store) pgHost := getEnv("DB_HOST", "localhost") pgPort := getEnv("DB_PORT", "5432") pgUser := getEnv("DB_USER", "baron") pgPass := getEnv("DB_PASSWORD", "password") pgName := getEnv("DB_NAME", "baron_sso") dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul", pgHost, pgUser, pgPass, pgName, pgPort) gormLog := gormLogger.New( log.New(os.Stdout, "\r\n", log.LstdFlags), gormLogger.Config{ SlowThreshold: time.Second, LogLevel: gormLogger.Warn, IgnoreRecordNotFoundError: true, Colorful: true, }, ) db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: gormLog, }) if err != nil { slog.Error("❌ Failed to connect to PostgreSQL", "error", err) // For local dev without Postgres, we might want to continue or panic. // But bootstrap requires DB. if getEnv("APP_ENV", "dev") == "production" { os.Exit(1) } } else { slog.Info("✅ Connected to PostgreSQL") // Run Bootstrap (Migrations & Seeding) if err := bootstrap.Run(db); err != nil { slog.Error("❌ Bootstrap failed", "error", err) // Panic or Exit depending on policy. } } 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, idpProvider) adminHandler := handler.NewAdminHandler() devHandler := handler.NewDevHandler() // 3. Initialize Fiber appEnv := getEnv("APP_ENV", "dev") app := fiber.New(fiber.Config{ AppName: "Baron SSO Backend", DisableStartupMessage: true, // Clean logs // Global Error Handler for Production Masking ErrorHandler: func(c *fiber.Ctx, err error) error { // Default status code code := fiber.StatusInternalServerError // Check if it's a known fiber.Error var e *fiber.Error if errors.As(err, &e) { code = e.Code } // In production or stage, mask detailed 500+ errors if appEnv == "production" || appEnv == "stage" { if code >= 500 { // Log the actual error for developers slog.Error("Internal Server Error", "error", err.Error(), "path", c.Path(), "method", c.Method(), ) // Return masked message return c.Status(code).JSON(fiber.Map{ "error": "Internal Server Error", }) } } // For development or non-500 errors, return the actual error message return c.Status(code).JSON(fiber.Map{ "error": err.Error(), }) }, }) // 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) api.Get("/audit", auditHandler.ListLogs) // 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) // 개발자 포털 라우트 (RP/Consent 관리) dev := api.Group("/dev") dev.Get("/clients", devHandler.ListClients) dev.Get("/clients/:id", devHandler.GetClient) dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus) dev.Get("/consents", devHandler.ListConsents) dev.Delete("/consents", devHandler.RevokeConsents) // 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) } }