fix: 코드 리뷰 기반 보안·안정성 개선 (14건)
All checks were successful
Server CI/CD / deploy (push) Successful in 1m36s

- unsafe 타입 단언 → safe assertion (chain handler 11곳, auth Logout)
- Repository 에러 시 nil 반환으로 통일 (chain, auth, announcement)
- string ID → uint 파싱으로 타입 안전성 확보 (auth, announcement)
- CORS AllowHeaders에 Idempotency-Key, X-API-Key 추가
- /verify 엔드포인트 rate limiter 적용
- Redis 호출에 context timeout 적용 (auth, idempotency 미들웨어)
- chain handler 에러 응답에서 내부 정보 노출 방지
- f.Close() 에러 검사 추가 (download service 2곳)
- 공지사항 Delete 404 응답 추가
- 회원가입 롤백 시 Delete 에러 로깅

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 14:43:19 +09:00
parent 3d0c9e5670
commit 23bec776ab
14 changed files with 274 additions and 89 deletions

View File

@@ -1,12 +1,15 @@
package chain
import (
"log"
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/tolelom/tolchain/core"
)
const maxLimit = 200
type Handler struct {
svc *Service
}
@@ -15,10 +18,40 @@ func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func getUserID(c *fiber.Ctx) (uint, error) {
uid, ok := c.Locals("userID").(uint)
if !ok {
return 0, fiber.NewError(fiber.StatusUnauthorized, "인증이 필요합니다")
}
return uid, nil
}
func parsePagination(c *fiber.Ctx) (int, int) {
offset, _ := strconv.Atoi(c.Query("offset", "0"))
limit, _ := strconv.Atoi(c.Query("limit", "50"))
if offset < 0 {
offset = 0
}
if limit <= 0 {
limit = 50
} else if limit > maxLimit {
limit = maxLimit
}
return offset, limit
}
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})
}
// ---- Query Handlers ----
func (h *Handler) GetWalletInfo(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
userID, err := getUserID(c)
if err != nil {
return err
}
w, err := h.svc.GetWallet(userID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "지갑을 찾을 수 없습니다"})
@@ -30,21 +63,26 @@ func (h *Handler) GetWalletInfo(c *fiber.Ctx) error {
}
func (h *Handler) GetBalance(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
userID, err := getUserID(c)
if err != nil {
return err
}
result, err := h.svc.GetBalance(userID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err)
}
return c.JSON(result)
}
func (h *Handler) GetAssets(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
offset, _ := strconv.Atoi(c.Query("offset", "0"))
limit, _ := strconv.Atoi(c.Query("limit", "50"))
userID, err := getUserID(c)
if err != nil {
return err
}
offset, limit := parsePagination(c)
result, err := h.svc.GetAssets(userID, offset, limit)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -57,28 +95,30 @@ func (h *Handler) GetAsset(c *fiber.Ctx) error {
}
result, err := h.svc.GetAsset(assetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
func (h *Handler) GetInventory(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
userID, err := getUserID(c)
if err != nil {
return err
}
result, err := h.svc.GetInventory(userID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
}
func (h *Handler) GetMarketListings(c *fiber.Ctx) error {
offset, _ := strconv.Atoi(c.Query("offset", "0"))
limit, _ := strconv.Atoi(c.Query("limit", "50"))
offset, limit := parsePagination(c)
result, err := h.svc.GetMarketListings(offset, limit)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -91,7 +131,7 @@ func (h *Handler) GetMarketListing(c *fiber.Ctx) error {
}
result, err := h.svc.GetListing(listingID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "마켓 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -100,7 +140,10 @@ func (h *Handler) GetMarketListing(c *fiber.Ctx) error {
// ---- User Transaction Handlers ----
func (h *Handler) Transfer(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
To string `json:"to"`
Amount uint64 `json:"amount"`
@@ -113,13 +156,16 @@ func (h *Handler) Transfer(c *fiber.Ctx) error {
}
result, err := h.svc.Transfer(userID, req.To, req.Amount)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "전송에 실패했습니다", err)
}
return c.JSON(result)
}
func (h *Handler) TransferAsset(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
AssetID string `json:"assetId"`
To string `json:"to"`
@@ -132,13 +178,16 @@ func (h *Handler) TransferAsset(c *fiber.Ctx) error {
}
result, err := h.svc.TransferAsset(userID, req.AssetID, req.To)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "에셋 전송에 실패했습니다", err)
}
return c.JSON(result)
}
func (h *Handler) ListOnMarket(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
AssetID string `json:"assetId"`
Price uint64 `json:"price"`
@@ -151,13 +200,16 @@ func (h *Handler) ListOnMarket(c *fiber.Ctx) error {
}
result, err := h.svc.ListOnMarket(userID, req.AssetID, req.Price)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "마켓 등록에 실패했습니다", err)
}
return c.JSON(result)
}
func (h *Handler) BuyFromMarket(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
ListingID string `json:"listingId"`
}
@@ -169,13 +221,16 @@ func (h *Handler) BuyFromMarket(c *fiber.Ctx) error {
}
result, err := h.svc.BuyFromMarket(userID, req.ListingID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "마켓 구매에 실패했습니다", err)
}
return c.JSON(result)
}
func (h *Handler) CancelListing(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
ListingID string `json:"listingId"`
}
@@ -187,13 +242,16 @@ func (h *Handler) CancelListing(c *fiber.Ctx) error {
}
result, err := h.svc.CancelListing(userID, req.ListingID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "마켓 취소에 실패했습니다", err)
}
return c.JSON(result)
}
func (h *Handler) EquipItem(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
AssetID string `json:"assetId"`
Slot string `json:"slot"`
@@ -206,13 +264,16 @@ func (h *Handler) EquipItem(c *fiber.Ctx) error {
}
result, err := h.svc.EquipItem(userID, req.AssetID, req.Slot)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "장착에 실패했습니다", err)
}
return c.JSON(result)
}
func (h *Handler) UnequipItem(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
userID, err := getUserID(c)
if err != nil {
return err
}
var req struct {
AssetID string `json:"assetId"`
}
@@ -224,7 +285,7 @@ func (h *Handler) UnequipItem(c *fiber.Ctx) error {
}
result, err := h.svc.UnequipItem(userID, req.AssetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "장착 해제에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -245,7 +306,7 @@ func (h *Handler) MintAsset(c *fiber.Ctx) error {
}
result, err := h.svc.MintAsset(req.TemplateID, req.OwnerPubKey, req.Properties)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -264,7 +325,7 @@ func (h *Handler) GrantReward(c *fiber.Ctx) error {
}
result, err := h.svc.GrantReward(req.RecipientPubKey, req.TokenAmount, req.Assets)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -284,7 +345,7 @@ func (h *Handler) RegisterTemplate(c *fiber.Ctx) error {
}
result, err := h.svc.RegisterTemplate(req.ID, req.Name, req.Schema, req.Tradeable)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "템플릿 등록에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -306,7 +367,7 @@ func (h *Handler) InternalGrantReward(c *fiber.Ctx) error {
}
result, err := h.svc.GrantRewardByUsername(req.Username, req.TokenAmount, req.Assets)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "보상 지급에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -326,7 +387,7 @@ func (h *Handler) InternalMintAsset(c *fiber.Ctx) error {
}
result, err := h.svc.MintAssetByUsername(req.TemplateID, req.Username, req.Properties)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "에셋 발행에 실패했습니다", err)
}
return c.Status(fiber.StatusCreated).JSON(result)
}
@@ -339,7 +400,7 @@ func (h *Handler) InternalGetBalance(c *fiber.Ctx) error {
}
result, err := h.svc.GetBalanceByUsername(username)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "잔액 조회에 실패했습니다", err)
}
return c.JSON(result)
}
@@ -350,11 +411,10 @@ func (h *Handler) InternalGetAssets(c *fiber.Ctx) error {
if username == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username은 필수입니다"})
}
offset, _ := strconv.Atoi(c.Query("offset", "0"))
limit, _ := strconv.Atoi(c.Query("limit", "50"))
offset, limit := parsePagination(c)
result, err := h.svc.GetAssetsByUsername(username, offset, limit)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "에셋 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)
@@ -368,7 +428,7 @@ func (h *Handler) InternalGetInventory(c *fiber.Ctx) error {
}
result, err := h.svc.GetInventoryByUsername(username)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return chainError(c, fiber.StatusInternalServerError, "인벤토리 조회에 실패했습니다", err)
}
c.Set("Content-Type", "application/json")
return c.Send(result)

View File

@@ -16,12 +16,16 @@ func (r *Repository) Create(w *UserWallet) error {
func (r *Repository) FindByUserID(userID uint) (*UserWallet, error) {
var w UserWallet
err := r.db.Where("user_id = ?", userID).First(&w).Error
return &w, err
if err := r.db.Where("user_id = ?", userID).First(&w).Error; err != nil {
return nil, err
}
return &w, nil
}
func (r *Repository) FindByPubKeyHex(pubKeyHex string) (*UserWallet, error) {
var w UserWallet
err := r.db.Where("pub_key_hex = ?", pubKeyHex).First(&w).Error
return &w, err
if err := r.db.Where("pub_key_hex = ?", pubKeyHex).First(&w).Error; err != nil {
return nil, err
}
return &w, nil
}