All checks were successful
Server CI/CD / deploy (push) Successful in 7s
- middleware: JWT MapClaims 타입 단언 패닉 → ok 패턴으로 방어 - auth/service: Redis Set 오류 처리, 지갑 생성 실패 시 유저 롤백 - auth/service: EnsureAdmin 지갑 생성 추가, Logout 리프레시 토큰도 삭제 - auth/service: 리프레시 토큰 발급(7일) 및 로테이션, REFRESH_SECRET 분리 - auth/handler: Login 응답에 refreshToken 포함, Refresh 핸들러 추가 - auth/handler: Logout 에러 처리 추가 - download/service: hashGameExeFromZip io.Copy 오류 처리 - download/handler: Content-Disposition mime.FormatMediaType으로 헤더 인젝션 방어 - announcement/handler: Update 빈 body 400 반환 - config: REFRESH_SECRET 환경변수 추가 - routes: POST /api/auth/refresh 엔드포인트 추가 - main: INTERNAL_API_KEY 미설정 시 경고 출력 - .env.example: 누락 환경변수 7개 보완 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
244 lines
7.0 KiB
Go
244 lines
7.0 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"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 string, role Role) error {
|
|
return s.repo.UpdateRole(id, role)
|
|
}
|
|
|
|
func (s *Service) DeleteUser(id string) 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)
|
|
s.repo.Delete(fmt.Sprintf("%d", user.ID))
|
|
return fmt.Errorf("계정 초기화에 실패했습니다. 잠시 후 다시 시도해주세요")
|
|
}
|
|
}
|
|
return 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
|
|
}
|