From 48434703107d60dd5be8160b146f26f26a4746a8 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Fri, 6 Mar 2026 09:51:17 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B3=B4=EC=95=88=20=EA=B0=95=ED=99=94?= =?UTF-8?q?=20=EB=B0=8F=20=EB=A6=AC=ED=94=84=EB=A0=88=EC=8B=9C=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - middleware: JWT MapClaims 타입 단언 패닉 → ok 패턴으로 방어 - auth/service: Redis Set 오류 처리, 지갑 생성 실패 시 유저 롤백 - auth/service: EnsureAdmin 지갑 생성 추가, Logout 리프레시 토큰도 삭제 - auth/service: 리프레시 토큰 발급(7일) 및 로테이션, REFRESH_SECRET 분리 - auth/handler: Login 응답에 refreshToken 포함, Refresh 핸들러 추가 - auth/handler: Logout 에러 처리 추가 - download/service: hashGameExeFromZip io.Copy 오류 처리 - download/handler: Content-Disposition mime.FormatMediaType으로 헤더 인젝션 방어 - announcement/handler: Update 빈 body 400 반환 - config: REFRESH_SECRET 환경변수 추가 - routes: POST /api/auth/refresh 엔드포인트 추가 - main: INTERNAL_API_KEY 미설정 시 경고 출력 - .env.example: 누락 환경변수 7개 보완 Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 19 ++++- internal/announcement/handler.go | 3 + internal/auth/handler.go | 32 ++++++-- internal/auth/service.go | 126 ++++++++++++++++++++++++++----- internal/download/handler.go | 3 +- internal/download/service.go | 5 +- main.go | 4 + pkg/config/config.go | 2 + pkg/middleware/auth.go | 21 +++++- routes/routes.go | 1 + 10 files changed, 186 insertions(+), 30 deletions(-) diff --git a/.env.example b/.env.example index e0b2300..3e13741 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,22 @@ REDIS_ADDR=localhost:6379 REDIS_PASSWORD= JWT_SECRET=your-secret-key-here -JWT_EXPIRY_HOURS=24 +REFRESH_SECRET=your-refresh-secret-key-here +JWT_EXPIRY_HOURS=1 ADMIN_USERNAME=admin -ADMIN_PASSWORD=admin1234 \ No newline at end of file +ADMIN_PASSWORD=admin1234 + +BASE_URL=http://localhost:8080 +GAME_DIR=/data/game + +# Chain integration +CHAIN_NODE_URL=http://localhost:8545 +CHAIN_ID=tolchain-dev +# 운영자 지갑 개인키 (hex, 비워두면 mint/reward 불가) +OPERATOR_KEY_HEX= +# AES-256 암호화 키 - 반드시 64자 hex (32 bytes) 설정 필요 +WALLET_ENCRYPTION_KEY= + +# 게임 서버 → API 서버 내부 통신용 API 키 (비워두면 /api/internal/* 비활성화) +INTERNAL_API_KEY= diff --git a/internal/announcement/handler.go b/internal/announcement/handler.go index 0026011..2895df6 100644 --- a/internal/announcement/handler.go +++ b/internal/announcement/handler.go @@ -41,6 +41,9 @@ func (h *Handler) Update(c *fiber.Ctx) error { if err := c.BodyParser(&body); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) } + if body.Title == "" && body.Content == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "수정할 내용을 입력해주세요"}) + } a, err := h.svc.Update(c.Params("id"), body.Title, body.Content) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 07e263f..4536613 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -42,21 +42,43 @@ func (h *Handler) Login(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"}) } - tokenStr, user, err := h.svc.Login(req.Username, req.Password) + accessToken, refreshToken, user, err := h.svc.Login(req.Username, req.Password) if err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{ - "token": tokenStr, - "username": user.Username, - "role": user.Role, + "token": accessToken, + "refreshToken": refreshToken, + "username": user.Username, + "role": user.Role, + }) +} + +func (h *Handler) Refresh(c *fiber.Ctx) error { + var req struct { + RefreshToken string `json:"refreshToken"` + } + if err := c.BodyParser(&req); err != nil || req.RefreshToken == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "refreshToken 필드가 필요합니다"}) + } + + newAccessToken, newRefreshToken, err := h.svc.Refresh(req.RefreshToken) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{ + "token": newAccessToken, + "refreshToken": newRefreshToken, }) } func (h *Handler) Logout(c *fiber.Ctx) error { userID := c.Locals("userID").(uint) - h.svc.Logout(userID) + if err := h.svc.Logout(userID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "로그아웃 처리 중 오류가 발생했습니다"}) + } return c.JSON(fiber.Map{"message": "로그아웃 되었습니다"}) } diff --git a/internal/auth/service.go b/internal/auth/service.go index 66c1b1d..a9bf222 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -12,6 +12,8 @@ import ( "golang.org/x/crypto/bcrypt" ) +const refreshTokenExpiry = 7 * 24 * time.Hour + type Claims struct { UserID uint `json:"user_id"` Username string `json:"username"` @@ -29,22 +31,34 @@ func NewService(repo *Repository, rdb *redis.Client) *Service { return &Service{repo: repo, rdb: rdb} } -// SetWalletCreator sets the callback invoked after user registration -// to create a blockchain wallet. func (s *Service) SetWalletCreator(fn func(userID uint) error) { s.walletCreator = fn } -func (s *Service) Login(username, password string) (string, *User, error) { - user, err := s.repo.FindByUsername(username) +func (s *Service) Login(username, password string) (accessToken, refreshToken string, user *User, err error) { + user, err = s.repo.FindByUsername(username) if err != nil { - return "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다") + return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다") } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { - return "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다") + return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다") } + accessToken, err = s.issueAccessToken(user) + if err != nil { + return "", "", nil, err + } + + refreshToken, err = s.issueRefreshToken(user) + if err != nil { + return "", "", nil, err + } + + return accessToken, refreshToken, user, nil +} + +func (s *Service) issueAccessToken(user *User) (string, error) { expiry := time.Duration(config.C.JWTExpiryHours) * time.Hour claims := &Claims{ UserID: user.ID, @@ -58,19 +72,86 @@ func (s *Service) Login(username, password string) (string, *User, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenStr, err := token.SignedString([]byte(config.C.JWTSecret)) if err != nil { - return "", nil, fmt.Errorf("토큰 생성에 실패했습니다") + return "", fmt.Errorf("토큰 생성에 실패했습니다") } - // Redis에 세션 저장 (1계정 1세션) key := fmt.Sprintf("session:%d", user.ID) - s.rdb.Set(context.Background(), key, tokenStr, expiry) + if err := s.rdb.Set(context.Background(), key, tokenStr, expiry).Err(); err != nil { + return "", fmt.Errorf("세션 저장에 실패했습니다") + } - return tokenStr, user, nil + return tokenStr, nil } -func (s *Service) Logout(userID uint) { - key := fmt.Sprintf("session:%d", userID) - s.rdb.Del(context.Background(), key) +func (s *Service) issueRefreshToken(user *User) (string, error) { + claims := &Claims{ + UserID: user.ID, + Username: user.Username, + Role: string(user.Role), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenExpiry)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, err := token.SignedString([]byte(config.C.RefreshSecret)) + if err != nil { + return "", fmt.Errorf("리프레시 토큰 생성에 실패했습니다") + } + + key := fmt.Sprintf("refresh:%d", user.ID) + if err := s.rdb.Set(context.Background(), key, tokenStr, refreshTokenExpiry).Err(); err != nil { + return "", fmt.Errorf("리프레시 토큰 저장에 실패했습니다") + } + + return tokenStr, nil +} + +// Refresh validates a refresh token and issues a new access + refresh token pair (rotation). +func (s *Service) Refresh(refreshTokenStr string) (newAccessToken, newRefreshToken string, err error) { + token, err := jwt.ParseWithClaims(refreshTokenStr, &Claims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(config.C.RefreshSecret), nil + }) + if err != nil || !token.Valid { + return "", "", fmt.Errorf("유효하지 않은 리프레시 토큰입니다") + } + + claims, ok := token.Claims.(*Claims) + if !ok { + return "", "", fmt.Errorf("토큰 파싱 실패") + } + + // Redis에서 저장된 리프레시 토큰과 비교 + key := fmt.Sprintf("refresh:%d", claims.UserID) + stored, err := s.rdb.Get(context.Background(), key).Result() + if err != nil || stored != refreshTokenStr { + return "", "", fmt.Errorf("만료되었거나 유효하지 않은 리프레시 토큰입니다") + } + + user := &User{ID: claims.UserID, Username: claims.Username, Role: Role(claims.Role)} + + newAccessToken, err = s.issueAccessToken(user) + if err != nil { + return "", "", err + } + + // 리프레시 토큰 로테이션 + newRefreshToken, err = s.issueRefreshToken(user) + if err != nil { + return "", "", err + } + + return newAccessToken, newRefreshToken, nil +} + +func (s *Service) Logout(userID uint) error { + ctx := context.Background() + sessionKey := fmt.Sprintf("session:%d", userID) + refreshKey := fmt.Sprintf("refresh:%d", userID) + return s.rdb.Del(ctx, sessionKey, refreshKey).Err() } func (s *Service) GetAllUsers() ([]User, error) { @@ -103,7 +184,9 @@ func (s *Service) Register(username, password string) error { } if s.walletCreator != nil { if err := s.walletCreator(user.ID); err != nil { - log.Printf("WARNING: wallet creation failed for user %d: %v", user.ID, err) + log.Printf("wallet creation failed for user %d: %v — rolling back", user.ID, err) + s.repo.Delete(fmt.Sprintf("%d", user.ID)) + return fmt.Errorf("계정 초기화에 실패했습니다. 잠시 후 다시 시도해주세요") } } return nil @@ -137,15 +220,24 @@ func (s *Service) VerifyToken(tokenStr string) (string, error) { func (s *Service) EnsureAdmin(username, password string) error { if _, err := s.repo.FindByUsername(username); err == nil { - return nil // 이미 존재하면 스킵 + return nil } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } - return s.repo.Create(&User{ + user := &User{ Username: username, PasswordHash: string(hash), Role: RoleAdmin, - }) + } + if err := s.repo.Create(user); err != nil { + return err + } + if s.walletCreator != nil { + if err := s.walletCreator(user.ID); err != nil { + log.Printf("WARNING: admin wallet creation failed for user %d: %v", user.ID, err) + } + } + return nil } diff --git a/internal/download/handler.go b/internal/download/handler.go index bb52267..89b3744 100644 --- a/internal/download/handler.go +++ b/internal/download/handler.go @@ -1,6 +1,7 @@ package download import ( + "mime" "os" "strings" @@ -50,7 +51,7 @@ func (h *Handler) ServeFile(c *fiber.Ctx) error { if info != nil && info.FileName != "" { filename = info.FileName } - c.Set("Content-Disposition", `attachment; filename="`+filename+`"`) + c.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) return c.SendFile(path) } diff --git a/internal/download/service.go b/internal/download/service.go index 5211e65..36547c4 100644 --- a/internal/download/service.go +++ b/internal/download/service.go @@ -143,8 +143,11 @@ func hashGameExeFromZip(zipPath string) string { return "" } h := sha256.New() - io.Copy(h, rc) + _, err = io.Copy(h, rc) rc.Close() + if err != nil { + return "" + } return hex.EncodeToString(h.Sum(nil)) } } diff --git a/main.go b/main.go index c36bcf5..5b3807f 100644 --- a/main.go +++ b/main.go @@ -67,6 +67,10 @@ func main() { return err }) + if config.C.InternalAPIKey == "" { + log.Println("WARN: INTERNAL_API_KEY not set — /api/internal/* endpoints are disabled") + } + annRepo := announcement.NewRepository(database.DB) annSvc := announcement.NewService(annRepo) annHandler := announcement.NewHandler(annSvc) diff --git a/pkg/config/config.go b/pkg/config/config.go index f1177e0..1a392e5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -17,6 +17,7 @@ type Config struct { RedisAddr string RedisPassword string JWTSecret string + RefreshSecret string JWTExpiryHours int AdminUsername string AdminPassword string @@ -49,6 +50,7 @@ func Load() { RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"), RedisPassword: getEnv("REDIS_PASSWORD", ""), JWTSecret: getEnv("JWT_SECRET", "secret"), + RefreshSecret: getEnv("REFRESH_SECRET", "refresh-secret"), JWTExpiryHours: hours, AdminUsername: getEnv("ADMIN_USERNAME", "admin"), AdminPassword: getEnv("ADMIN_PASSWORD", "admin1234"), diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 45e6b37..cce2409 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -28,10 +28,23 @@ func Auth(c *fiber.Ctx) error { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"}) } - claims := token.Claims.(jwt.MapClaims) - userID := uint(claims["user_id"].(float64)) - username := claims["username"].(string) - role := claims["role"].(string) + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"}) + } + userIDFloat, ok := claims["user_id"].(float64) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"}) + } + username, ok := claims["username"].(string) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"}) + } + role, ok := claims["role"].(string) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"}) + } + userID := uint(userIDFloat) // Redis 세션 확인 key := fmt.Sprintf("session:%d", userID) diff --git a/routes/routes.go b/routes/routes.go index 3fdebb3..0c0318b 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -22,6 +22,7 @@ func Register( a := api.Group("/auth") a.Post("/register", authH.Register) a.Post("/login", authH.Login) + a.Post("/refresh", authH.Refresh) a.Post("/logout", middleware.Auth, authH.Logout) a.Post("/verify", authH.VerifyToken)