From 0ce39a48b9a281734d7f7896d41316af28a6f57d Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 11 Mar 2026 23:54:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SSAFY=20OAuth=202.0=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSAFY 인증 서버를 통한 소셜 로그인 기능 추가. 인가 코드 교환, 사용자 정보 조회, 자동 회원가입 처리. Co-Authored-By: Claude Opus 4.6 --- .env.example | 5 ++ internal/auth/handler.go | 26 +++++++ internal/auth/model.go | 17 +++++ internal/auth/repository.go | 6 ++ internal/auth/service.go | 131 ++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 9 +++ routes/routes.go | 2 + 7 files changed, 196 insertions(+) diff --git a/.env.example b/.env.example index 3e13741..9ec8989 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,8 @@ WALLET_ENCRYPTION_KEY= # 게임 서버 → API 서버 내부 통신용 API 키 (비워두면 /api/internal/* 비활성화) INTERNAL_API_KEY= + +# SSAFY OAuth 2.0 +SSAFY_CLIENT_ID= +SSAFY_CLIENT_SECRET= +SSAFY_REDIRECT_URI= diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 94842b8..3fe23ca 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -127,6 +127,32 @@ func (h *Handler) VerifyToken(c *fiber.Ctx) error { }) } +func (h *Handler) SSAFYLoginURL(c *fiber.Ctx) error { + loginURL := h.svc.GetSSAFYLoginURL() + return c.JSON(fiber.Map{"url": loginURL}) +} + +func (h *Handler) SSAFYCallback(c *fiber.Ctx) error { + var req struct { + Code string `json:"code"` + } + if err := c.BodyParser(&req); err != nil || req.Code == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "인가 코드가 필요합니다"}) + } + + accessToken, refreshToken, user, err := h.svc.SSAFYLogin(req.Code) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{ + "token": accessToken, + "refreshToken": refreshToken, + "username": user.Username, + "role": user.Role, + }) +} + func (h *Handler) DeleteUser(c *fiber.Ctx) error { if err := h.svc.DeleteUser(c.Params("id")); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "유저 삭제에 실패했습니다"}) diff --git a/internal/auth/model.go b/internal/auth/model.go index f859d2e..19cea53 100644 --- a/internal/auth/model.go +++ b/internal/auth/model.go @@ -21,4 +21,21 @@ type User struct { Username string `json:"username" gorm:"type:varchar(100);uniqueIndex;not null"` PasswordHash string `json:"-" gorm:"not null"` Role Role `json:"role" gorm:"default:'user'"` + SsafyID *string `json:"ssafyId,omitempty" gorm:"type:varchar(100);uniqueIndex"` +} + +// SSAFY OAuth 응답 구조체 +type SSAFYTokenResponse struct { + TokenType string `json:"token_type"` + AccessToken string `json:"access_token"` + Scope string `json:"scope"` + ExpiresIn string `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + RefreshTokenExpiresIn int `json:"refresh_token_expires_in"` +} + +type SSAFYUserInfo struct { + UserID string `json:"userId"` + Email string `json:"email"` + Name string `json:"name"` } diff --git a/internal/auth/repository.go b/internal/auth/repository.go index 545d349..85c999c 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -39,3 +39,9 @@ func (r *Repository) UpdateRole(id string, role Role) error { func (r *Repository) Delete(id string) error { return r.db.Delete(&User{}, id).Error } + +func (r *Repository) FindBySsafyID(ssafyID string) (*User, error) { + var user User + err := r.db.Where("ssafy_id = ?", ssafyID).First(&user).Error + return &user, err +} diff --git a/internal/auth/service.go b/internal/auth/service.go index a9bf222..33fdf65 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -2,8 +2,15 @@ package auth import ( "context" + "crypto/rand" + "encoding/hex" + "encoding/json" "fmt" + "io" "log" + "net/http" + "net/url" + "strings" "time" "a301_server/pkg/config" @@ -192,6 +199,130 @@ func (s *Service) Register(username, password string) error { return nil } +// GetSSAFYLoginURL returns the SSAFY OAuth authorization URL. +func (s *Service) GetSSAFYLoginURL() string { + params := url.Values{ + "client_id": {config.C.SSAFYClientID}, + "redirect_uri": {config.C.SSAFYRedirectURI}, + "response_type": {"code"}, + } + return "https://project.ssafy.com/oauth/sso-check?" + params.Encode() +} + +// 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 := http.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, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("SSAFY 토큰 발급 실패 (status %d): %s", resp.StatusCode, string(body)) + } + + 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, _ := http.NewRequest("GET", "https://project.ssafy.com/ssafy/resources/userInfo", nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-type", "application/x-www-form-urlencoded;charset=utf-8") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("SSAFY 사용자 정보 요청 실패: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("SSAFY 사용자 정보 조회 실패 (status %d): %s", resp.StatusCode, string(body)) + } + + 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. +func (s *Service) SSAFYLogin(code string) (accessToken, refreshToken string, user *User, err error) { + 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) + rand.Read(randomBytes) + randomPassword := hex.EncodeToString(randomBytes) + + hash, err := bcrypt.GenerateFromPassword([]byte(randomPassword), bcrypt.DefaultCost) + if err != nil { + return "", "", nil, fmt.Errorf("계정 생성 실패") + } + + ssafyID := userInfo.UserID + username := "ssafy_" + ssafyID + user = &User{ + Username: username, + PasswordHash: string(hash), + Role: RoleUser, + SsafyID: &ssafyID, + } + if err := s.repo.Create(user); err != nil { + return "", "", nil, fmt.Errorf("계정 생성 실패: %v", err) + } + if s.walletCreator != nil { + if err := s.walletCreator(user.ID); err != nil { + log.Printf("wallet creation failed for SSAFY user %d: %v — rolling back", user.ID, err) + s.repo.Delete(fmt.Sprintf("%d", user.ID)) + 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 +} + // 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) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 1a392e5..e6f17d9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -32,6 +32,11 @@ type Config struct { // Server-to-server auth InternalAPIKey string + + // SSAFY OAuth 2.0 + SSAFYClientID string + SSAFYClientSecret string + SSAFYRedirectURI string } var C Config @@ -63,6 +68,10 @@ func Load() { WalletEncryptionKey: getEnv("WALLET_ENCRYPTION_KEY", ""), InternalAPIKey: getEnv("INTERNAL_API_KEY", ""), + + SSAFYClientID: getEnv("SSAFY_CLIENT_ID", ""), + SSAFYClientSecret: getEnv("SSAFY_CLIENT_SECRET", ""), + SSAFYRedirectURI: getEnv("SSAFY_REDIRECT_URI", ""), } } diff --git a/routes/routes.go b/routes/routes.go index c2db1ea..2026027 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -27,6 +27,8 @@ func Register( a.Post("/refresh", authLimiter, authH.Refresh) a.Post("/logout", middleware.Auth, authH.Logout) a.Post("/verify", authH.VerifyToken) + a.Get("/ssafy/login", authH.SSAFYLoginURL) + a.Post("/ssafy/callback", authLimiter, authH.SSAFYCallback) // Users (admin only) u := api.Group("/users", middleware.Auth, middleware.AdminOnly)