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