feat: SSAFY OAuth 2.0 로그인 구현
All checks were successful
Server CI/CD / deploy (push) Successful in 26s
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user