- fileHash 빈 문자열 시 게임 업로드 거부 (A301.exe 누락 zip 차단) - Rate limiting 추가: 인증 API 10req/min, 일반 API 60req/min - 블록체인 트랜잭션 Idempotency-Key 미들웨어 (Redis 캐싱, 10분 TTL) - 파일 업로드 크기 제한 4GB (BodyLimit) - Username 대소문자 정규화 (Register/Login에서 소문자 변환) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
go.mod
2
go.mod
@@ -29,11 +29,13 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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_golang v1.23.2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
github.com/rivo/uniseg v0.2.0 // 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/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
|
|||||||
4
go.sum
4
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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
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/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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import "github.com/gofiber/fiber/v2"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
svc *Service
|
svc *Service
|
||||||
@@ -18,6 +22,7 @@ func (h *Handler) Register(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||||
}
|
}
|
||||||
|
req.Username = strings.ToLower(strings.TrimSpace(req.Username))
|
||||||
if req.Username == "" || req.Password == "" {
|
if req.Username == "" || req.Password == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"})
|
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 {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||||
}
|
}
|
||||||
|
req.Username = strings.ToLower(strings.TrimSpace(req.Username))
|
||||||
if req.Username == "" || req.Password == "" {
|
if req.Username == "" || req.Password == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ func (s *Service) Upload(filename string, body io.Reader, baseURL string) (*Info
|
|||||||
}
|
}
|
||||||
|
|
||||||
fileHash := hashGameExeFromZip(finalPath)
|
fileHash := hashGameExeFromZip(finalPath)
|
||||||
|
if fileHash == "" {
|
||||||
|
os.Remove(finalPath)
|
||||||
|
return nil, fmt.Errorf("zip 파일에 %s이(가) 포함되어 있지 않습니다", "A301.exe")
|
||||||
|
}
|
||||||
|
|
||||||
info, err := s.repo.GetLatest()
|
info, err := s.repo.GetLatest()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
30
main.go
30
main.go
@@ -10,8 +10,11 @@ import (
|
|||||||
"a301_server/pkg/config"
|
"a301_server/pkg/config"
|
||||||
"a301_server/pkg/database"
|
"a301_server/pkg/database"
|
||||||
"a301_server/routes"
|
"a301_server/routes"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,6 +84,7 @@ func main() {
|
|||||||
|
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
StreamRequestBody: true,
|
StreamRequestBody: true,
|
||||||
|
BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB
|
||||||
})
|
})
|
||||||
app.Use(logger.New())
|
app.Use(logger.New())
|
||||||
app.Use(cors.New(cors.Config{
|
app.Use(cors.New(cors.Config{
|
||||||
@@ -89,7 +93,31 @@ func main() {
|
|||||||
AllowMethods: "GET, POST, PUT, PATCH, DELETE",
|
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))
|
log.Fatal(app.Listen(":" + config.C.AppPort))
|
||||||
}
|
}
|
||||||
|
|||||||
56
pkg/middleware/idempotency.go
Normal file
56
pkg/middleware/idempotency.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -15,14 +15,16 @@ func Register(
|
|||||||
annH *announcement.Handler,
|
annH *announcement.Handler,
|
||||||
dlH *download.Handler,
|
dlH *download.Handler,
|
||||||
chainH *chain.Handler,
|
chainH *chain.Handler,
|
||||||
|
authLimiter fiber.Handler,
|
||||||
|
apiLimiter fiber.Handler,
|
||||||
) {
|
) {
|
||||||
api := app.Group("/api")
|
api := app.Group("/api", apiLimiter)
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
a := api.Group("/auth")
|
a := api.Group("/auth")
|
||||||
a.Post("/register", authH.Register)
|
a.Post("/register", authLimiter, authH.Register)
|
||||||
a.Post("/login", authH.Login)
|
a.Post("/login", authLimiter, authH.Login)
|
||||||
a.Post("/refresh", authH.Refresh)
|
a.Post("/refresh", authLimiter, authH.Refresh)
|
||||||
a.Post("/logout", middleware.Auth, authH.Logout)
|
a.Post("/logout", middleware.Auth, authH.Logout)
|
||||||
a.Post("/verify", authH.VerifyToken)
|
a.Post("/verify", authH.VerifyToken)
|
||||||
|
|
||||||
@@ -57,25 +59,25 @@ func Register(
|
|||||||
ch.Get("/market", chainH.GetMarketListings)
|
ch.Get("/market", chainH.GetMarketListings)
|
||||||
ch.Get("/market/:id", chainH.GetMarketListing)
|
ch.Get("/market/:id", chainH.GetMarketListing)
|
||||||
|
|
||||||
// Chain - User Transactions (authenticated)
|
// Chain - User Transactions (authenticated, idempotency-protected)
|
||||||
ch.Post("/transfer", chainH.Transfer)
|
ch.Post("/transfer", middleware.Idempotency, chainH.Transfer)
|
||||||
ch.Post("/asset/transfer", chainH.TransferAsset)
|
ch.Post("/asset/transfer", middleware.Idempotency, chainH.TransferAsset)
|
||||||
ch.Post("/market/list", chainH.ListOnMarket)
|
ch.Post("/market/list", middleware.Idempotency, chainH.ListOnMarket)
|
||||||
ch.Post("/market/buy", chainH.BuyFromMarket)
|
ch.Post("/market/buy", middleware.Idempotency, chainH.BuyFromMarket)
|
||||||
ch.Post("/market/cancel", chainH.CancelListing)
|
ch.Post("/market/cancel", middleware.Idempotency, chainH.CancelListing)
|
||||||
ch.Post("/inventory/equip", chainH.EquipItem)
|
ch.Post("/inventory/equip", middleware.Idempotency, chainH.EquipItem)
|
||||||
ch.Post("/inventory/unequip", chainH.UnequipItem)
|
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 := api.Group("/chain/admin", middleware.Auth, middleware.AdminOnly)
|
||||||
chainAdmin.Post("/mint", chainH.MintAsset)
|
chainAdmin.Post("/mint", middleware.Idempotency, chainH.MintAsset)
|
||||||
chainAdmin.Post("/reward", chainH.GrantReward)
|
chainAdmin.Post("/reward", middleware.Idempotency, chainH.GrantReward)
|
||||||
chainAdmin.Post("/template", chainH.RegisterTemplate)
|
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 := api.Group("/internal/chain", middleware.ServerAuth)
|
||||||
internal.Post("/reward", chainH.InternalGrantReward)
|
internal.Post("/reward", middleware.Idempotency, chainH.InternalGrantReward)
|
||||||
internal.Post("/mint", chainH.InternalMintAsset)
|
internal.Post("/mint", middleware.Idempotency, chainH.InternalMintAsset)
|
||||||
internal.Get("/balance", chainH.InternalGetBalance)
|
internal.Get("/balance", chainH.InternalGetBalance)
|
||||||
internal.Get("/assets", chainH.InternalGetAssets)
|
internal.Get("/assets", chainH.InternalGetAssets)
|
||||||
internal.Get("/inventory", chainH.InternalGetInventory)
|
internal.Get("/inventory", chainH.InternalGetInventory)
|
||||||
|
|||||||
Reference in New Issue
Block a user