package auth import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "strings" "time" "gorm.io/gorm" "a301_server/pkg/apperror" "a301_server/pkg/config" "github.com/golang-jwt/jwt/v5" "github.com/redis/go-redis/v9" "golang.org/x/crypto/bcrypt" ) const refreshTokenExpiry = 7 * 24 * time.Hour var ssafyHTTPClient = &http.Client{Timeout: 10 * time.Second} type Claims struct { UserID uint `json:"user_id"` Username string `json:"username"` Role string `json:"role"` jwt.RegisteredClaims } type Service struct { repo *Repository rdb *redis.Client walletCreator func(userID uint) error profileCreator func(userID uint) error } func NewService(repo *Repository, rdb *redis.Client) *Service { return &Service{repo: repo, rdb: rdb} } func (s *Service) SetWalletCreator(fn func(userID uint) error) { s.walletCreator = fn } func (s *Service) SetProfileCreator(fn func(userID uint) error) { s.profileCreator = fn } func (s *Service) Login(username, password string) (accessToken, refreshToken string, user *User, err error) { user, err = s.repo.FindByUsername(username) if err != nil { if err == gorm.ErrRecordNotFound { return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다") } return "", "", nil, fmt.Errorf("로그인 처리 중 오류가 발생했습니다") } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { 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, Username: user.Username, Role: string(user.Role), RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenStr, err := token.SignedString([]byte(config.C.JWTSecret)) if err != nil { return "", fmt.Errorf("토큰 생성에 실패했습니다") } key := fmt.Sprintf("session:%d", user.ID) 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("세션 저장에 실패했습니다") } return tokenStr, nil } 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) 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("리프레시 토큰 저장에 실패했습니다") } 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) 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("만료되었거나 유효하지 않은 리프레시 토큰입니다") } // 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 { return "", "", err } // 리프레시 토큰 로테이션 newRefreshToken, err = s.issueRefreshToken(user) if err != nil { return "", "", err } return newAccessToken, newRefreshToken, nil } func (s *Service) Logout(userID uint) error { 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() } func (s *Service) GetAllUsers(offset, limit int) ([]User, error) { return s.repo.FindAll(offset, limit) } func (s *Service) UpdateRole(id uint, role Role) error { return s.repo.UpdateRole(id, role) } func (s *Service) DeleteUser(id uint) error { if err := s.repo.Delete(id); err != nil { return err } // Clean up Redis sessions for deleted user delCtx, delCancel := context.WithTimeout(context.Background(), 5*time.Second) defer delCancel() sessionKey := fmt.Sprintf("session:%d", id) refreshKey := fmt.Sprintf("refresh:%d", id) if err := s.rdb.Del(delCtx, sessionKey, refreshKey).Err(); err != nil { log.Printf("WARNING: failed to delete Redis sessions for user %d: %v", id, err) } // 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, 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) } 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, 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() 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 { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("비밀번호 처리에 실패했습니다") } return s.repo.Transaction(func(txRepo *Repository) error { user := &User{Username: username, PasswordHash: string(hash), Role: RoleUser} if err := txRepo.Create(user); err != nil { if apperror.IsDuplicateEntry(err) { return apperror.ErrDuplicateUsername } 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 }) } // 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, 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) } 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(), nil } // ExchangeSSAFYCode exchanges an authorization code for SSAFY tokens. func (s *Service) ExchangeSSAFYCode(code string) (*SSAFYTokenResponse, error) { data := url.Values{ "grant_type": {"authorization_code"}, "client_id": {config.C.SSAFYClientID}, "client_secret": {config.C.SSAFYClientSecret}, "redirect_uri": {config.C.SSAFYRedirectURI}, "code": {code}, } resp, err := ssafyHTTPClient.Post( "https://project.ssafy.com/ssafy/oauth2/token", "application/x-www-form-urlencoded;charset=utf-8", strings.NewReader(data.Encode()), ) if err != nil { return nil, fmt.Errorf("SSAFY 토큰 요청 실패: %v", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024)) if err != nil { return nil, fmt.Errorf("SSAFY 토큰 응답 읽기 실패: %v", err) } if resp.StatusCode != http.StatusOK { log.Printf("SSAFY 토큰 발급 실패 (status %d): %s", resp.StatusCode, string(body)) return nil, fmt.Errorf("SSAFY 인증에 실패했습니다") } var tokenResp SSAFYTokenResponse if err := json.Unmarshal(body, &tokenResp); err != nil { return nil, fmt.Errorf("SSAFY 토큰 응답 파싱 실패: %v", err) } return &tokenResp, nil } // GetSSAFYUserInfo fetches user info from SSAFY using an access token. func (s *Service) GetSSAFYUserInfo(accessToken string) (*SSAFYUserInfo, error) { req, err := http.NewRequest("GET", "https://project.ssafy.com/ssafy/resources/userInfo", nil) if err != nil { return nil, fmt.Errorf("SSAFY 사용자 정보 요청 생성 실패: %v", err) } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-type", "application/x-www-form-urlencoded;charset=utf-8") resp, err := ssafyHTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("SSAFY 사용자 정보 요청 실패: %v", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024)) if err != nil { return nil, fmt.Errorf("SSAFY 사용자 정보 응답 읽기 실패: %v", err) } if resp.StatusCode != http.StatusOK { log.Printf("SSAFY 사용자 정보 조회 실패 (status %d): %s", resp.StatusCode, string(body)) return nil, fmt.Errorf("SSAFY 사용자 정보를 가져올 수 없습니다") } var userInfo SSAFYUserInfo if err := json.Unmarshal(body, &userInfo); err != nil { return nil, fmt.Errorf("SSAFY 사용자 정보 파싱 실패: %v", err) } return &userInfo, nil } // SSAFYLogin handles the full SSAFY OAuth callback: exchange code, get user info, find or create user, issue tokens. // 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) 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 파라미터입니다") } tokenResp, err := s.ExchangeSSAFYCode(code) if err != nil { return "", "", nil, err } userInfo, err := s.GetSSAFYUserInfo(tokenResp.AccessToken) if err != nil { return "", "", nil, err } // SSAFY ID로 기존 사용자 조회 user, err = s.repo.FindBySsafyID(userInfo.UserID) if err != nil { // 신규 사용자 자동 가입 randomBytes := make([]byte, 16) if _, err := rand.Read(randomBytes); err != nil { return "", "", nil, fmt.Errorf("보안 난수 생성 실패: %v", err) } randomPassword := hex.EncodeToString(randomBytes) hash, err := bcrypt.GenerateFromPassword([]byte(randomPassword), bcrypt.DefaultCost) if err != nil { return "", "", nil, fmt.Errorf("계정 생성 실패") } ssafyID := userInfo.UserID // SSAFY ID에서 영문 소문자+숫자만 추출하여 안전한 username 생성 safeID := sanitizeForUsername(ssafyID) if safeID == "" { safeID = hex.EncodeToString(randomBytes[:8]) } username := "ssafy_" + safeID if len(username) > 50 { username = username[:50] } // DB unique constraint 충돌 시 랜덤 suffix로 최대 3회 재시도 maxRetries := 3 baseUsername := username for attempt := 0; attempt < maxRetries; attempt++ { if attempt > 0 { suffix := hex.EncodeToString(randomBytes[attempt*2 : attempt*2+4]) username = baseUsername + "_" + suffix if len(username) > 50 { username = username[:50] } } err = s.repo.Transaction(func(txRepo *Repository) error { user = &User{ Username: username, PasswordHash: string(hash), Role: RoleUser, SsafyID: &ssafyID, } 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 { break } log.Printf("SSAFY user creation attempt %d failed: %v", attempt+1, err) } if err != nil { return "", "", nil, fmt.Errorf("계정 생성 실패: %v", err) } } 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 } // VerifyToken validates a JWT and its Redis session, returning (username, error). func (s *Service) VerifyToken(tokenStr string) (string, error) { token, err := jwt.ParseWithClaims(tokenStr, &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.JWTSecret), nil }) if err != nil || !token.Valid { return "", fmt.Errorf("유효하지 않은 토큰입니다") } claims, ok := token.Claims.(*Claims) if !ok { return "", fmt.Errorf("토큰 파싱 실패") } key := fmt.Sprintf("session:%d", claims.UserID) 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("만료되었거나 로그아웃된 세션입니다") } return claims.Username, nil } // sanitizeForUsername strips characters that are not [a-z0-9_-]. func sanitizeForUsername(s string) string { var b strings.Builder for _, c := range strings.ToLower(s) { if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-' { b.WriteRune(c) } } 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). // VerifyPassword checks if the password matches the user's stored hash. func (s *Service) VerifyPassword(userID uint, password string) error { user, err := s.repo.FindByID(userID) if err != nil { return fmt.Errorf("user not found") } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { return fmt.Errorf("invalid password") } return nil } func (s *Service) EnsureAdmin(username, password string) error { if _, err := s.repo.FindByUsername(username); err == nil { return nil } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } 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) } } if s.profileCreator != nil { if err := s.profileCreator(user.ID); err != nil { log.Printf("WARNING: admin profile creation failed for user %d: %v", user.ID, err) } } return nil }