From d597ef2d468b68c4f58046b6f8a57b0e59455dc9 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Fri, 13 Mar 2026 21:40:06 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B3=B4=EC=95=88=C2=B7=EC=95=88?= =?UTF-8?q?=EC=A0=95=EC=84=B1=C2=B7=EB=8F=99=EC=8B=9C=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=203=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 입력 검증 강화 (로그인/체인 핸들러 전체) - boss raid 비관적 잠금으로 동시성 문제 해결 - SSAFY 사용자명 sanitize + 트랜잭션 처리 - constant-time API 키 비교, 보안 헤더, graceful shutdown - 안전하지 않은 기본값 경고 추가 Co-Authored-By: Claude Opus 4.6 --- internal/auth/handler.go | 17 +++- internal/auth/repository.go | 7 ++ internal/auth/service.go | 48 +++++++--- internal/bossraid/repository.go | 18 ++++ internal/bossraid/service.go | 150 +++++++++++++++++++------------- internal/chain/handler.go | 43 +++++---- internal/download/handler.go | 7 +- main.go | 19 +++- pkg/config/config.go | 17 ++++ pkg/middleware/auth.go | 5 +- pkg/middleware/security.go | 13 +++ 11 files changed, 247 insertions(+), 97 deletions(-) create mode 100644 pkg/middleware/security.go diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 9fdda2d..a4a807e 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -1,12 +1,16 @@ package auth import ( + "regexp" "strconv" "strings" "github.com/gofiber/fiber/v2" ) +// usernameRe allows alphanumeric, underscore, hyphen (3-50 chars). +var usernameRe = regexp.MustCompile(`^[a-z0-9_-]{3,50}$`) + type Handler struct { svc *Service } @@ -27,12 +31,15 @@ func (h *Handler) Register(c *fiber.Ctx) error { if req.Username == "" || req.Password == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"}) } - if len(req.Username) > 50 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디는 50자 이하여야 합니다"}) + if !usernameRe.MatchString(req.Username) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디는 3~50자의 영문 소문자, 숫자, _, -만 사용 가능합니다"}) } if len(req.Password) < 6 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "비밀번호는 6자 이상이어야 합니다"}) } + if len(req.Password) > 72 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "비밀번호는 72자 이하여야 합니다"}) + } if err := h.svc.Register(req.Username, req.Password); err != nil { return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()}) } @@ -51,6 +58,12 @@ func (h *Handler) Login(c *fiber.Ctx) error { if req.Username == "" || req.Password == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"}) } + if len(req.Username) > 50 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디 또는 비밀번호가 올바르지 않습니다"}) + } + if len(req.Password) > 72 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디 또는 비밀번호가 올바르지 않습니다"}) + } accessToken, refreshToken, user, err := h.svc.Login(req.Username, req.Password) if err != nil { diff --git a/internal/auth/repository.go b/internal/auth/repository.go index 89eb405..1c7499f 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -44,6 +44,13 @@ func (r *Repository) Delete(id uint) error { return r.db.Delete(&User{}, id).Error } +// Transaction wraps a function in a database transaction. +func (r *Repository) Transaction(fn func(txRepo *Repository) error) error { + return r.db.Transaction(func(tx *gorm.DB) error { + return fn(&Repository{db: tx}) + }) +} + func (r *Repository) FindBySsafyID(ssafyID string) (*User, error) { var user User if err := r.db.Where("ssafy_id = ?", ssafyID).First(&user).Error; err != nil { diff --git a/internal/auth/service.go b/internal/auth/service.go index 38a02f3..1a3f23f 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -313,21 +313,36 @@ func (s *Service) SSAFYLogin(code string) (accessToken, refreshToken string, use } ssafyID := userInfo.UserID - username := "ssafy_" + ssafyID - user = &User{ - Username: username, - PasswordHash: string(hash), - Role: RoleUser, - SsafyID: &ssafyID, + // SSAFY ID에서 영문 소문자+숫자만 추출하여 안전한 username 생성 + safeID := sanitizeForUsername(ssafyID) + if safeID == "" { + safeID = hex.EncodeToString(randomBytes[:8]) } - if err := s.repo.Create(user); err != nil { + username := "ssafy_" + safeID + if len(username) > 50 { + username = username[:50] + } + + var newUserID uint + err = s.repo.Transaction(func(txRepo *Repository) error { + user = &User{ + Username: username, + PasswordHash: string(hash), + Role: RoleUser, + SsafyID: &ssafyID, + } + return txRepo.Create(user) + }) + if err != nil { return "", "", nil, fmt.Errorf("계정 생성 실패: %v", err) } + newUserID = user.ID + if s.walletCreator != nil { - if err := s.walletCreator(user.ID); err != nil { - log.Printf("wallet creation failed for SSAFY user %d: %v — rolling back", user.ID, err) - if delErr := s.repo.Delete(user.ID); delErr != nil { - log.Printf("WARNING: rollback delete also failed for SSAFY user %d: %v", user.ID, delErr) + if err := s.walletCreator(newUserID); err != nil { + log.Printf("wallet creation failed for SSAFY user %d: %v — rolling back", newUserID, err) + if delErr := s.repo.Delete(newUserID); delErr != nil { + log.Printf("WARNING: rollback delete also failed for SSAFY user %d: %v", newUserID, delErr) } return "", "", nil, fmt.Errorf("계정 초기화에 실패했습니다. 잠시 후 다시 시도해주세요") } @@ -373,6 +388,17 @@ func (s *Service) VerifyToken(tokenStr string) (string, error) { return claims.Username, nil } +// sanitizeForUsername strips characters that are not [a-z0-9_-]. +func sanitizeForUsername(s string) string { + var b strings.Builder + for _, c := range strings.ToLower(s) { + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-' { + b.WriteRune(c) + } + } + return b.String() +} + func (s *Service) EnsureAdmin(username, password string) error { if _, err := s.repo.FindByUsername(username); err == nil { return nil diff --git a/internal/bossraid/repository.go b/internal/bossraid/repository.go index 4928718..99f848a 100644 --- a/internal/bossraid/repository.go +++ b/internal/bossraid/repository.go @@ -4,6 +4,7 @@ import ( "strings" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type Repository struct { @@ -30,6 +31,23 @@ func (r *Repository) FindBySessionName(sessionName string) (*BossRoom, error) { return &room, nil } +// FindBySessionNameForUpdate acquires a row-level lock (SELECT ... FOR UPDATE) +// to prevent concurrent state transitions. +func (r *Repository) FindBySessionNameForUpdate(sessionName string) (*BossRoom, error) { + var room BossRoom + if err := r.db.Clauses(clause.Locking{Strength: "UPDATE"}).Where("session_name = ?", sessionName).First(&room).Error; err != nil { + return nil, err + } + return &room, nil +} + +// Transaction wraps a function in a database transaction. +func (r *Repository) Transaction(fn func(txRepo *Repository) error) error { + return r.db.Transaction(func(tx *gorm.DB) error { + return fn(&Repository{db: tx}) + }) +} + // CountActiveByUsername checks if a player is already in an active boss raid. func (r *Repository) CountActiveByUsername(username string) (int64, error) { var count int64 diff --git a/internal/bossraid/service.go b/internal/bossraid/service.go index 750b082..2a6c319 100644 --- a/internal/bossraid/service.go +++ b/internal/bossraid/service.go @@ -76,24 +76,32 @@ func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error } // StartRaid marks a room as in_progress. +// Uses row-level locking to prevent concurrent state transitions. func (s *Service) StartRaid(sessionName string) (*BossRoom, error) { - room, err := s.repo.FindBySessionName(sessionName) + var resultRoom *BossRoom + err := s.repo.Transaction(func(txRepo *Repository) error { + room, err := txRepo.FindBySessionNameForUpdate(sessionName) + if err != nil { + return fmt.Errorf("방을 찾을 수 없습니다: %w", err) + } + if room.Status != StatusWaiting { + return fmt.Errorf("시작할 수 없는 상태입니다: %s", room.Status) + } + + now := time.Now() + room.Status = StatusInProgress + room.StartedAt = &now + + if err := txRepo.Update(room); err != nil { + return fmt.Errorf("상태 업데이트 실패: %w", err) + } + resultRoom = room + return nil + }) if err != nil { - return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err) + return nil, err } - if room.Status != StatusWaiting { - return nil, fmt.Errorf("시작할 수 없는 상태입니다: %s", room.Status) - } - - now := time.Now() - room.Status = StatusInProgress - room.StartedAt = &now - - if err := s.repo.Update(room); err != nil { - return nil, fmt.Errorf("상태 업데이트 실패: %w", err) - } - - return room, nil + return resultRoom, nil } // PlayerReward describes the reward for a single player. @@ -111,40 +119,52 @@ type RewardResult struct { } // CompleteRaid marks a room as completed and grants rewards via blockchain. +// Uses a database transaction with row-level locking to prevent double-completion. func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*BossRoom, []RewardResult, error) { - room, err := s.repo.FindBySessionName(sessionName) - if err != nil { - return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err) - } - if room.Status != StatusInProgress { - return nil, nil, fmt.Errorf("완료할 수 없는 상태입니다: %s", room.Status) - } + var resultRoom *BossRoom + var resultRewards []RewardResult - // Validate reward recipients are room players - var players []string - if err := json.Unmarshal([]byte(room.Players), &players); err != nil { - return nil, nil, fmt.Errorf("플레이어 목록 파싱 실패: %w", err) - } - playerSet := make(map[string]bool, len(players)) - for _, p := range players { - playerSet[p] = true - } - for _, r := range rewards { - if !playerSet[r.Username] { - return nil, nil, fmt.Errorf("보상 대상 %s가 방의 플레이어가 아닙니다", r.Username) + err := s.repo.Transaction(func(txRepo *Repository) error { + room, err := txRepo.FindBySessionNameForUpdate(sessionName) + if err != nil { + return fmt.Errorf("방을 찾을 수 없습니다: %w", err) } + if room.Status != StatusInProgress { + return fmt.Errorf("완료할 수 없는 상태입니다: %s", room.Status) + } + + // Validate reward recipients are room players + var players []string + if err := json.Unmarshal([]byte(room.Players), &players); err != nil { + return fmt.Errorf("플레이어 목록 파싱 실패: %w", err) + } + playerSet := make(map[string]bool, len(players)) + for _, p := range players { + playerSet[p] = true + } + for _, r := range rewards { + if !playerSet[r.Username] { + return fmt.Errorf("보상 대상 %s가 방의 플레이어가 아닙니다", r.Username) + } + } + + // Mark room completed + now := time.Now() + room.Status = StatusCompleted + room.CompletedAt = &now + if err := txRepo.Update(room); err != nil { + return fmt.Errorf("상태 업데이트 실패: %w", err) + } + + resultRoom = room + return nil + }) + if err != nil { + return nil, nil, err } - // Mark room completed - now := time.Now() - room.Status = StatusCompleted - room.CompletedAt = &now - if err := s.repo.Update(room); err != nil { - return nil, nil, fmt.Errorf("상태 업데이트 실패: %w", err) - } - - // Grant rewards - results := make([]RewardResult, 0, len(rewards)) + // Grant rewards outside the transaction to avoid holding the lock during RPC calls + resultRewards = make([]RewardResult, 0, len(rewards)) if s.rewardGrant != nil { for _, r := range rewards { grantErr := s.rewardGrant(r.Username, r.TokenAmount, r.Assets) @@ -153,32 +173,40 @@ func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*Bos result.Error = grantErr.Error() log.Printf("보상 지급 실패: %s: %v", r.Username, grantErr) } - results = append(results, result) + resultRewards = append(resultRewards, result) } } - return room, results, nil + return resultRoom, resultRewards, nil } // FailRaid marks a room as failed. +// Uses row-level locking to prevent concurrent state transitions. func (s *Service) FailRaid(sessionName string) (*BossRoom, error) { - room, err := s.repo.FindBySessionName(sessionName) + var resultRoom *BossRoom + err := s.repo.Transaction(func(txRepo *Repository) error { + room, err := txRepo.FindBySessionNameForUpdate(sessionName) + if err != nil { + return fmt.Errorf("방을 찾을 수 없습니다: %w", err) + } + if room.Status != StatusWaiting && room.Status != StatusInProgress { + return fmt.Errorf("실패 처리할 수 없는 상태입니다: %s", room.Status) + } + + now := time.Now() + room.Status = StatusFailed + room.CompletedAt = &now + + if err := txRepo.Update(room); err != nil { + return fmt.Errorf("상태 업데이트 실패: %w", err) + } + resultRoom = room + return nil + }) if err != nil { - return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err) + return nil, err } - if room.Status != StatusWaiting && room.Status != StatusInProgress { - return nil, fmt.Errorf("실패 처리할 수 없는 상태입니다: %s", room.Status) - } - - now := time.Now() - room.Status = StatusFailed - room.CompletedAt = &now - - if err := s.repo.Update(room); err != nil { - return nil, fmt.Errorf("상태 업데이트 실패: %w", err) - } - - return room, nil + return resultRoom, nil } // GetRoom returns a room by session name. diff --git a/internal/chain/handler.go b/internal/chain/handler.go index 5b2de37..c098673 100644 --- a/internal/chain/handler.go +++ b/internal/chain/handler.go @@ -9,6 +9,7 @@ import ( ) const maxLimit = 200 +const maxIDLength = 256 // max length for string IDs (assetId, listingId, etc.) type Handler struct { svc *Service @@ -40,6 +41,10 @@ func parsePagination(c *fiber.Ctx) (int, int) { return offset, limit } +func validID(s string) bool { + return s != "" && len(s) <= maxIDLength +} + func chainError(c *fiber.Ctx, status int, userMsg string, err error) error { log.Printf("chain error: %s: %v", userMsg, err) return c.Status(status).JSON(fiber.Map{"error": userMsg}) @@ -90,8 +95,8 @@ func (h *Handler) GetAssets(c *fiber.Ctx) error { func (h *Handler) GetAsset(c *fiber.Ctx) error { assetID := c.Params("id") - if assetID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "asset id is required"}) + if !validID(assetID) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효한 asset id가 필요합니다"}) } result, err := h.svc.GetAsset(assetID) if err != nil { @@ -126,8 +131,8 @@ func (h *Handler) GetMarketListings(c *fiber.Ctx) error { func (h *Handler) GetMarketListing(c *fiber.Ctx) error { listingID := c.Params("id") - if listingID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listing id is required"}) + if !validID(listingID) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "유효한 listing id가 필요합니다"}) } result, err := h.svc.GetListing(listingID) if err != nil { @@ -151,7 +156,7 @@ func (h *Handler) Transfer(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } - if req.To == "" || req.Amount == 0 { + if !validID(req.To) || req.Amount == 0 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "to와 amount는 필수입니다"}) } result, err := h.svc.Transfer(userID, req.To, req.Amount) @@ -173,7 +178,7 @@ func (h *Handler) TransferAsset(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } - if req.AssetID == "" || req.To == "" { + if !validID(req.AssetID) || !validID(req.To) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 to는 필수입니다"}) } result, err := h.svc.TransferAsset(userID, req.AssetID, req.To) @@ -195,7 +200,7 @@ func (h *Handler) ListOnMarket(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } - if req.AssetID == "" || req.Price == 0 { + if !validID(req.AssetID) || req.Price == 0 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 price는 필수입니다"}) } result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price) @@ -216,7 +221,7 @@ func (h *Handler) BuyFromMarket(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } - if req.ListingID == "" { + if !validID(req.ListingID) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"}) } result, err := h.svc.BuyFromMarket(userID, req.ListingID) @@ -237,7 +242,7 @@ func (h *Handler) CancelListing(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } - if req.ListingID == "" { + if !validID(req.ListingID) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "listingId는 필수입니다"}) } result, err := h.svc.CancelListing(userID, req.ListingID) @@ -259,7 +264,7 @@ func (h *Handler) EquipItem(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } - if req.AssetID == "" || req.Slot == "" { + if !validID(req.AssetID) || !validID(req.Slot) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId와 slot은 필수입니다"}) } result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot) @@ -280,7 +285,7 @@ func (h *Handler) UnequipItem(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } - if req.AssetID == "" { + if !validID(req.AssetID) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "assetId는 필수입니다"}) } result, err := h.svc.UnequipItem(userID, req.AssetID) @@ -301,7 +306,7 @@ func (h *Handler) MintAsset(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } - if req.TemplateID == "" || req.OwnerPubKey == "" { + if !validID(req.TemplateID) || !validID(req.OwnerPubKey) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 ownerPubKey는 필수입니다"}) } result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties) @@ -320,7 +325,7 @@ func (h *Handler) GrantReward(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } - if req.RecipientPubKey == "" { + if !validID(req.RecipientPubKey) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "recipientPubKey는 필수입니다"}) } result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets) @@ -340,7 +345,7 @@ func (h *Handler) RegisterTemplate(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } - if req.ID == "" || req.Name == "" { + if !validID(req.ID) || !validID(req.Name) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id와 name은 필수입니다"}) } result, err := h.svc.RegisterTemplate(req.ID, req.Name, req.Schema, req.Tradeable) @@ -362,7 +367,7 @@ func (h *Handler) InternalGrantReward(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } - if req.Username == "" { + if !validID(req.Username) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"}) } result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets) @@ -382,7 +387,7 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } - if req.TemplateID == "" || req.Username == "" { + if !validID(req.TemplateID) || !validID(req.Username) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "templateId와 username은 필수입니다"}) } result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties) @@ -395,7 +400,7 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error { // InternalGetBalance returns balance by username. For game server use. func (h *Handler) InternalGetBalance(c *fiber.Ctx) error { username := c.Query("username") - if username == "" { + if !validID(username) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"}) } result, err := h.svc.GetBalanceByUsername(username) @@ -408,7 +413,7 @@ func (h *Handler) InternalGetBalance(c *fiber.Ctx) error { // InternalGetAssets returns assets by username. For game server use. func (h *Handler) InternalGetAssets(c *fiber.Ctx) error { username := c.Query("username") - if username == "" { + if !validID(username) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"}) } offset, limit := parsePagination(c) @@ -423,7 +428,7 @@ func (h *Handler) InternalGetAssets(c *fiber.Ctx) error { // InternalGetInventory returns inventory by username. For game server use. func (h *Handler) InternalGetInventory(c *fiber.Ctx) error { username := c.Query("username") - if username == "" { + if !validID(username) { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"}) } result, err := h.svc.GetInventoryByUsername(username) diff --git a/internal/download/handler.go b/internal/download/handler.go index f687b82..fe4754b 100644 --- a/internal/download/handler.go +++ b/internal/download/handler.go @@ -1,6 +1,7 @@ package download import ( + "log" "mime" "os" "path/filepath" @@ -42,7 +43,8 @@ func (h *Handler) Upload(c *fiber.Ctx) error { body := c.Request().BodyStream() info, err := h.svc.Upload(filename, body, h.baseURL) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "업로드 실패: " + err.Error()}) + log.Printf("game upload failed: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "게임 파일 업로드에 실패했습니다"}) } return c.JSON(info) } @@ -65,7 +67,8 @@ func (h *Handler) UploadLauncher(c *fiber.Ctx) error { body := c.Request().BodyStream() info, err := h.svc.UploadLauncher(body, h.baseURL) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "업로드 실패: " + err.Error()}) + log.Printf("launcher upload failed: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "런처 업로드에 실패했습니다"}) } return c.JSON(info) } diff --git a/main.go b/main.go index d9e3698..2bc453d 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,10 @@ package main import ( "log" + "os" + "os/signal" + "syscall" + "time" "a301_server/internal/announcement" "a301_server/internal/auth" @@ -12,8 +16,8 @@ import ( "github.com/tolelom/tolchain/core" "a301_server/pkg/config" "a301_server/pkg/database" + "a301_server/pkg/middleware" "a301_server/routes" - "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" @@ -23,6 +27,7 @@ import ( func main() { config.Load() + config.WarnInsecureDefaults() if err := database.ConnectMySQL(); err != nil { log.Fatalf("MySQL 연결 실패: %v", err) @@ -101,6 +106,7 @@ func main() { BodyLimit: 4 * 1024 * 1024 * 1024, // 4GB }) app.Use(logger.New()) + app.Use(middleware.SecurityHeaders) app.Use(cors.New(cors.Config{ AllowOrigins: "https://a301.tolelom.xyz", AllowHeaders: "Origin, Content-Type, Authorization, Idempotency-Key, X-API-Key", @@ -133,5 +139,16 @@ func main() { routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, brHandler, authLimiter, apiLimiter) + // Graceful shutdown + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + sig := <-sigCh + log.Printf("수신된 시그널: %v — 서버 종료 중...", sig) + if err := app.ShutdownWithTimeout(10 * time.Second); err != nil { + log.Printf("서버 종료 실패: %v", err) + } + }() + log.Fatal(app.Listen(":" + config.C.AppPort)) } diff --git a/pkg/config/config.go b/pkg/config/config.go index e6f17d9..850330b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,6 +1,7 @@ package config import ( + "log" "os" "strconv" @@ -75,6 +76,22 @@ func Load() { } } +// WarnInsecureDefaults logs warnings for security-sensitive settings left at defaults. +func WarnInsecureDefaults() { + if C.JWTSecret == "secret" { + log.Println("WARNING: JWT_SECRET is using the default value — set a strong secret for production") + } + if C.RefreshSecret == "refresh-secret" { + log.Println("WARNING: REFRESH_SECRET is using the default value — set a strong secret for production") + } + if C.AdminPassword == "admin1234" { + log.Println("WARNING: ADMIN_PASSWORD is using the default value — change it for production") + } + if C.WalletEncryptionKey == "" { + log.Println("WARNING: WALLET_ENCRYPTION_KEY is empty — blockchain wallet features will fail") + } +} + func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index c27b38d..3235b9b 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -2,6 +2,7 @@ package middleware import ( "context" + "crypto/subtle" "fmt" "log" "strings" @@ -70,9 +71,11 @@ func AdminOnly(c *fiber.Ctx) error { } // ServerAuth validates X-API-Key header for server-to-server communication. +// Uses constant-time comparison to prevent timing attacks. func ServerAuth(c *fiber.Ctx) error { key := c.Get("X-API-Key") - if key == "" || config.C.InternalAPIKey == "" || key != config.C.InternalAPIKey { + expected := config.C.InternalAPIKey + if key == "" || expected == "" || subtle.ConstantTimeCompare([]byte(key), []byte(expected)) != 1 { log.Printf("ServerAuth 실패: IP=%s, Path=%s, KeyPresent=%v", c.IP(), c.Path(), key != "") return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 API 키입니다"}) } diff --git a/pkg/middleware/security.go b/pkg/middleware/security.go new file mode 100644 index 0000000..64e671c --- /dev/null +++ b/pkg/middleware/security.go @@ -0,0 +1,13 @@ +package middleware + +import "github.com/gofiber/fiber/v2" + +// SecurityHeaders sets common HTTP security headers on every response. +func SecurityHeaders(c *fiber.Ctx) error { + c.Set("X-Content-Type-Options", "nosniff") + c.Set("X-Frame-Options", "DENY") + c.Set("X-XSS-Protection", "0") + c.Set("Referrer-Policy", "strict-origin-when-cross-origin") + c.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + return c.Next() +}