feat: ticket redeem 인증 + 토큰 이중 전달 + 보안/품질 개선
- launch ticket을 서버에서 redeem하여 새 JWT 획득 (토큰 수명 문제 해결) - 게임에 커맨드라인 -token + 환경변수 A301_TOKEN 이중 전달 - fileHash 빈 문자열 이중 방어 (변조된 게임 실행 차단) - Win32 API 반환값 검증 (RegisterClassEx, CreateWindowEx) - 버전 ldflags 주입 지원 (var version = "dev") Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
71
main.go
71
main.go
@@ -24,10 +24,14 @@ import (
|
|||||||
"golang.org/x/sys/windows/registry"
|
"golang.org/x/sys/windows/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// version is set at build time via -ldflags "-X main.version=x.y.z"
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
protocolName = "a301"
|
protocolName = "a301"
|
||||||
gameExeName = "A301.exe"
|
gameExeName = "A301.exe"
|
||||||
serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info"
|
serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info"
|
||||||
|
redeemTicketURL = "https://a301.api.tolelom.xyz/api/auth/redeem-ticket"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxDownloadSize = 2 << 30 // 2GB
|
const maxDownloadSize = 2 << 30 // 2GB
|
||||||
@@ -241,6 +245,7 @@ func createUIFont(pointSize int, dpi uint32, bold bool) uintptr {
|
|||||||
face, _ := windows.UTF16FromString("Segoe UI")
|
face, _ := windows.UTF16FromString("Segoe UI")
|
||||||
copy(lf.lfFaceName[:], face)
|
copy(lf.lfFaceName[:], face)
|
||||||
font, _, _ := createFontIndirectWProc.Call(uintptr(unsafe.Pointer(&lf)))
|
font, _, _ := createFontIndirectWProc.Call(uintptr(unsafe.Pointer(&lf)))
|
||||||
|
// font가 0이면 시스템 기본 폰트가 사용됨 (WM_SETFONT에 0 전달 시 기본값)
|
||||||
return font
|
return font
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +342,10 @@ func downloadWithProgress(downloadURL, destDir string) error {
|
|||||||
lpszClassName: className,
|
lpszClassName: className,
|
||||||
hbrBackground: hBrushBg,
|
hbrBackground: hBrushBg,
|
||||||
}
|
}
|
||||||
registerClassExWProc.Call(uintptr(unsafe.Pointer(&wc)))
|
atom, _, _ := registerClassExWProc.Call(uintptr(unsafe.Pointer(&wc)))
|
||||||
|
if atom == 0 {
|
||||||
|
return fmt.Errorf("윈도우 클래스 등록 실패")
|
||||||
|
}
|
||||||
|
|
||||||
screenW, _, _ := getSystemMetricsProc.Call(smCxScreen)
|
screenW, _, _ := getSystemMetricsProc.Call(smCxScreen)
|
||||||
screenH, _, _ := getSystemMetricsProc.Call(smCyScreen)
|
screenH, _, _ := getSystemMetricsProc.Call(smCyScreen)
|
||||||
@@ -356,6 +364,9 @@ func downloadWithProgress(downloadURL, destDir string) error {
|
|||||||
x, y, winW, winH,
|
x, y, winW, winH,
|
||||||
0, 0, hInstance, 0,
|
0, 0, hInstance, 0,
|
||||||
)
|
)
|
||||||
|
if hwnd == 0 {
|
||||||
|
return fmt.Errorf("다운로드 창 생성 실패")
|
||||||
|
}
|
||||||
|
|
||||||
titleFont := createUIFont(13, dpi, true)
|
titleFont := createUIFont(13, dpi, true)
|
||||||
defer deleteObjectProc.Call(titleFont)
|
defer deleteObjectProc.Call(titleFont)
|
||||||
@@ -788,6 +799,34 @@ func fetchServerInfo() (*downloadInfo, error) {
|
|||||||
return nil, fmt.Errorf("서버 연결 실패 (%d회 재시도): %w", maxRetries, lastErr)
|
return nil, fmt.Errorf("서버 연결 실패 (%d회 재시도): %w", maxRetries, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// redeemTicket exchanges a one-time launch ticket for a fresh JWT access token.
|
||||||
|
// The ticket has a 30-second TTL on the server and can only be used once.
|
||||||
|
// 재시도 불필요 — ticket은 일회용이므로 한 번 사용(또는 만료)되면 소멸.
|
||||||
|
func redeemTicket(ticket string) (string, error) {
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
body := fmt.Sprintf(`{"ticket":"%s"}`, ticket)
|
||||||
|
resp, err := client.Post(redeemTicketURL, "application/json", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("서버에 연결할 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("런처 인증에 실패했습니다 (HTTP %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
|
||||||
|
return "", fmt.Errorf("서버 응답을 처리할 수 없습니다: %w", err)
|
||||||
|
}
|
||||||
|
if result.Token == "" {
|
||||||
|
return "", fmt.Errorf("서버가 토큰을 반환하지 않았습니다")
|
||||||
|
}
|
||||||
|
return result.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
func hashFile(path string) (string, error) {
|
func hashFile(path string) (string, error) {
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -885,13 +924,17 @@ func uninstall() error {
|
|||||||
// ── Game update check + download ─────────────────────────────────────────────
|
// ── Game update check + download ─────────────────────────────────────────────
|
||||||
|
|
||||||
func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
|
func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
|
||||||
|
if serverInfo.FileHash == "" {
|
||||||
|
return fmt.Errorf("서버에서 유효한 파일 정보를 받지 못했습니다")
|
||||||
|
}
|
||||||
|
|
||||||
needsDownload := false
|
needsDownload := false
|
||||||
|
|
||||||
if _, err := os.Stat(gamePath); os.IsNotExist(err) {
|
if _, err := os.Stat(gamePath); os.IsNotExist(err) {
|
||||||
needsDownload = true
|
needsDownload = true
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return fmt.Errorf("게임 파일 확인 실패: %w", err)
|
return fmt.Errorf("게임 파일 확인 실패: %w", err)
|
||||||
} else if serverInfo.FileHash != "" {
|
} else {
|
||||||
localHash, err := hashFile(gamePath)
|
localHash, err := hashFile(gamePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("파일 검증 실패: %w", err)
|
return fmt.Errorf("파일 검증 실패: %w", err)
|
||||||
@@ -1045,13 +1088,19 @@ func handleURI(rawURI string) error {
|
|||||||
return fmt.Errorf("지원하지 않는 URI 스킴: %s", parsed.Scheme)
|
return fmt.Errorf("지원하지 않는 URI 스킴: %s", parsed.Scheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
token := parsed.Query().Get("token")
|
// 웹 클라이언트가 발급한 일회용 티켓을 서버에서 JWT로 교환
|
||||||
if token == "" {
|
ticket := parsed.Query().Get("token")
|
||||||
|
if ticket == "" {
|
||||||
return fmt.Errorf("토큰이 없습니다")
|
return fmt.Errorf("토큰이 없습니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
token, err := redeemTicket(ticket)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("런처 인증에 실패했습니다: %w", err)
|
||||||
|
}
|
||||||
// JWT는 점(.)으로 구분된 3파트 형식이어야 함
|
// JWT는 점(.)으로 구분된 3파트 형식이어야 함
|
||||||
if parts := strings.Split(token, "."); len(parts) != 3 {
|
if parts := strings.Split(token, "."); len(parts) != 3 {
|
||||||
return fmt.Errorf("유효하지 않은 토큰 형식입니다")
|
return fmt.Errorf("서버에서 유효하지 않은 토큰을 받았습니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
gameDir, err := installDir()
|
gameDir, err := installDir()
|
||||||
@@ -1075,7 +1124,7 @@ func handleURI(rawURI string) error {
|
|||||||
if _, statErr := os.Stat(gamePath); statErr == nil {
|
if _, statErr := os.Stat(gamePath); statErr == nil {
|
||||||
ret := msgBox("One of the plans", "서버에 연결할 수 없습니다.\n설치된 게임을 실행하시겠습니까?\n(업데이트 확인 불가)", mbYesNo|mbQ)
|
ret := msgBox("One of the plans", "서버에 연결할 수 없습니다.\n설치된 게임을 실행하시겠습니까?\n(업데이트 확인 불가)", mbYesNo|mbQ)
|
||||||
if ret == idYes {
|
if ret == idYes {
|
||||||
cmd := exec.Command(gamePath)
|
cmd := exec.Command(gamePath, "-token", token)
|
||||||
cmd.Dir = gameDir
|
cmd.Dir = gameDir
|
||||||
cmd.Env = append(os.Environ(), "A301_TOKEN="+token)
|
cmd.Env = append(os.Environ(), "A301_TOKEN="+token)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
@@ -1104,7 +1153,7 @@ func handleURI(rawURI string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(gamePath)
|
cmd := exec.Command(gamePath, "-token", token)
|
||||||
cmd.Dir = gameDir
|
cmd.Dir = gameDir
|
||||||
cmd.Env = append(os.Environ(), "A301_TOKEN="+token)
|
cmd.Env = append(os.Environ(), "A301_TOKEN="+token)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
@@ -1179,7 +1228,7 @@ func main() {
|
|||||||
msgBox("One of the plans 런처", "a301:// 프로토콜이 제거되었습니다.", mbOK|mbInfo)
|
msgBox("One of the plans 런처", "a301:// 프로토콜이 제거되었습니다.", mbOK|mbInfo)
|
||||||
|
|
||||||
case arg == "--version" || arg == "version":
|
case arg == "--version" || arg == "version":
|
||||||
msgBox("One of the plans 런처", "버전: 1.0.0", mbOK|mbInfo)
|
msgBox("One of the plans 런처", fmt.Sprintf("버전: %s", version), mbOK|mbInfo)
|
||||||
|
|
||||||
case strings.HasPrefix(arg, protocolName+"://"):
|
case strings.HasPrefix(arg, protocolName+"://"):
|
||||||
if err := handleURI(arg); err != nil {
|
if err := handleURI(arg); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user