fix: 보안 강화 및 리프레시 토큰 도입
All checks were successful
Server CI/CD / deploy (push) Successful in 7s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 09:51:17 +09:00
parent f8b23e93bf
commit 4843470310
10 changed files with 186 additions and 30 deletions

View File

@@ -10,7 +10,22 @@ REDIS_ADDR=localhost:6379
REDIS_PASSWORD= REDIS_PASSWORD=
JWT_SECRET=your-secret-key-here 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_USERNAME=admin
ADMIN_PASSWORD=admin1234 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=

View File

@@ -41,6 +41,9 @@ func (h *Handler) Update(c *fiber.Ctx) error {
if err := c.BodyParser(&body); err != nil { if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) 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) a, err := h.svc.Update(c.Params("id"), body.Title, body.Content)
if err != nil { if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})

View File

@@ -42,21 +42,43 @@ func (h *Handler) Login(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"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 { if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()})
} }
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"token": tokenStr, "token": accessToken,
"username": user.Username, "refreshToken": refreshToken,
"role": user.Role, "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 { func (h *Handler) Logout(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint) 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": "로그아웃 되었습니다"}) return c.JSON(fiber.Map{"message": "로그아웃 되었습니다"})
} }

View File

@@ -12,6 +12,8 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
const refreshTokenExpiry = 7 * 24 * time.Hour
type Claims struct { type Claims struct {
UserID uint `json:"user_id"` UserID uint `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
@@ -29,22 +31,34 @@ func NewService(repo *Repository, rdb *redis.Client) *Service {
return &Service{repo: repo, rdb: rdb} 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) { func (s *Service) SetWalletCreator(fn func(userID uint) error) {
s.walletCreator = fn s.walletCreator = fn
} }
func (s *Service) Login(username, password string) (string, *User, error) { func (s *Service) Login(username, password string) (accessToken, refreshToken string, user *User, err error) {
user, err := s.repo.FindByUsername(username) user, err = s.repo.FindByUsername(username)
if err != nil { if err != nil {
return "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다") return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
} }
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { 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 expiry := time.Duration(config.C.JWTExpiryHours) * time.Hour
claims := &Claims{ claims := &Claims{
UserID: user.ID, UserID: user.ID,
@@ -58,19 +72,86 @@ func (s *Service) Login(username, password string) (string, *User, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString([]byte(config.C.JWTSecret)) tokenStr, err := token.SignedString([]byte(config.C.JWTSecret))
if err != nil { if err != nil {
return "", nil, fmt.Errorf("토큰 생성에 실패했습니다") return "", fmt.Errorf("토큰 생성에 실패했습니다")
} }
// Redis에 세션 저장 (1계정 1세션)
key := fmt.Sprintf("session:%d", user.ID) 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) { func (s *Service) issueRefreshToken(user *User) (string, error) {
key := fmt.Sprintf("session:%d", userID) claims := &Claims{
s.rdb.Del(context.Background(), key) 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) { func (s *Service) GetAllUsers() ([]User, error) {
@@ -103,7 +184,9 @@ func (s *Service) Register(username, password string) error {
} }
if s.walletCreator != nil { if s.walletCreator != nil {
if err := s.walletCreator(user.ID); err != 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 return nil
@@ -137,15 +220,24 @@ func (s *Service) VerifyToken(tokenStr string) (string, error) {
func (s *Service) EnsureAdmin(username, password string) error { func (s *Service) EnsureAdmin(username, password string) error {
if _, err := s.repo.FindByUsername(username); err == nil { if _, err := s.repo.FindByUsername(username); err == nil {
return nil // 이미 존재하면 스킵 return nil
} }
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return err return err
} }
return s.repo.Create(&User{ user := &User{
Username: username, Username: username,
PasswordHash: string(hash), PasswordHash: string(hash),
Role: RoleAdmin, 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
} }

View File

@@ -1,6 +1,7 @@
package download package download
import ( import (
"mime"
"os" "os"
"strings" "strings"
@@ -50,7 +51,7 @@ func (h *Handler) ServeFile(c *fiber.Ctx) error {
if info != nil && info.FileName != "" { if info != nil && info.FileName != "" {
filename = 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) return c.SendFile(path)
} }

View File

@@ -143,8 +143,11 @@ func hashGameExeFromZip(zipPath string) string {
return "" return ""
} }
h := sha256.New() h := sha256.New()
io.Copy(h, rc) _, err = io.Copy(h, rc)
rc.Close() rc.Close()
if err != nil {
return ""
}
return hex.EncodeToString(h.Sum(nil)) return hex.EncodeToString(h.Sum(nil))
} }
} }

View File

@@ -67,6 +67,10 @@ func main() {
return err return err
}) })
if config.C.InternalAPIKey == "" {
log.Println("WARN: INTERNAL_API_KEY not set — /api/internal/* endpoints are disabled")
}
annRepo := announcement.NewRepository(database.DB) annRepo := announcement.NewRepository(database.DB)
annSvc := announcement.NewService(annRepo) annSvc := announcement.NewService(annRepo)
annHandler := announcement.NewHandler(annSvc) annHandler := announcement.NewHandler(annSvc)

View File

@@ -17,6 +17,7 @@ type Config struct {
RedisAddr string RedisAddr string
RedisPassword string RedisPassword string
JWTSecret string JWTSecret string
RefreshSecret string
JWTExpiryHours int JWTExpiryHours int
AdminUsername string AdminUsername string
AdminPassword string AdminPassword string
@@ -49,6 +50,7 @@ func Load() {
RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"), RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"),
RedisPassword: getEnv("REDIS_PASSWORD", ""), RedisPassword: getEnv("REDIS_PASSWORD", ""),
JWTSecret: getEnv("JWT_SECRET", "secret"), JWTSecret: getEnv("JWT_SECRET", "secret"),
RefreshSecret: getEnv("REFRESH_SECRET", "refresh-secret"),
JWTExpiryHours: hours, JWTExpiryHours: hours,
AdminUsername: getEnv("ADMIN_USERNAME", "admin"), AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
AdminPassword: getEnv("ADMIN_PASSWORD", "admin1234"), AdminPassword: getEnv("ADMIN_PASSWORD", "admin1234"),

View File

@@ -28,10 +28,23 @@ func Auth(c *fiber.Ctx) error {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"}) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
} }
claims := token.Claims.(jwt.MapClaims) claims, ok := token.Claims.(jwt.MapClaims)
userID := uint(claims["user_id"].(float64)) if !ok {
username := claims["username"].(string) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
role := claims["role"].(string) }
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 세션 확인 // Redis 세션 확인
key := fmt.Sprintf("session:%d", userID) key := fmt.Sprintf("session:%d", userID)

View File

@@ -22,6 +22,7 @@ func Register(
a := api.Group("/auth") a := api.Group("/auth")
a.Post("/register", authH.Register) a.Post("/register", authH.Register)
a.Post("/login", authH.Login) a.Post("/login", authH.Login)
a.Post("/refresh", 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)