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

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