feat: SSAFY OAuth 2.0 로그인 구현
All checks were successful
Server CI/CD / deploy (push) Successful in 26s

SSAFY 인증 서버를 통한 소셜 로그인 기능 추가.
인가 코드 교환, 사용자 정보 조회, 자동 회원가입 처리.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 23:54:22 +09:00
parent 26876ba8ca
commit 0ce39a48b9
7 changed files with 196 additions and 0 deletions

View File

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

View File

@@ -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": "유저 삭제에 실패했습니다"})

View File

@@ -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"`
}

View File

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

View File

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

View File

@@ -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", ""),
}
}

View File

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