feat: 사용자 인증(Authentication)을 위한 로그인(Login) 및 회원가입(Signup) 기능을 구현하고 관련 UI, API, 테스트 코드를 추가했습니다.

This commit is contained in:
이정수 2026-02-25 16:39:33 +09:00
parent ea4eefb525
commit fee3c1912b
12 changed files with 2806 additions and 676 deletions

33
docs/auth-flow.md Normal file
View File

@ -0,0 +1,33 @@
# Personal Authentication Mod Flow
This document details the modifications and features integrated within `spt-launcher` and `server-csharp` to support the custom Personal Authentication Mod.
## What is Personal Authentication?
Unlike the vanilla SPT launcher which assumes 1 user = 1 machine, Personal Authentication enables multiple isolated accounts. Users register and log in to uniquely generated session IDs via `AuthRouter.cs` hooking into `LauncherCallbacks`.
## The `server-csharp` Modifications:
The server's default JSON payload parsing was too strict regarding unmapped JSON structures (like adding a `password` field).
We addressed this by updating the base interface in `SPTarkov.Server.Core`:
```csharp
namespace SPTarkov.Server.Core.Models.Eft.Launcher;
public record LoginRequestData : IRequestData
{
[JsonPropertyName("username")]
public string? Username { get; set; }
[JsonPropertyName("password")]
public string? Password { get; set; }
}
```
Now, `LoginRequestData` natively accepts passwords, allowing Harmony Patches on `LauncherCallbacks.Login` and `LauncherCallbacks.Register` to validate against the database gracefully.
## Custom Token Session vs Default Random Session Strings
Vanilla SPT creates a random GUID session variable that is used loosely.
With SSO:
1. `spt-launcher` initiates an HTTP POST `/launcher/profile/login` with `username` and `password`.
2. The Database (`DatabaseManager.cs`) verifies credentials against PostgreSQL passwords (hashed).
3. Returns a specific `SessionID` mapped directly to that User ID.
4. The launcher preserves this ID in cookies and local cache storage.
5. Sub-requests (Start Client, Fetch Match Profiles) utilize this single, constant Session ID instead of performing a secondary manual profile scan discovery via `/launcher/profiles`.

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,11 @@ contextBridge.exposeInMainWorld("sptLauncher", {
setSptInstallPath: (payload: { path: string; allowMissing?: boolean }) =>
ipcRenderer.invoke("spt:setSptInstallPath", payload),
pickSptInstallPath: () => ipcRenderer.invoke("spt:pickSptInstallPath"),
fetchProfile: (payload: { username: string }) =>
login: (payload: { username: string; password?: string }) =>
ipcRenderer.invoke("spt:login", payload),
register: (payload: { username: string; password?: string }) =>
ipcRenderer.invoke("spt:register", payload),
fetchProfile: (payload: { username: string; password?: string }) =>
ipcRenderer.invoke("spt:fetchProfile", payload),
downloadProfile: (payload: { username: string }) =>
ipcRenderer.invoke("spt:downloadProfile", payload),

View File

@ -74,6 +74,9 @@ const App = () => {
const [hasSession, setHasSession] = useState(false);
const [loginId, setLoginId] = useState("");
const [loginPassword, setLoginPassword] = useState("");
const [signupId, setSignupId] = useState("");
const [signupPassword, setSignupPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [profileLoading, setProfileLoading] = useState(false);
const [profile, setProfile] = useState<{
username: string;
@ -169,8 +172,8 @@ const App = () => {
value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
const pickString = (...values: unknown[]) =>
values.find((value) => typeof value === "string" && value.trim().length > 0) as
| string
| undefined;
| string
| undefined;
const pickNumber = (...values: unknown[]) => {
const candidate = values.find(
(value) => typeof value === "number" || (typeof value === "string" && value.trim() !== "")
@ -496,18 +499,31 @@ const App = () => {
sessionResumeAttemptedRef.current = true;
void (async () => {
const result = await window.sptLauncher.resumeSession();
if (!result.ok || !result.data) {
return;
try {
const result = await window.sptLauncher.resumeSession();
if (!result.ok || !result.data) {
scheduleScreenTransition("login");
return;
}
const resumedProfile = extractProfile(result.data, loginId.trim());
// Simple validation: profile must have an ID or Username to be considered valid
if (!resumedProfile.id && !resumedProfile.username) {
scheduleScreenTransition("login");
return;
}
setProfile(resumedProfile);
setHasSession(true);
if (resumedProfile.username) {
setLoginId(resumedProfile.username);
}
clearTransitionTimeout();
setScreen("main");
} catch (error) {
console.error("Session resume failed", error);
scheduleScreenTransition("login");
}
const resumedProfile = extractProfile(result.data, loginId.trim());
setProfile(resumedProfile);
setHasSession(true);
if (resumedProfile.username) {
setLoginId(resumedProfile.username);
}
clearTransitionTimeout();
setScreen("main");
})();
}, [
screen,
@ -815,28 +831,30 @@ const App = () => {
const handleLogin = async () => {
const trimmedId = loginId.trim();
const trimmedPassword = loginPassword.trim();
if (!trimmedId) {
window.alert("아이디를 입력해주세요.");
return;
}
if (!window.sptLauncher?.fetchProfile) {
window.alert("프로필 조회 기능이 준비되지 않았습니다.");
if (!window.sptLauncher?.login) {
window.alert("로그인 기능이 준비되지 않았습니다.");
return;
}
setProfileLoading(true);
try {
const result = await window.sptLauncher.fetchProfile({
username: trimmedId
const result = await window.sptLauncher.login({
username: trimmedId,
password: trimmedPassword
});
if (!result.ok) {
if (isTimeoutError(result.error)) {
enterRecoveryMode("timeout");
return;
}
window.alert(result.error ?? "프로필 조회에 실패했습니다.");
window.alert(result.error ?? "로그인에 실패했습니다.");
return;
}
@ -860,9 +878,51 @@ const App = () => {
}
};
const handleSignup = () => {
window.alert("회원가입 요청이 접수되었습니다. 관리자 승인 후 이용 가능합니다.");
setScreen("login");
const handleSignup = async () => {
const trimmedId = signupId.trim();
const trimmedPassword = signupPassword.trim();
const trimmedConfirm = confirmPassword.trim();
if (!trimmedId) {
window.alert("아이디를 입력해주세요.");
return;
}
if (!trimmedPassword) {
window.alert("비밀번호를 입력해주세요.");
return;
}
if (trimmedPassword !== trimmedConfirm) {
window.alert("비밀번호가 일치하지 않습니다.");
return;
}
if (!window.sptLauncher?.register) {
window.alert("회원가입 기능이 준비되지 않았습니다.");
return;
}
setProfileLoading(true);
try {
const result = await window.sptLauncher.register({
username: trimmedId,
password: trimmedPassword
});
if (!result.ok) {
window.alert(result.error ?? "회원가입 요청에 실패했습니다.");
return;
}
window.alert("회원가입이 완료되었습니다. 이제 로그인할 수 있습니다.");
setLoginId(trimmedId);
setLoginPassword(trimmedPassword);
setScreen("login");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
window.alert(message);
} finally {
setProfileLoading(false);
}
};
const handlePasswordReset = () => {
@ -1037,42 +1097,42 @@ const App = () => {
// Subscribe to progress events
const cleanup = window.sptLauncher.onModSyncProgress
? window.sptLauncher.onModSyncProgress((_event, step) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = step as any;
const uiStep = {
name: s.name,
ok: s.result?.ok ?? false,
message: s.result?.ok ? undefined : s.result?.message ?? s.result?.error,
status: s.result?.ok ? "done" : "error"
};
upsertSyncStep(uiStep);
})
: () => {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = step as any;
const uiStep = {
name: s.name,
ok: s.result?.ok ?? false,
message: s.result?.ok ? undefined : s.result?.message ?? s.result?.error,
status: (s.result?.ok ? "done" : "error") as "done" | "error"
};
upsertSyncStep(uiStep);
})
: () => { };
try {
const result = await window.sptLauncher.runModSync({
targetDir,
tag: targetTag
});
cleanup(); // Unsubscribe
if (!result.ok) {
setSyncResult({ ok: false, error: result.error ?? "모드 동기화에 실패했습니다." });
// If steps are returned in result (history), use them?
// The real-time listener should have captured them, but let's ensure we have full list
if (result.steps) {
setSyncSteps(
result.steps.map((s) => ({
name: s.name,
ok: s.ok,
message: s.ok ? undefined : s.message,
status: s.ok ? "done" : "error"
}))
);
}
setModCheckMessage(result.error ?? "모드 동기화에 실패했습니다.");
return false;
setSyncResult({ ok: false, error: result.error ?? "모드 동기화에 실패했습니다." });
// If steps are returned in result (history), use them?
// The real-time listener should have captured them, but let's ensure we have full list
if (result.steps) {
setSyncSteps(
result.steps.map((s) => ({
name: s.name,
ok: s.ok,
message: s.ok ? undefined : s.message,
status: s.ok ? "done" : "error"
}))
);
}
setModCheckMessage(result.error ?? "모드 동기화에 실패했습니다.");
return false;
}
setSyncResult({ ok: true });
@ -1109,7 +1169,7 @@ const App = () => {
const closeSyncModal = () => {
if (syncInProgress) {
return; // Cannot close while running
return; // Cannot close while running
}
setSyncResult(undefined);
setSyncSteps([]);
@ -1130,31 +1190,31 @@ const App = () => {
}
if (!window.sptLauncher?.launchGame) {
window.alert("게임 시작 기능이 준비되지 않았습니다.");
return;
window.alert("게임 시작 기능이 준비되지 않았습니다.");
return;
}
try {
const result = await window.sptLauncher.launchGame();
if (!result.ok) {
if (result.error === "already_running") {
window.alert(`게임이 이미 실행 중입니다.\n(발견된 프로세스: ${(result.details ?? []).join(", ")})`);
} else if (result.error === "mod_verification_failed") {
window.alert("모드 버전 검증에 실패했습니다. 버전을 다시 확인해주세요.");
} else if (result.error === "no_spt_path") {
window.alert("SPT 설치 경로가 설정되지 않았습니다.");
} else if (result.error === "executable_not_found") {
window.alert("실행 파일(Launcher/Server)을 찾을 수 없습니다.");
} else {
window.alert(`게임 실행 실패: ${result.error}`);
}
return;
const result = await window.sptLauncher.launchGame();
if (!result.ok) {
if (result.error === "already_running") {
window.alert(`게임이 이미 실행 중입니다.\n(발견된 프로세스: ${(result.details ?? []).join(", ")})`);
} else if (result.error === "mod_verification_failed") {
window.alert("모드 버전 검증에 실패했습니다. 버전을 다시 확인해주세요.");
} else if (result.error === "no_spt_path") {
window.alert("SPT 설치 경로가 설정되지 않았습니다.");
} else if (result.error === "executable_not_found") {
window.alert("실행 파일(Launcher/Server)을 찾을 수 없습니다.");
} else {
window.alert(`게임 실행 실패: ${result.error}`);
}
// Success
window.alert(`게임이 실행되었습니다.\n(${result.executedPath})`);
return;
}
// Success
window.alert(`게임이 실행되었습니다.\n(${result.executedPath})`);
} catch (e: any) {
window.alert(`게임 실행 중 오류가 발생했습니다: ${e.message}`);
window.alert(`게임 실행 중 오류가 발생했습니다: ${e.message}`);
}
};
@ -1312,12 +1372,27 @@ const App = () => {
.
</p>
<div className="login-form">
<input type="text" placeholder="아이디" />
<input type="password" placeholder="비밀번호" />
<input type="password" placeholder="비밀번호 확인" />
<input
type="text"
placeholder="아이디"
value={signupId}
onChange={(e) => setSignupId(e.target.value)}
/>
<input
type="password"
placeholder="비밀번호"
value={signupPassword}
onChange={(e) => setSignupPassword(e.target.value)}
/>
<input
type="password"
placeholder="비밀번호 확인"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
<button type="button" onClick={handleSignup}>
<button type="button" onClick={handleSignup} disabled={profileLoading}>
{profileLoading ? "가입 요청 중..." : "회원가입 요청"}
</button>
<button type="button" className="ghost" onClick={() => setScreen("login")}>
@ -1381,114 +1456,114 @@ const App = () => {
<section className="card spt-runner-card">
<div className="section-head">
<h2>SPT </h2>
<button
type="button"
className="launch-button-header"
onClick={handleLaunch}
disabled={!startReady}
>
<span className="launch-icon">🚀</span>
<div className="launch-info">
<span className="launch-text">{syncInProgress ? "동기화 중..." : "게임 시작"}</span>
{/* <span className="launch-sub">Server & Launcher</span> */}
</div>
</button>
<button
type="button"
className="launch-button-header"
onClick={handleLaunch}
disabled={!startReady}
>
<span className="launch-icon">🚀</span>
<div className="launch-info">
<span className="launch-text">{syncInProgress ? "동기화 중..." : "게임 시작"}</span>
{/* <span className="launch-sub">Server & Launcher</span> */}
</div>
</button>
</div>
<div className="spt-rows-container">
{/* Install Path Row */}
<div className="spt-row">
<div className="row-header">
<span className="label"> </span>
<span className={`status ${sptInstallInfo?.ok ? "ok" : "blocked"}`}>
{sptInstallInfo?.ok ? "확인됨" : "확인 필요"}
<div className="row-header">
<span className="label"> </span>
<span className={`status ${sptInstallInfo?.ok ? "ok" : "blocked"}`}>
{sptInstallInfo?.ok ? "확인됨" : "확인 필요"}
</span>
</div>
<div className="row-body">
<span className="value" title={sptInstallInfo?.path}>{sptInstallInfo?.path ?? "-"}</span>
{sptInstallInfo?.source && (
<span className="sub-helper">
({sptInstallInfo.source === "auto" ? "자동" : "수동"})
</span>
</div>
<div className="row-body">
<span className="value" title={sptInstallInfo?.path}>{sptInstallInfo?.path ?? "-"}</span>
{sptInstallInfo?.source && (
<span className="sub-helper">
({sptInstallInfo.source === "auto" ? "자동" : "수동"})
</span>
)}
</div>
<div className="row-controls">
<button
type="button"
className="ghost icon-button"
onClick={async () => {
if (!window.sptLauncher?.pickSptInstallPath) {
window.alert("준비 안됨");
return;
}
const result = await window.sptLauncher.pickSptInstallPath();
if (!result.ok && result.error === "invalid_spt_path") {
setSptInstallMessage("유효하지 않은 SPT 폴더입니다.");
return;
}
setSptInstallMessage(undefined);
await runSptInstallCheck();
}}
title="경로 변경"
>
📂
</button>
<button type="button" className="ghost" onClick={runSptInstallCheck}>
</button>
</div>
)}
</div>
<div className="row-controls">
<button
type="button"
className="ghost icon-button"
onClick={async () => {
if (!window.sptLauncher?.pickSptInstallPath) {
window.alert("준비 안됨");
return;
}
const result = await window.sptLauncher.pickSptInstallPath();
if (!result.ok && result.error === "invalid_spt_path") {
setSptInstallMessage("유효하지 않은 SPT 폴더입니다.");
return;
}
setSptInstallMessage(undefined);
await runSptInstallCheck();
}}
title="경로 변경"
>
📂
</button>
<button type="button" className="ghost" onClick={runSptInstallCheck}>
</button>
</div>
</div>
{/* Mod Version Row */}
<div className="spt-row">
<div className="row-header">
<span className="label"> </span>
<span className={`status ${modStatusClass}`}>{modStatusLabel}</span>
</div>
<div className="row-body">
<span className="value">
{modVersionStatus?.currentTag ?? "-"} {modVersionStatus?.latestTag ?? "-"}
<div className="row-header">
<span className="label"> </span>
<span className={`status ${modStatusClass}`}>{modStatusLabel}</span>
</div>
<div className="row-body">
<span className="value">
{modVersionStatus?.currentTag ?? "-"} {modVersionStatus?.latestTag ?? "-"}
</span>
{modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && (
<span className="sub-helper">
(+{modVersionStatus.latestMinorTags.length} )
</span>
{modVersionStatus?.latestMinorTags && modVersionStatus.latestMinorTags.length > 0 && (
<span className="sub-helper">
(+{modVersionStatus.latestMinorTags.length} )
</span>
)}
</div>
<div className="row-controls">
<select
value={selectedModTag}
onChange={(event) => setSelectedModTag(event.target.value)}
disabled={modTagOptions.length === 0 || syncInProgress}
className="version-select"
>
<option value="">...</option>
{modTagOptions.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
)}
</div>
<div className="row-controls">
<select
value={selectedModTag}
onChange={(event) => setSelectedModTag(event.target.value)}
disabled={modTagOptions.length === 0 || syncInProgress}
className="version-select"
>
<option value="">...</option>
{modTagOptions.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
<button
type="button"
className="ghost"
onClick={() => performModSync("optional", selectedModTag)}
disabled={syncInProgress || !selectedModTag}
title="선택 버전 적용"
>
</button>
<div className="sync-menu">
<button
type="button"
className="ghost"
onClick={() => performModSync("optional", selectedModTag)}
disabled={syncInProgress || !selectedModTag}
title="선택 버전 적용"
className="ghost icon-button"
onClick={() => performModSync("optional")}
disabled={syncInProgress || !modVersionStatus?.latestTag}
title="최신 동기화"
>
</button>
<div className="sync-menu">
<button
type="button"
className="ghost icon-button"
onClick={() => performModSync("optional")}
disabled={syncInProgress || !modVersionStatus?.latestTag}
title="최신 동기화"
>
</button>
{/* Hover menu logic handled by CSS usually, keeping simple for now */}
</div>
</div>
{/* Hover menu logic handled by CSS usually, keeping simple for now */}
</div>
</div>
</div>
</div>
@ -1506,19 +1581,19 @@ const App = () => {
<footer className="footer">
<div className="tool-led">
<span className="tool-label"></span>
<span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" />
<span className="tool-label"></span>
<span className={`led ${serverStatusLedClass}`} aria-label="서버 상태" />
</div>
<div className="tool-led">
<span className="tool-label">Git</span>
<span className={`led ${gitLedClass}`} aria-label="Git 상태" />
<span className="tool-label">Git</span>
<span className={`led ${gitLedClass}`} aria-label="Git 상태" />
</div>
<div className="tool-led">
<span className="tool-label">LFS</span>
<span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" />
<span className="tool-label">LFS</span>
<span className={`led ${lfsLedClass}`} aria-label="Git LFS 상태" />
</div>
<button type="button" className="ghost status-button" onClick={handleOpenToolsModal}>
</button>
</footer>
</main>
@ -1566,7 +1641,7 @@ const App = () => {
</div>
</div>
)}
{installMessage && <div className="notice">{installMessage}</div>}
{installMessage && <div className="notice">{installMessage}</div>}
{gitPathsResult && (
<div className="notice">
<strong> </strong>
@ -1583,73 +1658,73 @@ const App = () => {
{(syncInProgress || syncResult) && (
<div className="modal-overlay" role="presentation">
<section className="modal" onClick={(e) => e.stopPropagation()}>
<header className="modal-header">
<h3>
{syncResult
? (syncResult.ok ? (modalTitle.replace("중...", "완료")) : (syncResult.error ? `오류: ${syncResult.error}` : "오류 발생"))
: modalTitle}
</h3>
{!syncInProgress && (
<button type="button" className="ghost" onClick={closeSyncModal}>
</button>
)}
</header>
<div className="modal-body">
{syncProgress?.progress && (
<div className="sync-progress">
<div className="sync-progress-header">
<span>(Progress)</span>
<span>{syncProgress.progress.percent}%</span>
</div>
<div className="sync-progress-bar">
<div
className="sync-progress-fill"
style={{ width: `${syncProgress.progress.percent}%` }}
/>
</div>
{syncProgress.stepName && (
<div className="sync-progress-step">
(Current Step): {getStepDisplayInfo(syncProgress.stepName).title}
</div>
)}
</div>
)}
<div className="sync-steps">
{syncSteps.map((step, idx) => {
const info = getStepDisplayInfo(step.name);
const stepClass =
step.status === "running" ? "running" : step.ok ? "ok" : "fail";
const stepIcon =
step.status === "running" ? "⏳" : step.ok ? "✅" : "❌";
return (
<div key={idx} className={`sync-step ${stepClass}`}>
<span className="step-number">{idx + 1}.</span>
<div className="step-content">
<div className="step-title">{info.title}</div>
<div className="step-command">{info.command}</div>
{!step.ok && step.message && (
<div className="step-msg">(Error): {step.message}</div>
)}
</div>
<span className="step-icon">{stepIcon}</span>
</div>
);
})}
{syncInProgress && <div className="spinner-row">
<span className="spinner"></span>
<span> ...</span>
</div>}
</div>
{syncResult && (
<div className={`notice ${syncResult.ok ? "info" : "error"}`}>
{syncResult.ok ? "모드 적용이 완료되었습니다." : (syncResult.error ? `실패: ${syncResult.error}` : "오류가 발생했습니다.")}
</div>
)}
<section className="modal" onClick={(e) => e.stopPropagation()}>
<header className="modal-header">
<h3>
{syncResult
? (syncResult.ok ? (modalTitle.replace("중...", "완료")) : (syncResult.error ? `오류: ${syncResult.error}` : "오류 발생"))
: modalTitle}
</h3>
{!syncInProgress && (
<button type="button" className="ghost" onClick={closeSyncModal}>
</button>
)}
</header>
<div className="modal-body">
{syncProgress?.progress && (
<div className="sync-progress">
<div className="sync-progress-header">
<span>(Progress)</span>
<span>{syncProgress.progress.percent}%</span>
</div>
<div className="sync-progress-bar">
<div
className="sync-progress-fill"
style={{ width: `${syncProgress.progress.percent}%` }}
/>
</div>
{syncProgress.stepName && (
<div className="sync-progress-step">
(Current Step): {getStepDisplayInfo(syncProgress.stepName).title}
</div>
)}
</div>
)}
<div className="sync-steps">
{syncSteps.map((step, idx) => {
const info = getStepDisplayInfo(step.name);
const stepClass =
step.status === "running" ? "running" : step.ok ? "ok" : "fail";
const stepIcon =
step.status === "running" ? "⏳" : step.ok ? "✅" : "❌";
return (
<div key={idx} className={`sync-step ${stepClass}`}>
<span className="step-number">{idx + 1}.</span>
<div className="step-content">
<div className="step-title">{info.title}</div>
<div className="step-command">{info.command}</div>
{!step.ok && step.message && (
<div className="step-msg">(Error): {step.message}</div>
)}
</div>
<span className="step-icon">{stepIcon}</span>
</div>
);
})}
{syncInProgress && <div className="spinner-row">
<span className="spinner"></span>
<span> ...</span>
</div>}
</div>
{syncResult && (
<div className={`notice ${syncResult.ok ? "info" : "error"}`}>
{syncResult.ok ? "모드 적용이 완료되었습니다." : (syncResult.error ? `실패: ${syncResult.error}` : "오류가 발생했습니다.")}
</div>
)}
</div>
</section>
</section>
</div>
)}
</div>

1734
src/renderer/App.tsx.orig Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
--- src/renderer/App.tsx
+++ src/renderer/App.tsx
@@ -49,6 +49,7 @@
// MOCK For Browser testing since we hit CORS issues
if (window.location.protocol === 'http:') {
setServerStatus('ready');
+ return;
}
const result = await window.sptLauncher.checkServerVersion();

8
src/renderer/App.tsx.rej Normal file
View File

@ -0,0 +1,8 @@
@@ -49,6 +49,7 @@
// MOCK For Browser testing since we hit CORS issues
if (window.location.protocol === 'http:') {
setServerStatus('ready');
+ return;
}
const result = await window.sptLauncher.checkServerVersion();

View File

@ -121,7 +121,21 @@ interface Window {
path?: string;
error?: string;
}>;
fetchProfile: (payload: { username: string }) => Promise<{
login: (payload: { username: string; password?: string }) => Promise<{
ok: boolean;
status?: number;
data?: unknown;
error?: string;
url: string;
}>;
register: (payload: { username: string; password?: string }) => Promise<{
ok: boolean;
status?: number;
data?: unknown;
error?: string;
url: string;
}>;
fetchProfile: (payload: { username: string; password?: string }) => Promise<{
ok: boolean;
status?: number;
data?: unknown;

View File

@ -1,5 +1,5 @@
export const APP_CONFIG = {
serverBaseUrl: "https://pandoli365.com:5069",
serverBaseUrl: "https://127.0.0.1:6969",
serverHealthcheckPath: "/launcher/ping",
serverHealthcheckTimeoutMs: 2000,
serverRequestTimeoutMs: 4000,

88
test-auth-deflate.js Normal file
View File

@ -0,0 +1,88 @@
const https = require('https');
const zlib = require('zlib');
const agent = new https.Agent({ rejectUnauthorized: false });
const makeRequest = (path, data, cookie) => {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(data);
const deflated = zlib.deflateSync(payload);
const headers = {
'Content-Type': 'application/json',
'Accept-Encoding': 'deflate',
'Expect': '100-continue'
};
if (cookie) {
headers['Cookie'] = cookie;
}
const req = https.request({
hostname: '127.0.0.1',
port: 6969,
path: path,
method: 'POST',
headers: headers,
agent
}, (res) => {
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => {
const buffer = Buffer.concat(chunks);
let body = '';
if (buffer.length > 0) {
try {
body = zlib.inflateSync(buffer).toString();
} catch (e) {
body = buffer.toString();
}
}
resolve({ status: res.statusCode, body });
});
});
req.on('error', reject);
req.write(deflated);
req.end();
});
};
async function testFlow() {
const user = 'testuser' + Math.floor(Math.random() * 1000);
const pass = 'password123';
console.log(`Registering new user: ${user}`);
const regRes = await makeRequest('/launcher/profile/register', {
username: user,
password: pass,
edition: 'Standard'
});
console.log("Registration Response:", regRes.status, regRes.body);
if (regRes.status !== 200 || regRes.body === 'FAILED') {
console.log("Registration failed, stopping.");
return;
}
console.log("\nLogging in with new user...");
const loginRes = await makeRequest('/launcher/profile/login', {
username: user,
password: pass
});
console.log("Login Response:", loginRes.status, loginRes.body);
if (loginRes.status !== 200 || loginRes.body === 'FAILED') {
console.log("Login failed, stopping.");
return;
}
const sessionId = loginRes.body;
console.log("\nFetching profile info...");
const infoRes = await makeRequest('/launcher/profile/info', {
username: user
}, `PHPSESSID=${sessionId}`);
console.log("Info Response:", infoRes.status, infoRes.body);
}
testFlow().catch(console.error);

45
test-auth.js Normal file
View File

@ -0,0 +1,45 @@
const http = require('http');
const https = require('https');
const zlib = require('zlib');
async function sendRequest(path, data) {
const payload = JSON.stringify(data);
const options = {
hostname: '127.0.0.1',
port: 6969,
path: path,
method: 'POST',
rejectUnauthorized: false,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
'Accept-Encoding': 'identity',
'requestcompressed': '0',
'responsecompressed': '0'
}
};
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let result = '';
res.on('data', d => result += d);
res.on('end', () => resolve({ status: res.statusCode, body: result }));
});
req.on('error', reject);
req.write(payload); // We pass uncompressed for now using requestcompressed: 0
req.end();
});
}
async function run() {
console.log("Registering...");
const regRes = await sendRequest('/launcher/profile/register', { username: 'testuser3', password: 'testpassword3', edition: 'Standard' });
console.log(regRes);
console.log("Logging in...");
const loginRes = await sendRequest('/launcher/profile/login', { username: 'testuser3', password: 'testpassword3' });
console.log(loginRes);
}
run();

54
test-signup-login.js Normal file
View File

@ -0,0 +1,54 @@
const https = require('https');
const agent = new https.Agent({ rejectUnauthorized: false });
const makeRequest = (path, data) => {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(data);
const req = https.request({
hostname: '127.0.0.1',
port: 6969,
path: path,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
},
agent
}, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => resolve({ status: res.statusCode, body }));
});
req.on('error', reject);
req.write(payload);
req.end();
});
};
async function testFlow() {
console.log("Registering new user...");
const regRes = await makeRequest('/launcher/profile/register', {
username: 'newuser123',
password: 'newpass123',
edition: 'Standard'
});
console.log("Registration:", regRes);
console.log("Logging in with new user...");
const loginRes = await makeRequest('/launcher/profile/login', {
username: 'newuser123',
password: 'newpass123'
});
console.log("Login:", loginRes);
const sessionId = loginRes.body;
console.log("Fetching profile info...");
const infoRes = await makeRequest('/launcher/profile/info', {
username: 'newuser123'
});
console.log("Info:", infoRes);
}
testFlow().catch(console.error);