feat: 코드 리뷰 기반 전면 개선 — 보안, 검증, 테스트, 안정성
Some checks failed
Server CI/CD / lint-and-build (push) Failing after 47s
Server CI/CD / deploy (push) Has been skipped

- 체인 nonce 경쟁 조건 수정 (operatorMu + per-user mutex)
- 등록/SSAFY 원자적 트랜잭션 (wallet+profile 롤백 보장)
- IdempotencyRequired 미들웨어 (SETNX 원자적 클레임)
- 런치 티켓 API (JWT URL 노출 방지)
- HttpOnly 쿠키 refresh token
- SSAFY OAuth state 파라미터 (CSRF 방지)
- Refresh 시 DB 조회로 최신 role 사용
- 공지사항/유저목록 페이지네이션
- BodyLimit 미들웨어 (1MB, upload 제외)
- 입력 검증 강화 (닉네임, 게임데이터, 공지 길이)
- 에러 메시지 내부 정보 노출 방지
- io.LimitReader (RPC 10MB, SSAFY 1MB)
- RequestID 비출력 문자 제거
- 단위 테스트 (auth 11, announcement 9, bossraid 16)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 18:03:25 +09:00
parent cc8368dfba
commit b0de89a18a
25 changed files with 1691 additions and 105 deletions

View File

