fix: 3차 리뷰 LOW — 에러 메시지 일관성, Redis 타임아웃, 입력 검증
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 30s
Server CI/CD / deploy (push) Has been skipped

- 5개 핸들러 err.Error() → 제네릭 메시지 (Login, Refresh, SSAFY, Ticket, BossRaid)
- Redis context.Background() → WithTimeout 5s (10곳)
- SprintMultiplier 범위 검증 추가
- 방어적 문서화 (SSAFY 충돌, zip bomb, body limit prefix, 로그 주입)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 19:05:17 +09:00
parent 9504bf37de
commit 423e2832a0
7 changed files with 51 additions and 20 deletions

View File

@@ -95,7 +95,9 @@ func (s *Service) issueAccessToken(user *User) (string, error) {
}
key := fmt.Sprintf("session:%d", user.ID)
if err := s.rdb.Set(context.Background(), key, tokenStr, expiry).Err(); err != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.rdb.Set(ctx, key, tokenStr, expiry).Err(); err != nil {
return "", fmt.Errorf("세션 저장에 실패했습니다")
}
@@ -119,7 +121,9 @@ func (s *Service) issueRefreshToken(user *User) (string, error) {
}
key := fmt.Sprintf("refresh:%d", user.ID)
if err := s.rdb.Set(context.Background(), key, tokenStr, refreshTokenExpiry).Err(); err != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.rdb.Set(ctx, key, tokenStr, refreshTokenExpiry).Err(); err != nil {
return "", fmt.Errorf("리프레시 토큰 저장에 실패했습니다")
}
@@ -145,7 +149,9 @@ func (s *Service) Refresh(refreshTokenStr string) (newAccessToken, newRefreshTok
// Redis에서 저장된 리프레시 토큰과 비교
key := fmt.Sprintf("refresh:%d", claims.UserID)
stored, err := s.rdb.Get(context.Background(), key).Result()
refreshCtx, refreshCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer refreshCancel()
stored, err := s.rdb.Get(refreshCtx, key).Result()
if err != nil || stored != refreshTokenStr {
return "", "", fmt.Errorf("만료되었거나 유효하지 않은 리프레시 토큰입니다")
}
@@ -171,7 +177,8 @@ func (s *Service) Refresh(refreshTokenStr string) (newAccessToken, newRefreshTok
}
func (s *Service) Logout(userID uint) error {
ctx := context.Background()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sessionKey := fmt.Sprintf("session:%d", userID)
refreshKey := fmt.Sprintf("refresh:%d", userID)
return s.rdb.Del(ctx, sessionKey, refreshKey).Err()
@@ -191,10 +198,11 @@ func (s *Service) DeleteUser(id uint) error {
}
// Clean up Redis sessions for deleted user
ctx := context.Background()
delCtx, delCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer delCancel()
sessionKey := fmt.Sprintf("session:%d", id)
refreshKey := fmt.Sprintf("refresh:%d", id)
s.rdb.Del(ctx, sessionKey, refreshKey)
s.rdb.Del(delCtx, sessionKey, refreshKey)
// TODO: Clean up wallet and profile data via cross-service calls
// (walletCreator/profileCreator are creation-only; deletion callbacks are not yet wired up)
@@ -214,7 +222,8 @@ func (s *Service) CreateLaunchTicket(userID uint) (string, error) {
// Store ticket → userID mapping in Redis with 30s TTL
key := fmt.Sprintf("launch_ticket:%s", ticket)
ctx := context.Background()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.rdb.Set(ctx, key, userID, 30*time.Second).Err(); err != nil {
return "", fmt.Errorf("store ticket: %w", err)
}
@@ -225,7 +234,8 @@ func (s *Service) CreateLaunchTicket(userID uint) (string, error) {
// The ticket is deleted immediately after use (one-time).
func (s *Service) RedeemLaunchTicket(ticket string) (string, error) {
key := fmt.Sprintf("launch_ticket:%s", ticket)
ctx := context.Background()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Atomically get and delete (one-time use)
userIDStr, err := s.rdb.GetDel(ctx, key).Result()
@@ -290,7 +300,8 @@ func (s *Service) GetSSAFYLoginURL() (string, error) {
// Store state in Redis with 5-minute TTL for one-time verification
key := fmt.Sprintf("ssafy_state:%s", state)
ctx := context.Background()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.rdb.Set(ctx, key, "1", 5*time.Minute).Err(); err != nil {
return "", fmt.Errorf("state 저장 실패: %w", err)
}
@@ -379,7 +390,9 @@ func (s *Service) SSAFYLogin(code, state string) (accessToken, refreshToken stri
return "", "", nil, fmt.Errorf("state 파라미터가 필요합니다")
}
stateKey := fmt.Sprintf("ssafy_state:%s", state)
val, err := s.rdb.GetDel(context.Background(), stateKey).Result()
stateCtx, stateCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer stateCancel()
val, err := s.rdb.GetDel(stateCtx, stateKey).Result()
if err != nil || val != "1" {
return "", "", nil, fmt.Errorf("유효하지 않거나 만료된 state 파라미터입니다")
}
@@ -411,6 +424,9 @@ func (s *Service) SSAFYLogin(code, state string) (accessToken, refreshToken stri
ssafyID := userInfo.UserID
// SSAFY ID에서 영문 소문자+숫자만 추출하여 안전한 username 생성
// NOTE: Username collision is handled by the DB unique constraint.
// If collision occurs, the transaction will rollback and return a generic error.
// A retry with random suffix could improve UX but is not critical.
safeID := sanitizeForUsername(ssafyID)
if safeID == "" {
safeID = hex.EncodeToString(randomBytes[:8])
@@ -480,7 +496,9 @@ func (s *Service) VerifyToken(tokenStr string) (string, error) {
}
key := fmt.Sprintf("session:%d", claims.UserID)
stored, err := s.rdb.Get(context.Background(), key).Result()
verifyCtx, verifyCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer verifyCancel()
stored, err := s.rdb.Get(verifyCtx, key).Result()
if err != nil || stored != tokenStr {
return "", fmt.Errorf("만료되었거나 로그아웃된 세션입니다")
}