Files
a301_server/internal/auth/service.go
tolelom 23bec776ab
All checks were successful
Server CI/CD / deploy (push) Successful in 1m36s
fix: 코드 리뷰 기반 보안·안정성 개선 (14건)
- unsafe 타입 단언 → safe assertion (chain handler 11곳, auth Logout)
- Repository 에러 시 nil 반환으로 통일 (chain, auth, announcement)
- string ID → uint 파싱으로 타입 안전성 확보 (auth, announcement)
- CORS AllowHeaders에 Idempotency-Key, X-API-Key 추가
- /verify 엔드포인트 rate limiter 적용
- Redis 호출에 context timeout 적용 (auth, idempotency 미들웨어)
- chain handler 에러 응답에서 내부 정보 노출 방지
- f.Close() 에러 검사 추가 (download service 2곳)
- 공지사항 Delete 404 응답 추가
- 회원가입 롤백 시 Delete 에러 로깅

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:47:00 +09:00

379 lines
11 KiB
Go

package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"a301_server/pkg/config"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
)
const refreshTokenExpiry = 7 * 24 * time.Hour
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type Service struct {
repo *Repository
rdb *redis.Client
walletCreator func(userID uint) error
}
func NewService(repo *Repository, rdb *redis.Client) *Service {
return &Service{repo: repo, rdb: rdb}
}
func (s *Service) SetWalletCreator(fn func(userID uint) error) {
s.walletCreator = fn
}
func (s *Service) Login(username, password string) (accessToken, refreshToken string, user *User, err error) {
user, err = s.repo.FindByUsername(username)
if err != nil {
return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
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
}
func (s *Service) issueAccessToken(user *User) (string, error) {
expiry := time.Duration(config.C.JWTExpiryHours) * time.Hour
claims := &Claims{
UserID: user.ID,
Username: user.Username,
Role: string(user.Role),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString([]byte(config.C.JWTSecret))
if err != nil {
return "", fmt.Errorf("토큰 생성에 실패했습니다")
}
key := fmt.Sprintf("session:%d", user.ID)
if err := s.rdb.Set(context.Background(), key, tokenStr, expiry).Err(); err != nil {
return "", fmt.Errorf("세션 저장에 실패했습니다")
}
return tokenStr, nil
}
func (s *Service) issueRefreshToken(user *User) (string, error) {
claims := &Claims{
UserID: user.ID,
Username: user.Username,
Role: string(user.Role),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenExpiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString([]byte(config.C.RefreshSecret))
if err != nil {
return "", fmt.Errorf("리프레시 토큰 생성에 실패했습니다")
}
key := fmt.Sprintf("refresh:%d", user.ID)
if err := s.rdb.Set(context.Background(), key, tokenStr, refreshTokenExpiry).Err(); err != nil {
return "", fmt.Errorf("리프레시 토큰 저장에 실패했습니다")
}
return tokenStr, nil
}
// Refresh validates a refresh token and issues a new access + refresh token pair (rotation).
func (s *Service) Refresh(refreshTokenStr string) (newAccessToken, newRefreshToken string, err error) {
token, err := jwt.ParseWithClaims(refreshTokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return []byte(config.C.RefreshSecret), nil
})
if err != nil || !token.Valid {
return "", "", fmt.Errorf("유효하지 않은 리프레시 토큰입니다")
}
claims, ok := token.Claims.(*Claims)
if !ok {
return "", "", fmt.Errorf("토큰 파싱 실패")
}
// Redis에서 저장된 리프레시 토큰과 비교
key := fmt.Sprintf("refresh:%d", claims.UserID)
stored, err := s.rdb.Get(context.Background(), key).Result()
if err != nil || stored != refreshTokenStr {
return "", "", fmt.Errorf("만료되었거나 유효하지 않은 리프레시 토큰입니다")
}
user := &User{ID: claims.UserID, Username: claims.Username, Role: Role(claims.Role)}
newAccessToken, err = s.issueAccessToken(user)
if err != nil {
return "", "", err
}
// 리프레시 토큰 로테이션
newRefreshToken, err = s.issueRefreshToken(user)
if err != nil {
return "", "", err
}
return newAccessToken, newRefreshToken, nil
}
func (s *Service) Logout(userID uint) error {
ctx := context.Background()
sessionKey := fmt.Sprintf("session:%d", userID)
refreshKey := fmt.Sprintf("refresh:%d", userID)
return s.rdb.Del(ctx, sessionKey, refreshKey).Err()
}
func (s *Service) GetAllUsers() ([]User, error) {
return s.repo.FindAll()
}
func (s *Service) UpdateRole(id uint, role Role) error {
return s.repo.UpdateRole(id, role)
}
func (s *Service) DeleteUser(id uint) error {
return s.repo.Delete(id)
}
func (s *Service) Register(username, password string) error {
if _, err := s.repo.FindByUsername(username); err == nil {
return fmt.Errorf("이미 사용 중인 아이디입니다")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("비밀번호 처리에 실패했습니다")
}
user := &User{
Username: username,
PasswordHash: string(hash),
Role: RoleUser,
}
if err := s.repo.Create(user); err != nil {
return err
}
if s.walletCreator != nil {
if err := s.walletCreator(user.ID); err != nil {
log.Printf("wallet creation failed for user %d: %v — rolling back", user.ID, err)
if delErr := s.repo.Delete(user.ID); delErr != nil {
log.Printf("WARNING: rollback delete also failed for user %d: %v", user.ID, delErr)
}
return fmt.Errorf("계정 초기화에 실패했습니다. 잠시 후 다시 시도해주세요")
}
}
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)
if delErr := s.repo.Delete(user.ID); delErr != nil {
log.Printf("WARNING: rollback delete also failed for SSAFY user %d: %v", user.ID, delErr)
}
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) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return []byte(config.C.JWTSecret), nil
})
if err != nil || !token.Valid {
return "", fmt.Errorf("유효하지 않은 토큰입니다")
}
claims, ok := token.Claims.(*Claims)
if !ok {
return "", fmt.Errorf("토큰 파싱 실패")
}
key := fmt.Sprintf("session:%d", claims.UserID)
stored, err := s.rdb.Get(context.Background(), key).Result()
if err != nil || stored != tokenStr {
return "", fmt.Errorf("만료되었거나 로그아웃된 세션입니다")
}
return claims.Username, nil
}
func (s *Service) EnsureAdmin(username, password string) error {
if _, err := s.repo.FindByUsername(username); err == nil {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user := &User{
Username: username,
PasswordHash: string(hash),
Role: RoleAdmin,
}
if err := s.repo.Create(user); err != nil {
return err
}
if s.walletCreator != nil {
if err := s.walletCreator(user.ID); err != nil {
log.Printf("WARNING: admin wallet creation failed for user %d: %v", user.ID, err)
}
}
return nil
}