diff --git a/go.mod b/go.mod index 70eeafb..93d2d86 100644 --- a/go.mod +++ b/go.mod @@ -29,11 +29,13 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/tinylib/msgp v1.2.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect diff --git a/go.sum b/go.sum index b31ce34..5260a7f 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -68,6 +70,8 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= +github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 4536613..94842b8 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -1,6 +1,10 @@ package auth -import "github.com/gofiber/fiber/v2" +import ( + "strings" + + "github.com/gofiber/fiber/v2" +) type Handler struct { svc *Service @@ -18,6 +22,7 @@ func (h *Handler) Register(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } + req.Username = strings.ToLower(strings.TrimSpace(req.Username)) if req.Username == "" || req.Password == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"}) } @@ -38,6 +43,7 @@ func (h *Handler) Login(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } + req.Username = strings.ToLower(strings.TrimSpace(req.Username)) if req.Username == "" || req.Password == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"}) } diff --git a/internal/download/service.go b/internal/download/service.go index 36547c4..cadab61 100644 --- a/internal/download/service.go +++ b/internal/download/service.go @@ -116,6 +116,10 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info } fileHash := hashGameExeFromZip(finalPath) + if fileHash == "" { + os.Remove(finalPath) + return nil, fmt.Errorf("zip 파일에 %s이(가) 포함되어 있지 않습니다", "A301.exe") + } info, err := s.repo.GetLatest() if err != nil { diff --git a/main.go b/main.go index 5b3807f..76743ad 100644 --- a/main.go +++ b/main.go @@ -10,8 +10,11 @@ import ( "a301_server/pkg/config" "a301_server/pkg/database" "a301_server/routes" + "time" + "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/limiter" "github.com/gofiber/fiber/v2/middleware/logger" ) @@ -81,6 +84,7 @@ func main() { app := fiber.New(fiber.Config{ StreamRequestBody: true, + BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB }) app.Use(logger.New()) app.Use(cors.New(cors.Config{ @@ -89,7 +93,31 @@ func main() { AllowMethods: "GET, POST, PUT, PATCH, DELETE", })) - routes.Register(app, authHandler, annHandler, dlHandler, chainHandler) + // Rate limiting: 인증 관련 엔드포인트 (로그인/회원가입/리프레시) + authLimiter := limiter.New(limiter.Config{ + Max: 10, + Expiration: 1 * time.Minute, + KeyGenerator: func(c *fiber.Ctx) string { + return c.IP() + }, + LimitReached: func(c *fiber.Ctx) error { + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "요청이 너무 많습니다. 잠시 후 다시 시도해주세요"}) + }, + }) + + // Rate limiting: 일반 API + apiLimiter := limiter.New(limiter.Config{ + Max: 60, + Expiration: 1 * time.Minute, + KeyGenerator: func(c *fiber.Ctx) string { + return c.IP() + }, + LimitReached: func(c *fiber.Ctx) error { + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "요청이 너무 많습니다. 잠시 후 다시 시도해주세요"}) + }, + }) + + routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, authLimiter, apiLimiter) log.Fatal(app.Listen(":" + config.C.AppPort)) } diff --git a/pkg/middleware/idempotency.go b/pkg/middleware/idempotency.go new file mode 100644 index 0000000..d404622 --- /dev/null +++ b/pkg/middleware/idempotency.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "context" + "encoding/json" + "time" + + "a301_server/pkg/database" + "github.com/gofiber/fiber/v2" +) + +const idempotencyTTL = 10 * time.Minute + +type cachedResponse struct { + StatusCode int `json:"s"` + Body json.RawMessage `json:"b"` +} + +// Idempotency checks the Idempotency-Key header to prevent duplicate transactions. +// If the same key is seen again within the TTL, the cached response is returned. +func Idempotency(c *fiber.Ctx) error { + key := c.Get("Idempotency-Key") + if key == "" { + return c.Next() + } + + redisKey := "idempotency:" + key + ctx := context.Background() + + // Check if this key was already processed + cached, err := database.RDB.Get(ctx, redisKey).Bytes() + if err == nil && len(cached) > 0 { + var cr cachedResponse + if json.Unmarshal(cached, &cr) == nil { + c.Set("Content-Type", "application/json") + c.Set("X-Idempotent-Replay", "true") + return c.Status(cr.StatusCode).Send(cr.Body) + } + } + + // Process the request + if err := c.Next(); err != nil { + return err + } + + // Cache successful responses (2xx) + status := c.Response().StatusCode() + if status >= 200 && status < 300 { + cr := cachedResponse{StatusCode: status, Body: c.Response().Body()} + if data, err := json.Marshal(cr); err == nil { + database.RDB.Set(ctx, redisKey, data, idempotencyTTL) + } + } + + return nil +} diff --git a/routes/routes.go b/routes/routes.go index 0c0318b..c2db1ea 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -15,14 +15,16 @@ func Register( annH *announcement.Handler, dlH *download.Handler, chainH *chain.Handler, + authLimiter fiber.Handler, + apiLimiter fiber.Handler, ) { - api := app.Group("/api") + api := app.Group("/api", apiLimiter) // Auth a := api.Group("/auth") - a.Post("/register", authH.Register) - a.Post("/login", authH.Login) - a.Post("/refresh", authH.Refresh) + a.Post("/register", authLimiter, authH.Register) + a.Post("/login", authLimiter, authH.Login) + a.Post("/refresh", authLimiter, authH.Refresh) a.Post("/logout", middleware.Auth, authH.Logout) a.Post("/verify", authH.VerifyToken) @@ -57,25 +59,25 @@ func Register( ch.Get("/market", chainH.GetMarketListings) ch.Get("/market/:id", chainH.GetMarketListing) - // Chain - User Transactions (authenticated) - ch.Post("/transfer", chainH.Transfer) - ch.Post("/asset/transfer", chainH.TransferAsset) - ch.Post("/market/list", chainH.ListOnMarket) - ch.Post("/market/buy", chainH.BuyFromMarket) - ch.Post("/market/cancel", chainH.CancelListing) - ch.Post("/inventory/equip", chainH.EquipItem) - ch.Post("/inventory/unequip", chainH.UnequipItem) + // Chain - User Transactions (authenticated, idempotency-protected) + ch.Post("/transfer", middleware.Idempotency, chainH.Transfer) + ch.Post("/asset/transfer", middleware.Idempotency, chainH.TransferAsset) + ch.Post("/market/list", middleware.Idempotency, chainH.ListOnMarket) + ch.Post("/market/buy", middleware.Idempotency, chainH.BuyFromMarket) + ch.Post("/market/cancel", middleware.Idempotency, chainH.CancelListing) + ch.Post("/inventory/equip", middleware.Idempotency, chainH.EquipItem) + ch.Post("/inventory/unequip", middleware.Idempotency, chainH.UnequipItem) - // Chain - Admin Transactions (admin only) + // Chain - Admin Transactions (admin only, idempotency-protected) chainAdmin := api.Group("/chain/admin", middleware.Auth, middleware.AdminOnly) - chainAdmin.Post("/mint", chainH.MintAsset) - chainAdmin.Post("/reward", chainH.GrantReward) - chainAdmin.Post("/template", chainH.RegisterTemplate) + chainAdmin.Post("/mint", middleware.Idempotency, chainH.MintAsset) + chainAdmin.Post("/reward", middleware.Idempotency, chainH.GrantReward) + chainAdmin.Post("/template", middleware.Idempotency, chainH.RegisterTemplate) - // Internal - Game server endpoints (API key auth, username-based) + // Internal - Game server endpoints (API key auth, username-based, idempotency-protected) internal := api.Group("/internal/chain", middleware.ServerAuth) - internal.Post("/reward", chainH.InternalGrantReward) - internal.Post("/mint", chainH.InternalMintAsset) + internal.Post("/reward", middleware.Idempotency, chainH.InternalGrantReward) + internal.Post("/mint", middleware.Idempotency, chainH.InternalMintAsset) internal.Get("/balance", chainH.InternalGetBalance) internal.Get("/assets", chainH.InternalGetAssets) internal.Get("/inventory", chainH.InternalGetInventory)