fix: 보안·안정성·동시성 개선 3차
All checks were successful
Server CI/CD / deploy (push) Successful in 1m31s

- 입력 검증 강화 (로그인/체인 핸들러 전체)
- boss raid 비관적 잠금으로 동시성 문제 해결
- SSAFY 사용자명 sanitize + 트랜잭션 처리
- constant-time API 키 비교, 보안 헤더, graceful shutdown
- 안전하지 않은 기본값 경고 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 21:40:06 +09:00
parent cc751653c4
commit d597ef2d46
11 changed files with 247 additions and 97 deletions

View File

@@ -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

View File

@@ -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.