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`.

View File

@ -125,6 +125,11 @@ const checkServerHealth = async (): Promise<ServerHealthResult> => {
const request = net.request({ const request = net.request({
method: "GET", method: "GET",
url, url,
headers: {
"Accept-Encoding": "identity",
requestcompressed: "0",
responsecompressed: "0",
}
}); });
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@ -1336,8 +1341,10 @@ const postText = async (path: string, body: unknown) => {
const requestSessionId = async ( const requestSessionId = async (
username: string, username: string,
password?: string,
): Promise<SessionRequestResult> => { ): Promise<SessionRequestResult> => {
const loginResult = await postText("/launcher/profile/login", { username }); // 1. Login to get the session token
const loginResult = await postText("/launcher/profile/login", { username, password });
if (!loginResult.ok) { if (!loginResult.ok) {
return { return {
ok: false, ok: false,
@ -1347,10 +1354,17 @@ const requestSessionId = async (
} }
const sessionId = loginResult.data?.trim(); const sessionId = loginResult.data?.trim();
if (!sessionId) {
return { ok: false, error: "empty_session", url: loginResult.url }; // Check if session ID is empty or explicitly "FAILED"
if (!sessionId || sessionId === "FAILED" || sessionId === "") {
return {
ok: false,
error: sessionId === "FAILED" ? "login_failed_credentials" : "empty_session",
url: loginResult.url
};
} }
// With PersonalAuthMod, the sessionId is the unique token we need.
return { ok: true, sessionId, url: loginResult.url }; return { ok: true, sessionId, url: loginResult.url };
}; };
@ -1359,6 +1373,7 @@ const isSessionInvalidStatus = (status?: number) =>
const getSessionIdForUser = async ( const getSessionIdForUser = async (
username: string, username: string,
password?: string,
): Promise<SessionLookupResult> => { ): Promise<SessionLookupResult> => {
const cached = await readSessionRecord(); const cached = await readSessionRecord();
if (cached && cached.username === username && !isSessionExpired(cached)) { if (cached && cached.username === username && !isSessionExpired(cached)) {
@ -1378,7 +1393,7 @@ const getSessionIdForUser = async (
await clearSessionRecord(); await clearSessionRecord();
} }
const loginResult = await requestSessionId(username); const loginResult = await requestSessionId(username, password);
if (!loginResult.ok) { if (!loginResult.ok) {
return loginResult; return loginResult;
} }
@ -1392,9 +1407,10 @@ const getSessionIdForUser = async (
}; };
}; };
const registerProfile = async (username: string) => { const registerProfile = async (username: string, password?: string) => {
return await postJson("/launcher/profile/register", { return await postText("/launcher/profile/register", {
username, username,
password,
edition: "Standard", edition: "Standard",
}); });
}; };
@ -1420,7 +1436,23 @@ const createWindow = () => {
} }
}; };
app.whenReady().then(() => { const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
console.log("[spt-launcher] Another instance is already running. Quitting...");
app.quit();
} else {
app.on("second-instance", (event, commandLine, workingDirectory) => {
// Someone tried to run a second instance, we should focus our window.
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
const mainWindow = windows[0];
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
app.whenReady().then(() => {
const expectedServerUrl = new URL(SERVER_BASE_URL); const expectedServerUrl = new URL(SERVER_BASE_URL);
const expectedServerOrigin = expectedServerUrl.origin; const expectedServerOrigin = expectedServerUrl.origin;
const expectedServerHost = expectedServerUrl.hostname; const expectedServerHost = expectedServerUrl.hostname;
@ -1687,9 +1719,42 @@ app.whenReady().then(() => {
}); });
ipcMain.handle( ipcMain.handle(
"spt:fetchProfile", "spt:register",
async (_event, payload: { username: string }) => { async (_event, payload: { username: string; password?: string }) => {
const username = payload.username.trim(); const username = payload.username.trim();
const password = payload.password?.trim();
return await registerProfile(username, password);
}
);
ipcMain.handle(
"spt:login",
async (_event, payload: { username: string; password?: string }) => {
const username = payload.username.trim();
const password = payload.password?.trim();
const sessionResult = await getSessionIdForUser(username, password);
if (!sessionResult.ok) {
return sessionResult;
}
await writeSessionRecord({ username, sessionId: sessionResult.sessionId });
return await postJson(
"/launcher/profile/info",
{ username },
{
Cookie: `PHPSESSID=${sessionResult.sessionId}`,
},
);
}
);
ipcMain.handle(
"spt:fetchProfile",
async (_event, payload: { username: string; password?: string }) => {
const username = payload.username.trim();
const password = payload.password?.trim();
let activeSessionId: string | undefined; let activeSessionId: string | undefined;
const fetchProfileInfo = async (): Promise<{ const fetchProfileInfo = async (): Promise<{
@ -1699,7 +1764,7 @@ app.whenReady().then(() => {
error?: string; error?: string;
url: string; url: string;
}> => { }> => {
const sessionResult = await getSessionIdForUser(username); const sessionResult = await getSessionIdForUser(username, password);
if (!sessionResult.ok) { if (!sessionResult.ok) {
return { return {
ok: false, ok: false,
@ -1720,7 +1785,7 @@ app.whenReady().then(() => {
let infoResult = await fetchProfileInfo(); let infoResult = await fetchProfileInfo();
if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) { if (!infoResult.ok && isSessionInvalidStatus(infoResult.status)) {
const relogin = await requestSessionId(username); const relogin = await requestSessionId(username, password);
if (!relogin.ok) { if (!relogin.ok) {
return { return {
ok: false, ok: false,
@ -1745,8 +1810,7 @@ app.whenReady().then(() => {
return infoResult; return infoResult;
} }
await registerProfile(username); return infoResult;
return await fetchProfileInfo();
}, },
); );
@ -1897,10 +1961,11 @@ app.whenReady().then(() => {
createWindow(); createWindow();
} }
}); });
}); });
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
app.quit(); app.quit();
} }
}); });
}

View File

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

View File

@ -74,6 +74,9 @@ const App = () => {
const [hasSession, setHasSession] = useState(false); const [hasSession, setHasSession] = useState(false);
const [loginId, setLoginId] = useState(""); const [loginId, setLoginId] = useState("");
const [loginPassword, setLoginPassword] = useState(""); const [loginPassword, setLoginPassword] = useState("");
const [signupId, setSignupId] = useState("");
const [signupPassword, setSignupPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [profileLoading, setProfileLoading] = useState(false); const [profileLoading, setProfileLoading] = useState(false);
const [profile, setProfile] = useState<{ const [profile, setProfile] = useState<{
username: string; username: string;
@ -496,11 +499,20 @@ const App = () => {
sessionResumeAttemptedRef.current = true; sessionResumeAttemptedRef.current = true;
void (async () => { void (async () => {
try {
const result = await window.sptLauncher.resumeSession(); const result = await window.sptLauncher.resumeSession();
if (!result.ok || !result.data) { if (!result.ok || !result.data) {
scheduleScreenTransition("login");
return; return;
} }
const resumedProfile = extractProfile(result.data, loginId.trim()); 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); setProfile(resumedProfile);
setHasSession(true); setHasSession(true);
if (resumedProfile.username) { if (resumedProfile.username) {
@ -508,6 +520,10 @@ const App = () => {
} }
clearTransitionTimeout(); clearTransitionTimeout();
setScreen("main"); setScreen("main");
} catch (error) {
console.error("Session resume failed", error);
scheduleScreenTransition("login");
}
})(); })();
}, [ }, [
screen, screen,
@ -815,28 +831,30 @@ const App = () => {
const handleLogin = async () => { const handleLogin = async () => {
const trimmedId = loginId.trim(); const trimmedId = loginId.trim();
const trimmedPassword = loginPassword.trim();
if (!trimmedId) { if (!trimmedId) {
window.alert("아이디를 입력해주세요."); window.alert("아이디를 입력해주세요.");
return; return;
} }
if (!window.sptLauncher?.fetchProfile) { if (!window.sptLauncher?.login) {
window.alert("프로필 조회 기능이 준비되지 않았습니다."); window.alert("로그인 기능이 준비되지 않았습니다.");
return; return;
} }
setProfileLoading(true); setProfileLoading(true);
try { try {
const result = await window.sptLauncher.fetchProfile({ const result = await window.sptLauncher.login({
username: trimmedId username: trimmedId,
password: trimmedPassword
}); });
if (!result.ok) { if (!result.ok) {
if (isTimeoutError(result.error)) { if (isTimeoutError(result.error)) {
enterRecoveryMode("timeout"); enterRecoveryMode("timeout");
return; return;
} }
window.alert(result.error ?? "프로필 조회에 실패했습니다."); window.alert(result.error ?? "로그인에 실패했습니다.");
return; return;
} }
@ -860,9 +878,51 @@ const App = () => {
} }
}; };
const handleSignup = () => { const handleSignup = async () => {
window.alert("회원가입 요청이 접수되었습니다. 관리자 승인 후 이용 가능합니다."); 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"); setScreen("login");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
window.alert(message);
} finally {
setProfileLoading(false);
}
}; };
const handlePasswordReset = () => { const handlePasswordReset = () => {
@ -1043,11 +1103,11 @@ const App = () => {
name: s.name, name: s.name,
ok: s.result?.ok ?? false, ok: s.result?.ok ?? false,
message: s.result?.ok ? undefined : s.result?.message ?? s.result?.error, message: s.result?.ok ? undefined : s.result?.message ?? s.result?.error,
status: s.result?.ok ? "done" : "error" status: (s.result?.ok ? "done" : "error") as "done" | "error"
}; };
upsertSyncStep(uiStep); upsertSyncStep(uiStep);
}) })
: () => {}; : () => { };
try { try {
const result = await window.sptLauncher.runModSync({ const result = await window.sptLauncher.runModSync({
@ -1312,12 +1372,27 @@ const App = () => {
. .
</p> </p>
<div className="login-form"> <div className="login-form">
<input type="text" placeholder="아이디" /> <input
<input type="password" placeholder="비밀번호" /> type="text"
<input type="password" placeholder="비밀번호 확인" /> 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> </div>
<button type="button" onClick={handleSignup}> <button type="button" onClick={handleSignup} disabled={profileLoading}>
{profileLoading ? "가입 요청 중..." : "회원가입 요청"}
</button> </button>
<button type="button" className="ghost" onClick={() => setScreen("login")}> <button type="button" className="ghost" onClick={() => setScreen("login")}>

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; path?: string;
error?: 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; ok: boolean;
status?: number; status?: number;
data?: unknown; data?: unknown;

View File

@ -1,5 +1,5 @@
export const APP_CONFIG = { export const APP_CONFIG = {
serverBaseUrl: "https://pandoli365.com:5069", serverBaseUrl: "https://127.0.0.1:6969",
serverHealthcheckPath: "/launcher/ping", serverHealthcheckPath: "/launcher/ping",
serverHealthcheckTimeoutMs: 2000, serverHealthcheckTimeoutMs: 2000,
serverRequestTimeoutMs: 4000, 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);