- 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>
This commit is contained in:
17
.env.example
17
.env.example
@@ -10,7 +10,22 @@ REDIS_ADDR=localhost:6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
JWT_SECRET=your-secret-key-here
|
||||
JWT_EXPIRY_HOURS=24
|
||||
REFRESH_SECRET=your-refresh-secret-key-here
|
||||
JWT_EXPIRY_HOURS=1
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin1234
|
||||
|
||||
BASE_URL=http://localhost:8080
|
||||
GAME_DIR=/data/game
|
||||
|
||||
# Chain integration
|
||||
CHAIN_NODE_URL=http://localhost:8545
|
||||
CHAIN_ID=tolchain-dev
|
||||
# 운영자 지갑 개인키 (hex, 비워두면 mint/reward 불가)
|
||||
OPERATOR_KEY_HEX=
|
||||
# AES-256 암호화 키 - 반드시 64자 hex (32 bytes) 설정 필요
|
||||
WALLET_ENCRYPTION_KEY=
|
||||
|
||||
# 게임 서버 → API 서버 내부 통신용 API 키 (비워두면 /api/internal/* 비활성화)
|
||||
INTERNAL_API_KEY=
|
||||
|
||||
@@ -41,6 +41,9 @@ func (h *Handler) Update(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||
}
|
||||
if body.Title == "" && body.Content == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "수정할 내용을 입력해주세요"})
|
||||
}
|
||||
a, err := h.svc.Update(c.Params("id"), body.Title, body.Content)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
|
||||
|
||||
@@ -42,21 +42,43 @@ func (h *Handler) Login(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"})
|
||||
}
|
||||
|
||||
tokenStr, user, err := h.svc.Login(req.Username, req.Password)
|
||||
accessToken, refreshToken, user, err := h.svc.Login(req.Username, req.Password)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"token": tokenStr,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
"token": accessToken,
|
||||
"refreshToken": refreshToken,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Refresh(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil || req.RefreshToken == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "refreshToken 필드가 필요합니다"})
|
||||
}
|
||||
|
||||
newAccessToken, newRefreshToken, err := h.svc.Refresh(req.RefreshToken)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"token": newAccessToken,
|
||||
"refreshToken": newRefreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userID").(uint)
|
||||
h.svc.Logout(userID)
|
||||
if err := h.svc.Logout(userID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "로그아웃 처리 중 오류가 발생했습니다"})
|
||||
}
|
||||
return c.JSON(fiber.Map{"message": "로그아웃 되었습니다"})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const refreshTokenExpiry = 7 * 24 * time.Hour
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
@@ -29,22 +31,34 @@ func NewService(repo *Repository, rdb *redis.Client) *Service {
|
||||
return &Service{repo: repo, rdb: rdb}
|
||||
}
|
||||
|
||||
// SetWalletCreator sets the callback invoked after user registration
|
||||
// to create a blockchain wallet.
|
||||
func (s *Service) SetWalletCreator(fn func(userID uint) error) {
|
||||
s.walletCreator = fn
|
||||
}
|
||||
|
||||
func (s *Service) Login(username, password string) (string, *User, error) {
|
||||
user, err := s.repo.FindByUsername(username)
|
||||
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("아이디 또는 비밀번호가 올바르지 않습니다")
|
||||
return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
|
||||
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,
|
||||
@@ -58,19 +72,86 @@ func (s *Service) Login(username, password string) (string, *User, error) {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenStr, err := token.SignedString([]byte(config.C.JWTSecret))
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("토큰 생성에 실패했습니다")
|
||||
return "", fmt.Errorf("토큰 생성에 실패했습니다")
|
||||
}
|
||||
|
||||
// Redis에 세션 저장 (1계정 1세션)
|
||||
key := fmt.Sprintf("session:%d", user.ID)
|
||||
s.rdb.Set(context.Background(), key, tokenStr, expiry)
|
||||
if err := s.rdb.Set(context.Background(), key, tokenStr, expiry).Err(); err != nil {
|
||||
return "", fmt.Errorf("세션 저장에 실패했습니다")
|
||||
}
|
||||
|
||||
return tokenStr, user, nil
|
||||
return tokenStr, nil
|
||||
}
|
||||
|
||||
func (s *Service) Logout(userID uint) {
|
||||
key := fmt.Sprintf("session:%d", userID)
|
||||
s.rdb.Del(context.Background(), key)
|
||||
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) {
|
||||
@@ -103,7 +184,9 @@ func (s *Service) Register(username, password string) error {
|
||||
}
|
||||
if s.walletCreator != nil {
|
||||
if err := s.walletCreator(user.ID); err != nil {
|
||||
log.Printf("WARNING: wallet creation failed for user %d: %v", user.ID, err)
|
||||
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
|
||||
@@ -137,15 +220,24 @@ func (s *Service) VerifyToken(tokenStr string) (string, error) {
|
||||
|
||||
func (s *Service) EnsureAdmin(username, password string) error {
|
||||
if _, err := s.repo.FindByUsername(username); err == nil {
|
||||
return nil // 이미 존재하면 스킵
|
||||
return nil
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Create(&User{
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -50,7 +51,7 @@ func (h *Handler) ServeFile(c *fiber.Ctx) error {
|
||||
if info != nil && info.FileName != "" {
|
||||
filename = info.FileName
|
||||
}
|
||||
c.Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
c.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
|
||||
return c.SendFile(path)
|
||||
}
|
||||
|
||||
|
||||
@@ -143,8 +143,11 @@ func hashGameExeFromZip(zipPath string) string {
|
||||
return ""
|
||||
}
|
||||
h := sha256.New()
|
||||
io.Copy(h, rc)
|
||||
_, err = io.Copy(h, rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
}
|
||||
|
||||
4
main.go
4
main.go
@@ -67,6 +67,10 @@ func main() {
|
||||
return err
|
||||
})
|
||||
|
||||
if config.C.InternalAPIKey == "" {
|
||||
log.Println("WARN: INTERNAL_API_KEY not set — /api/internal/* endpoints are disabled")
|
||||
}
|
||||
|
||||
annRepo := announcement.NewRepository(database.DB)
|
||||
annSvc := announcement.NewService(annRepo)
|
||||
annHandler := announcement.NewHandler(annSvc)
|
||||
|
||||
@@ -17,6 +17,7 @@ type Config struct {
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
JWTSecret string
|
||||
RefreshSecret string
|
||||
JWTExpiryHours int
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
@@ -49,6 +50,7 @@ func Load() {
|
||||
RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"),
|
||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||
JWTSecret: getEnv("JWT_SECRET", "secret"),
|
||||
RefreshSecret: getEnv("REFRESH_SECRET", "refresh-secret"),
|
||||
JWTExpiryHours: hours,
|
||||
AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
|
||||
AdminPassword: getEnv("ADMIN_PASSWORD", "admin1234"),
|
||||
|
||||
@@ -28,10 +28,23 @@ func Auth(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
||||
}
|
||||
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
userID := uint(claims["user_id"].(float64))
|
||||
username := claims["username"].(string)
|
||||
role := claims["role"].(string)
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
||||
}
|
||||
userIDFloat, ok := claims["user_id"].(float64)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
||||
}
|
||||
username, ok := claims["username"].(string)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
||||
}
|
||||
role, ok := claims["role"].(string)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
||||
}
|
||||
userID := uint(userIDFloat)
|
||||
|
||||
// Redis 세션 확인
|
||||
key := fmt.Sprintf("session:%d", userID)
|
||||
|
||||
@@ -22,6 +22,7 @@ func Register(
|
||||
a := api.Group("/auth")
|
||||
a.Post("/register", authH.Register)
|
||||
a.Post("/login", authH.Login)
|
||||
a.Post("/refresh", authH.Refresh)
|
||||
a.Post("/logout", middleware.Auth, authH.Logout)
|
||||
a.Post("/verify", authH.VerifyToken)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user