@@ -150,7 +150,11 @@ func (s *Service) Refresh(refreshTokenStr string) (newAccessToken, newRefreshTok
return "", "", fmt.Errorf("만료되었거나 유효하지 않은 리프레시 토큰입니다")
}
user := &User{ID: claims.UserID, Username: claims.Username, Role: Role(claims.Role)}
// Look up the current user from DB to avoid using stale role from JWT claims
user, dbErr := s.repo.FindByID(claims.UserID)
if dbErr != nil {
return "", "", fmt.Errorf("유저를 찾을 수 없습니다")
}
newAccessToken, err = s.issueAccessToken(user)
if err != nil {
@@ -173,8 +177,8 @@ func (s *Service) Logout(userID uint) error {
return s.rdb.Del(ctx, sessionKey, refreshKey).Err()
}
func (s *Service) GetAllUsers() ([]User, error) {
return s.repo.FindAll()
func (s *Service) GetAllUsers(offset, limit int) ([]User, error) {
return s.repo.FindAll(offset, limit)
}
func (s *Service) UpdateRole(id uint, role Role) error {
@@ -182,7 +186,68 @@ func (s *Service) UpdateRole(id uint, role Role) error {
}
func (s *Service) DeleteUser(id uint) error {
return s.repo.Delete(id)
if err := s.repo.Delete(id); err != nil {
return err
}
// Clean up Redis sessions for deleted user
ctx := context.Background()
sessionKey := fmt.Sprintf("session:%d", id)
refreshKey := fmt.Sprintf("refresh:%d", id)
s.rdb.Del(ctx, 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)
return nil
}
// CreateLaunchTicket generates a one-time ticket that the game launcher
// exchanges for the real JWT. The ticket expires in 30 seconds and can only
// be redeemed once, preventing token exposure in URLs or browser history.
func (s *Service) CreateLaunchTicket(userID uint) (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate ticket: %w", err)
}
ticket := hex.EncodeToString(buf)
// Store ticket → userID mapping in Redis with 30s TTL
key := fmt.Sprintf("launch_ticket:%s", ticket)
ctx := context.Background()
if err := s.rdb.Set(ctx, key, userID, 30*time.Second).Err(); err != nil {
return "", fmt.Errorf("store ticket: %w", err)
}
return ticket, nil
}
// RedeemLaunchTicket exchanges a one-time ticket for the user's access token.
// 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()
// Atomically get and delete (one-time use)
userIDStr, err := s.rdb.GetDel(ctx, key).Result()
if err != nil {
return "", fmt.Errorf("유효하지 않거나 만료된 티켓입니다")
}
var userID uint
if _, err := fmt.Sscanf(userIDStr, "%d", &userID); err != nil {
return "", fmt.Errorf("invalid ticket data")
}
user, err := s.repo.FindByID(userID)
if err != nil {
return "", fmt.Errorf("유저를 찾을 수 없습니다")
}
accessToken, err := s.issueAccessToken(user)
if err != nil {
return "", err
}
return accessToken, nil
}
func (s *Service) Register(username, password string) error {
@@ -193,39 +258,50 @@ func (s *Service) Register(username, password string) error {
if err != nil {
return fmt.Errorf("비밀번호 처리에 실패했습니다")
}
user := &User{
Username: username,
PasswordHash: string(hash),
Role: RoleUser,
}
if err := s.repo.Create(user); err != nil {
return err
}
if s.walletCreator != nil {
if err := s.walletCreator(user.ID); err != nil {
log.Printf("wallet creation failed for user %d: %v — rolling back", user.ID, err)
if delErr := s.repo.Delete(user.ID); delErr != nil {
log.Printf("WARNING: rollback delete also failed for user %d: %v", user.ID, delErr)
return s.repo.Transaction(func(txRepo *Repository) error {
user := &User{Username: username, PasswordHash: string(hash), Role: RoleUser}
if err := txRepo.Create(user); err != nil {
return err
}
if s.walletCreator != nil {
if err := s.walletCreator(user.ID); err != nil {
return fmt.Errorf("wallet creation failed: %w", err)
}
return fmt.Errorf("계정 초기화에 실패했습니다. 잠시 후 다시 시도해주세요")
}
}
if s.profileCreator != nil {
if err := s.profileCreator(user.ID); err != nil {
log.Printf("profile creation failed for user %d: %v", user.ID, err)
if s.profileCreator != nil {
if err := s.profileCreator(user.ID); err != nil {
return fmt.Errorf("profile creation failed: %w", err)
}
}
}
return nil
return nil
})
}
// GetSSAFYLoginURL returns the SSAFY OAuth authorization URL.
func (s *Service) GetSSAFYLoginURL() string {
// GetSSAFYLoginURL returns the SSAFY OAuth authorization URL with a random
// state parameter for CSRF protection. The state is stored in Redis with a
// 5-minute TTL and must be verified in the callback.
func (s *Service) GetSSAFYLoginURL() (string, error) {
stateBytes := make([]byte, 16)
if _, err := rand.Read(stateBytes); err != nil {
return "", fmt.Errorf("state 생성 실패: %w", err)
}
state := hex.EncodeToString(stateBytes)
// Store state in Redis with 5-minute TTL for one-time verification
key := fmt.Sprintf("ssafy_state:%s", state)
ctx := context.Background()
if err := s.rdb.Set(ctx, key, "1", 5*time.Minute).Err(); err != nil {
return "", fmt.Errorf("state 저장 실패: %w", err)
}
params := url.Values{
"client_id": {config.C.SSAFYClientID},
"redirect_uri": {config.C.SSAFYRedirectURI},
"response_type": {"code"},
"state": {state},
}
return "https://project.ssafy.com/oauth/sso-check?" + params.Encode()
return "https://project.ssafy.com/oauth/sso-check?" + params.Encode(), nil
}
// ExchangeSSAFYCode exchanges an authorization code for SSAFY tokens.
@@ -248,7 +324,7 @@ func (s *Service) ExchangeSSAFYCode(code string) (*SSAFYTokenResponse, error) {
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
if err != nil {
return nil, fmt.Errorf("SSAFY 토큰 응답 읽기 실패: %v", err)
}
@@ -279,7 +355,7 @@ func (s *Service) GetSSAFYUserInfo(accessToken string) (*SSAFYUserInfo, error) {
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
if err != nil {
return nil, fmt.Errorf("SSAFY 사용자 정보 응답 읽기 실패: %v", err)
}
@@ -296,7 +372,18 @@ func (s *Service) GetSSAFYUserInfo(accessToken string) (*SSAFYUserInfo, error) {
}
// SSAFYLogin handles the full SSAFY OAuth callback: exchange code, get user info, find or create user, issue tokens.
func (s *Service) SSAFYLogin(code string) (accessToken, refreshToken string, user *User, err error) {
// The state parameter is verified against Redis (one-time use via GetDel) for CSRF protection.
func (s *Service) SSAFYLogin(code, state string) (accessToken, refreshToken string, user *User, err error) {
// Verify CSRF state parameter (one-time use)
if state == "" {
return "", "", nil, fmt.Errorf("state 파라미터가 필요합니다")
}
stateKey := fmt.Sprintf("ssafy_state:%s", state)
val, err := s.rdb.GetDel(context.Background(), stateKey).Result()
if err != nil || val != "1" {
return "", "", nil, fmt.Errorf("유효하지 않거나 만료된 state 파라미터입니다")
}
tokenResp, err := s.ExchangeSSAFYCode(code)
if err != nil {
return "", "", nil, err
@@ -333,7 +420,6 @@ func (s *Service) SSAFYLogin(code string) (accessToken, refreshToken string, use
username = username[:50]
}
var newUserID uint
err = s.repo.Transaction(func(txRepo *Repository) error {
user = &User{
Username: username,
@@ -341,27 +427,26 @@ func (s *Service) SSAFYLogin(code string) (accessToken, refreshToken string, use
Role: RoleUser,
SsafyID: &ssafyID,
}
return txRepo.Create(user)
if err := txRepo.Create(user); err != nil {
return err
}
if s.walletCreator != nil {
if err := s.walletCreator(user.ID); err != nil {
return fmt.Errorf("wallet creation failed: %w", err)
}
}
if s.profileCreator != nil {
if err := s.profileCreator(user.ID); err != nil {
return fmt.Errorf("profile creation failed: %w", err)
}
}
return nil
})
if err != nil {
log.Printf("SSAFY user creation transaction failed: %v", err)
return "", "", nil, fmt.Errorf("계정 생성 실패: %v", err)
}
newUserID = user.ID
if s.walletCreator != nil {
if err := s.walletCreator(newUserID); err != nil {
log.Printf("wallet creation failed for SSAFY user %d: %v — rolling back", newUserID, err)
if delErr := s.repo.Delete(newUserID); delErr != nil {
log.Printf("WARNING: rollback delete also failed for SSAFY user %d: %v", newUserID, delErr)
}
return "", "", nil, fmt.Errorf("계정 초기화에 실패했습니다. 잠시 후 다시 시도해주세요")
}
}
if s.profileCreator != nil {
if err := s.profileCreator(newUserID); err != nil {
log.Printf("profile creation failed for SSAFY user %d: %v", newUserID, err)
}
}
}
accessToken, err = s.issueAccessToken(user)
@@ -414,6 +499,10 @@ func sanitizeForUsername(s string) string {
return b.String()
}
// NOTE: EnsureAdmin does not use a transaction for wallet/profile creation.
// If these fail, the admin user exists without a wallet/profile.
// This is acceptable because EnsureAdmin runs once at startup and failures
// are logged as warnings. A restart will skip user creation (already exists).
func (s *Service) EnsureAdmin(username, password string) error {
if _, err := s.repo.FindByUsername(username); err == nil {
return nil