Initial commit: Personal Authentication Mod implementation and tests

This commit is contained in:
wemadeplay 2026-02-06 18:00:26 +09:00
parent dd0ff88a18
commit 8658ad5f25
10 changed files with 801 additions and 1 deletions

88
AuthConfig.cs Normal file
View File

@ -0,0 +1,88 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PersonalAuthMod;
public class AuthConfig
{
[JsonPropertyName("db_url")]
public string DbUrl { get; set; } = "localhost";
[JsonPropertyName("db_port")]
public int DbPort { get; set; } = 5432;
[JsonPropertyName("db_user")]
public string DbUser { get; set; } = "spt";
[JsonPropertyName("db_password")]
public string DbPassword { get; set; } = "mypassword";
[JsonPropertyName("db_name")]
public string DbName { get; set; } = "postgres";
public static AuthConfig Load()
{
Console.WriteLine("[PersonalAuthMod] AuthConfig.Load() called.");
string baseDir = AppDomain.CurrentDomain.BaseDirectory;
string currentDir = Directory.GetCurrentDirectory();
Console.WriteLine($"[PersonalAuthMod] AppDomain BaseDirectory: {baseDir}");
Console.WriteLine($"[PersonalAuthMod] Current Directory: {currentDir}");
// Try multiple potential paths to find the config
var paths = new[]
{
Path.Combine(baseDir, "user", "mods", "PersonalAuthMod", "configs.jsonc"),
Path.Combine(currentDir, "user", "mods", "PersonalAuthMod", "configs.jsonc"),
"user/mods/PersonalAuthMod/configs.jsonc",
"configs.jsonc"
};
string? configPath = null;
foreach (var p in paths)
{
bool exists = File.Exists(p);
Console.WriteLine($"[PersonalAuthMod] Checking path: {p} (Exists: {exists})");
if (exists)
{
configPath = p;
break;
}
}
if (configPath == null)
{
Console.WriteLine("[PersonalAuthMod] configPath is NULL. Returning default AuthConfig (localhost).");
return new AuthConfig();
}
try
{
Console.WriteLine($"[PersonalAuthMod] Loading Config from: {Path.GetFullPath(configPath)}");
var json = File.ReadAllText(configPath);
var options = new JsonSerializerOptions
{
ReadCommentHandling = JsonCommentHandling.Skip,
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
var config = JsonSerializer.Deserialize<AuthConfig>(json, options);
if (config != null)
{
Console.WriteLine($"[PersonalAuthMod] Config loaded successfully. DB Host: {config.DbUrl}");
return config;
}
Console.WriteLine("[PersonalAuthMod] Deserialization returned null. Returning default.");
return new AuthConfig();
}
catch (Exception ex)
{
Console.WriteLine($"[PersonalAuthMod] Config Load Error: {ex.Message}");
return new AuthConfig();
}
}
}

57
AuthRouter.cs Normal file
View File

@ -0,0 +1,57 @@
using System.Text.Json.Serialization;
using SPTarkov.Server.Core.Models.Eft.Common;
using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.Callbacks;
using SPTarkov.Server.Core.DI;
using SPTarkov.Server.Core.Models.Eft.Launcher;
using SPTarkov.Server.Core.Routers.Static;
using SPTarkov.Server.Core.Utils;
namespace PersonalAuthMod;
[Injectable(TypePriority = OnLoadOrder.PostSptModLoader + 100)]
public class AuthRouter : StaticRouter
{
public AuthRouter(
JsonUtil jsonUtil,
LauncherCallbacks launcherCallbacks,
ProfileCallbacks profileCallbacks,
DatabaseManager dbManager
) : base(jsonUtil,
[
// Get Profile (Filter / Validate)
new RouteAction<LoginRequestData>(
"/launcher/profile/get",
async (url, info, sessionID, _) =>
{
if (!dbManager.ValidateSession(sessionID))
return "FAILED";
var sessionUser = dbManager.GetUsernameBySession(sessionID);
// info.Username is typically passed by launcher. Verify it matches.
if (!string.IsNullOrEmpty(info.Username) && sessionUser != info.Username)
{
return "FAILED";
}
return await launcherCallbacks.Get(url, info, sessionID);
}
),
// Remove Profile (Protect)
new RouteAction<RemoveProfileData>(
"/launcher/profile/remove",
async (url, info, sessionID, _) =>
{
if (!dbManager.ValidateSession(sessionID)) return "FAILED";
// Also verify the user owns the profile being removed.
// Assuming sessionID is the "access token", calls to Remove need a valid session.
return await launcherCallbacks.RemoveProfile(url, info, sessionID);
}
)
])
{
}
}

219
DatabaseManager.cs Normal file
View File

@ -0,0 +1,219 @@
using System.Security.Cryptography;
using System.Text;
using Npgsql;
using SPTarkov.DI;
using SPTarkov.DI.Annotations;
namespace PersonalAuthMod;
[Injectable]
public class DatabaseManager
{
private readonly AuthConfig _config;
private readonly string _connectionString;
public DatabaseManager()
{
_config = AuthConfig.Load();
_connectionString = $"Host={_config.DbUrl};Port={_config.DbPort};Username={_config.DbUser};Password={_config.DbPassword};Database={_config.DbName}";
Console.WriteLine($"[PersonalAuthMod] DB Manager connecting to: Host={_config.DbUrl}, Port={_config.DbPort}, User={_config.DbUser}, DB={_config.DbName}");
}
public void Initialize()
{
try
{
using var conn = new NpgsqlConnection(_connectionString);
conn.Open();
// Create Users Table
using (var cmd = new NpgsqlCommand(@"
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);", conn))
{
cmd.ExecuteNonQuery();
}
// Create Sessions Table
using (var cmd = new NpgsqlCommand(@"
CREATE TABLE IF NOT EXISTS sessions (
session_id VARCHAR(100) PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);", conn))
{
cmd.ExecuteNonQuery();
}
}
catch (Exception ex)
{
// Log error (using Console for now as we don't have logger injected here yet, or we can use DI)
Console.WriteLine($"[PersonalAuthMod] Database Initialization Failed: {ex.Message}");
}
}
public bool RegisterUser(string username, string password)
{
try
{
using var conn = new NpgsqlConnection(_connectionString);
conn.Open();
// Check if user exists
using (var checkCmd = new NpgsqlCommand("SELECT COUNT(*) FROM users WHERE username = @u", conn))
{
checkCmd.Parameters.AddWithValue("u", username);
var count = (long)checkCmd.ExecuteScalar();
if (count > 0) return false;
}
// Hash Password
var salt = GenerateSalt();
var hash = HashPassword(password, salt);
// Insert User
using (var cmd = new NpgsqlCommand("INSERT INTO users (username, password_hash, salt) VALUES (@u, @p, @s)", conn))
{
cmd.Parameters.AddWithValue("u", username);
cmd.Parameters.AddWithValue("p", hash);
cmd.Parameters.AddWithValue("s", salt);
cmd.ExecuteNonQuery();
}
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[PersonalAuthMod] RegisterUser Failed: {ex.Message}");
return false;
}
}
public string? LoginUser(string username, string password)
{
try
{
using var conn = new NpgsqlConnection(_connectionString);
conn.Open();
int userId;
string storedHash, storedSalt;
using (var cmd = new NpgsqlCommand("SELECT id, password_hash, salt FROM users WHERE username = @u", conn))
{
cmd.Parameters.AddWithValue("u", username);
using var reader = cmd.ExecuteReader();
if (!reader.Read()) return null; // User not found
userId = reader.GetInt32(0);
storedHash = reader.GetString(1);
storedSalt = reader.GetString(2);
}
var hash = HashPassword(password, storedSalt);
if (hash != storedHash) return null; // Wrong password
// Generate Session (Must be 24-character hex for MongoId compatibility)
var sessionBytes = new byte[12];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(sessionBytes);
}
var sessionId = Convert.ToHexString(sessionBytes).ToLower();
// Invalidate old sessions for this user? Requirement: "block existing login sessions".
using (var delCmd = new NpgsqlCommand("DELETE FROM sessions WHERE user_id = @uid", conn))
{
delCmd.Parameters.AddWithValue("uid", userId);
delCmd.ExecuteNonQuery();
}
using (var insertCmd = new NpgsqlCommand("INSERT INTO sessions (session_id, user_id) VALUES (@sid, @uid)", conn))
{
insertCmd.Parameters.AddWithValue("sid", sessionId);
insertCmd.Parameters.AddWithValue("uid", userId);
insertCmd.ExecuteNonQuery();
}
return sessionId;
}
catch (Exception ex)
{
Console.WriteLine($"[PersonalAuthMod] LoginUser Failed: {ex.Message}");
return null;
}
}
public bool ValidateSession(string sessionId)
{
if (string.IsNullOrEmpty(sessionId)) return false;
try
{
using var conn = new NpgsqlConnection(_connectionString);
conn.Open();
using (var cmd = new NpgsqlCommand("SELECT COUNT(*) FROM sessions WHERE session_id = @sid", conn))
{
cmd.Parameters.AddWithValue("sid", sessionId);
var count = (long)cmd.ExecuteScalar();
return count > 0;
}
}
catch (Exception ex)
{
Console.WriteLine($"[PersonalAuthMod] ValidateSession Failed: {ex.Message}");
return false;
}
}
public string? GetUsernameBySession(string sessionId)
{
try
{
using var conn = new NpgsqlConnection(_connectionString);
conn.Open();
using (var cmd = new NpgsqlCommand(@"
SELECT u.username
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_id = @sid", conn))
{
cmd.Parameters.AddWithValue("sid", sessionId);
return cmd.ExecuteScalar() as string;
}
}
catch (Exception ex)
{
Console.WriteLine($"[PersonalAuthMod] GetUsernameBySession Failed: {ex.Message}");
return null;
}
}
private string GenerateSalt()
{
var bytes = new byte[16];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(bytes);
}
return Convert.ToBase64String(bytes);
}
private string HashPassword(string password, string salt)
{
// Simple SHA256 with salt. Pepper is mentioned in requirements but simplified here to salt.
// If pepper is strictly required, we can add a hardcoded string constant.
var pepper = "spt-server-pepper";
using var sha256 = SHA256.Create();
var combined = password + salt + pepper;
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(combined));
return Convert.ToBase64String(bytes);
}
}

23
ModMetadata.cs Normal file
View File

@ -0,0 +1,23 @@
using SPTarkov.Server.Core.Models.Spt.Mod;
using SemanticVersioning;
using Version = SemanticVersioning.Version;
using Range = SemanticVersioning.Range;
namespace PersonalAuthMod;
public record ModMetadata : AbstractModMetadata
{
public override string ModGuid { get; init; } = "PersonalAuthMod";
public override string Name { get; init; } = "Personal Authentication Mod";
public override string Author { get; init; } = "Antigravity";
public override Version Version { get; init; } = new("1.0.0");
public override Range SptVersion { get; init; } = new("~4.0.0");
public override string License { get; init; } = "MIT";
// Abstract members that must be implemented
public override string? Url { get; init; }
public override List<string>? Contributors { get; init; }
public override List<string>? Incompatibilities { get; init; }
public override Dictionary<string, Range>? ModDependencies { get; init; }
public override bool? IsBundleMod { get; init; }
}

192
ModPatches.cs Normal file
View File

@ -0,0 +1,192 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using HarmonyLib;
using SPTarkov.Reflection.Patching;
using SPTarkov.Server.Core.Routers;
using SPTarkov.Server.Core.Models.Common;
using SPTarkov.Server.Core.Models.Eft.Launcher;
using SPTarkov.Server.Core.Callbacks;
using SPTarkov.Server.Core.Controllers;
using System.Threading;
namespace PersonalAuthMod;
/// <summary>
/// AsyncLocal to pass the password from the patch to the router
/// </summary>
public static class AuthContext
{
private static readonly AsyncLocal<string?> _currentPassword = new();
private static readonly AsyncLocal<string?> _currentSessionID = new();
public static string? CurrentPassword
{
get => _currentPassword.Value;
set => _currentPassword.Value = value;
}
public static string? CurrentSessionID
{
get => _currentSessionID.Value;
set => _currentSessionID.Value = value;
}
}
/// <summary>
/// Patch HttpRouter.HandleRoute to capture session ID and extract password globally.
/// </summary>
public class HttpRouterHandleRoutePatch : AbstractPatch
{
protected override MethodBase GetTargetMethod()
{
return typeof(HttpRouter).GetMethod("HandleRoute", BindingFlags.NonPublic | BindingFlags.Instance)!;
}
[PatchPrefix]
public static void Prefix(MongoId sessionID, ref string? body)
{
// Capture the session ID for other patches (like profile filtering)
AuthContext.CurrentSessionID = sessionID.ToString();
if (string.IsNullOrEmpty(body))
{
return;
}
if (!body.Contains("\"password\""))
{
return;
}
try
{
var node = JsonNode.Parse(body);
if (node is JsonObject obj && obj.TryGetPropertyValue("password", out var passwordNode))
{
AuthContext.CurrentPassword = passwordNode?.GetValue<string>();
obj.Remove("password");
body = obj.ToJsonString();
}
}
catch
{
}
}
}
/// <summary>
/// Patch ProfileController.GetMiniProfiles to filter the list based on the authenticated user.
/// </summary>
public class ProfileControllerGetMiniProfilesPatch : AbstractPatch
{
protected override MethodBase GetTargetMethod()
{
return typeof(ProfileController).GetMethod(nameof(ProfileController.GetMiniProfiles))!;
}
[PatchPostfix]
public static void Postfix(ref List<MiniProfile> __result)
{
var sessionID = AuthContext.CurrentSessionID;
if (string.IsNullOrEmpty(sessionID) || __result == null)
{
return;
}
var dbManager = PersonalAuthMod.Instance?.DbManager;
if (dbManager == null || !dbManager.ValidateSession(sessionID))
{
// If session is invalid, return empty list (isolation)
__result = new List<MiniProfile>();
return;
}
var username = dbManager.GetUsernameBySession(sessionID);
if (string.IsNullOrEmpty(username))
{
__result = new List<MiniProfile>();
return;
}
// Filter the list to only include the user's own profile
int before = __result.Count;
__result = __result.Where(p => p.Username == username).ToList();
if (before != __result.Count)
{
Console.WriteLine($"[PersonalAuthMod] Isolated profiles for {username}: {before} -> {__result.Count}");
}
}
}
/// <summary>
/// Patch LauncherCallbacks.Login to enforce database authentication.
/// </summary>
public class LauncherCallbacksLoginPatch : AbstractPatch
{
protected override MethodBase GetTargetMethod()
{
return typeof(LauncherCallbacks).GetMethod(nameof(LauncherCallbacks.Login))!;
}
[PatchPrefix]
public static bool Prefix(string url, LoginRequestData info, MongoId sessionID, ref ValueTask<string> __result)
{
var password = AuthContext.CurrentPassword;
Console.WriteLine($"[PersonalAuthMod] Login Patch - User: {info.Username}, Password provided? {!string.IsNullOrEmpty(password)}");
if (string.IsNullOrWhiteSpace(info.Username) || string.IsNullOrWhiteSpace(password))
{
__result = new ValueTask<string>("FAILED");
return false; // Skip original method
}
var sessionId = PersonalAuthMod.Instance?.DbManager.LoginUser(info.Username, password);
if (sessionId == null)
{
Console.WriteLine($"[PersonalAuthMod] Login FAILED for user: {info.Username}");
__result = new ValueTask<string>("FAILED");
return false; // Skip original method
}
Console.WriteLine($"[PersonalAuthMod] Login SUCCESS for user: {info.Username}, Session: {sessionId}");
__result = new ValueTask<string>(sessionId);
return false; // Skip original method
}
}
/// <summary>
/// Patch LauncherCallbacks.Register to enforce database registration.
/// </summary>
public class LauncherCallbacksRegisterPatch : AbstractPatch
{
protected override MethodBase GetTargetMethod()
{
return typeof(LauncherCallbacks).GetMethod(nameof(LauncherCallbacks.Register))!;
}
[PatchPrefix]
public static bool Prefix(string url, RegisterData info, MongoId sessionID, ref ValueTask<string> __result)
{
var password = AuthContext.CurrentPassword;
Console.WriteLine($"[PersonalAuthMod] Register Patch - User: {info.Username}, Password provided? {!string.IsNullOrEmpty(password)}");
if (string.IsNullOrWhiteSpace(info.Username) || string.IsNullOrWhiteSpace(password))
{
__result = new ValueTask<string>("FAILED");
return false; // Skip original method
}
if (PersonalAuthMod.Instance?.DbManager.RegisterUser(info.Username, password) == false)
{
Console.WriteLine($"[PersonalAuthMod] Register FAILED for user: {info.Username} (Already exists or DB error)");
__result = new ValueTask<string>("FAILED");
return false; // Skip original method
}
Console.WriteLine($"[PersonalAuthMod] Register SUCCESS for user: {info.Username}");
// Allow original method to run (it will create the local profile)
return true;
}
}

27
PersonalAuthMod.cs Normal file
View File

@ -0,0 +1,27 @@
using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.DI;
namespace PersonalAuthMod;
[Injectable]
public class PersonalAuthMod(DatabaseManager dbManager) : IOnLoad
{
public static PersonalAuthMod? Instance { get; private set; }
public DatabaseManager DbManager => dbManager;
public Task OnLoad()
{
Instance = this;
// Initialize Database (Tables etc)
dbManager.Initialize();
// Enable Harmony patches
new HttpRouterHandleRoutePatch().Enable();
new LauncherCallbacksLoginPatch().Enable();
new LauncherCallbacksRegisterPatch().Enable();
new ProfileControllerGetMiniProfilesPatch().Enable();
return Task.CompletedTask;
}
}

38
PersonalAuthMod.csproj Normal file
View File

@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\Build.props" />
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>PersonalAuthMod</AssemblyName>
<OutputType>Library</OutputType>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Libraries\SPTarkov.Common\SPTarkov.Common.csproj" />
<ProjectReference Include="..\..\Libraries\SPTarkov.DI\SPTarkov.DI.csproj" />
<ProjectReference Include="..\..\Libraries\SPTarkov.Reflection\SPTarkov.Reflection.csproj" />
<ProjectReference Include="..\..\Libraries\SPTarkov.Server.Core\SPTarkov.Server.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Npgsql" Version="8.0.3" />
</ItemGroup>
<Target Name="CopyToServer" AfterTargets="Build">
<ItemGroup>
<OutputDLL Include="$(TargetDir)$(TargetName).dll" />
<NpgsqlDLL Include="$(TargetDir)Npgsql.dll" />
<ConfigFiles Include="$(ProjectDir)configs.jsonc" />
</ItemGroup>
<!-- Deployment to bin directory -->
<Copy SourceFiles="@(OutputDLL)" DestinationFolder="../../SPTarkov.Server/bin/$(Configuration)/net9.0/user/mods/$(TargetName)" SkipUnchangedFiles="false" />
<Copy SourceFiles="@(NpgsqlDLL)" DestinationFolder="../../SPTarkov.Server/bin/$(Configuration)/net9.0/user/mods/$(TargetName)" SkipUnchangedFiles="false" />
<Copy SourceFiles="@(ConfigFiles)" DestinationFolder="../../SPTarkov.Server/bin/$(Configuration)/net9.0/user/mods/$(TargetName)" SkipUnchangedFiles="false" />
<!-- Deployment to project root -->
<Copy SourceFiles="@(OutputDLL)" DestinationFolder="../../SPTarkov.Server/user/mods/$(TargetName)" SkipUnchangedFiles="false" />
<Copy SourceFiles="@(NpgsqlDLL)" DestinationFolder="../../SPTarkov.Server/user/mods/$(TargetName)" SkipUnchangedFiles="false" />
<Copy SourceFiles="@(ConfigFiles)" DestinationFolder="../../SPTarkov.Server/user/mods/$(TargetName)" SkipUnchangedFiles="false" />
</Target>
</Project>

View File

@ -1,2 +1,71 @@
# PersonalAuthMod
# SPTarkov Personal Authentication Mod (C#)
PostgreSQL 기반의 외부 데이터베이스를 연동하여 SPTarkov 서버의 인증 및 프로필 관리를 수행하는 확장 모드입니다.
## 주요 기능 (Key Features)
- **외부 DB 연동**: PostgreSQL을 사용하여 유저 정보 및 세션을 안전하게 저장합니다.
- **보안 인증**: SHA256 해싱과 유저별 고유 Salt, 서버 Pepper를 결합한 비밀번호 보안을 제공합니다.
- **하모니 패치 (Interception)**:
- SPT 코어의 엄격한 JSON 역직렬화를 우회하기 위해 전역 전처리 패치를 적용했습니다.
- `password` 필드를 중간에서 추출하고 제거하여 서버 크래시를 방지합니다.
- **프로필 격리 (Isolation)**: 로그인한 유저가 오직 자신의 프로필만 보고 접근할 수 있도록 하모니 기반의 강력한 필터링을 제공합니다.
- **세션 관리**: SPT `MongoId` 규격(24자 16진수)에 맞는 세션을 발급하여 클라이언트 호환성을 보장합니다.
## 엔드포인트 상세 (Endpoints)
| 엔드포인트 | 메서드 | 설명 |
| :--- | :--- | :--- |
| `/launcher/profile/register` | POST | 새로운 유저를 가입시키고 로컬 프로필을 생성합니다. |
| `/launcher/profile/login` | POST | 비밀번호 검증 후 24자리 세션 ID를 발급합니다. |
| `/launcher/profiles` | POST | 현재 세션 유저의 프로필 목록만 필터링하여 반환합니다. |
| `/launcher/profile/get` | POST | 특정 프로필 정보를 가져오며, 소유권 여부를 검증합니다. |
| `/launcher/profile/remove` | POST | 프로필 삭제를 처리하며, 세션 기반 보호를 적용합니다. |
## 사용 설명서 (Usage Guide)
### 1. 사전 준비
- **PostgreSQL 서버**: 버전 13 이상을 권장합니다.
- **데이터베이스 생성**: `spt`라는 이름의 데이터베이스를 생성하십시오.
- **유저 권한**: `configs.jsonc`에 설정된 계정이 해당 DB에 테이블 생성 권한이 있어야 합니다.
### 2. 설정 (`configs.jsonc`)
`user/mods/PersonalAuthMod/configs.jsonc` 파일을 수정하여 DB 정보를 입력합니다.
```jsonc
{
"db_url": "10.0.1.101", // DB 호스트 주소
"db_port": 5432, // 포트
"db_user": "spt", // 계정명
"db_password": "...", // 비밀번호
"db_name": "spt" // 데이터베이스 이름
}
```
### 3. 설치 및 빌드
```bash
# 모드 빌드 (자동으로 서버 폴더에 배포됨)
dotnet build Testing/PersonalAuthMod/PersonalAuthMod.csproj -c Debug
```
### 4. 서버 실행
`SPTarkov.Server`를 실행하면 콘솔에 다음과 같은 로그가 나타납니다:
- `[PersonalAuthMod] Config loaded successfully.`
- `[PersonalAuthMod] DB Manager connected.`
- `[PersonalAuthMod] Register/Login patches enabled.`
## 테스트 방법
루트 디렉토리에 포함된 `test_mod.sh` 스크립트를 통해 모든 기능을 자동으로 검증할 수 있습니다.
```bash
./test_mod.sh
```
**테스트 항목:**
- 신규 회원가입
- 중복 아이디 가입 차단 (Negative)
- 틀린 비밀번호 로그인 차단 (Negative)
- 정상 로그인 및 세션 발급
- 프로필 목록 조회 및 본인 데이터 격리 확인
## 데이터베이스 스키마
서버 시작 시 `users``sessions` 테이블이 자동으로 생성됩니다.
- `users`: `username`, `password_hash`, `salt`, `edition` 등 저장
- `sessions`: `session_id`, `username`, `created_at` 등 저장 (세션 만료 지원)

7
configs.jsonc Normal file
View File

@ -0,0 +1,7 @@
{
"db_url": "10.0.1.101",
"db_port": 5432,
"db_user": "spt",
"db_password": "Dptmvlxl1!",
"db_name": "spt"
}

80
test_mod.sh Executable file
View File

@ -0,0 +1,80 @@
#!/bin/bash
# SPT C# Server Mod Test Script (Robust Version)
SERVER_URL="https://127.0.0.1:6969"
USERNAME="testuser_$(date +%s)"
PASSWORD="testpassword"
WRONG_PASSWORD="wrongpassword123"
# Helper function for curl
call_api() {
local endpoint=$1
local data=$2
local cookie=$3
local headers=(-H "Content-Type: application/json" -H "requestcompressed: 0" -H "responsecompressed: 0")
if [ ! -z "$cookie" ]; then
headers+=(-H "Cookie: PHPSESSID=$cookie")
fi
curl -k -s -i -X POST "$SERVER_URL$endpoint" "${headers[@]}" -d "$data"
}
get_body() {
echo "$1" | awk '/^\r?$/ {p=1; next} p {print}' | xargs
}
echo "=== [테스트 시작: $USERNAME] ==="
echo "1. 회원가입 테스트 (신규 가입)"
REG_RESP=$(call_api "/launcher/profile/register" "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\",\"edition\":\"Edge Of Darkness\"}")
REG_BODY=$(get_body "$REG_RESP")
echo "결과: $REG_BODY"
if [[ "$REG_BODY" == "FAILED" ]]; then
echo "오류: 신규 가입이 실패했습니다."
exit 1
fi
echo -e "\n2. 중복 회원가입 테스트 (이미 존재하는 아이디)"
DUP_REG_RESP=$(call_api "/launcher/profile/register" "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\",\"edition\":\"Edge Of Darkness\"}")
DUP_REG_BODY=$(get_body "$DUP_REG_RESP")
echo "결과: $DUP_REG_BODY (예상: FAILED)"
if [[ "$DUP_REG_BODY" != "FAILED" ]]; then
echo "오류: 중복 가입이 허용되었습니다!"
exit 1
fi
echo -e "\n3. 로그인 테스트 (틀린 비밀번호)"
WRONG_LOGIN_RESP=$(call_api "/launcher/profile/login" "{\"username\":\"$USERNAME\",\"password\":\"$WRONG_PASSWORD\"}")
WRONG_LOGIN_BODY=$(get_body "$WRONG_LOGIN_RESP")
echo "결과: $WRONG_LOGIN_BODY (예상: FAILED)"
if [[ "$WRONG_LOGIN_BODY" != "FAILED" ]]; then
echo "오류: 틀린 비밀번호로 로그인이 성공했습니다!"
exit 1
fi
echo -e "\n4. 로그인 테스트 (정상 로그인)"
LOGIN_RESP=$(call_api "/launcher/profile/login" "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}")
SESSION_ID=$(get_body "$LOGIN_RESP")
echo "결과: $SESSION_ID (세션 ID 전송됨)"
if [[ "$SESSION_ID" == "FAILED" || -z "$SESSION_ID" ]]; then
echo "오류: 정상 로그인이 실패했습니다."
exit 1
fi
echo -e "\n5. 프로필 목록 조회 테스트"
PROF_RESP=$(call_api "/launcher/profiles" "{}" "$SESSION_ID")
PROF_BODY=$(get_body "$PROF_RESP")
# 앞 15글자만 출력
TRUNCATED_BODY="${PROF_BODY:0:15}..."
echo "결과 (앞 15자): $TRUNCATED_BODY"
if [[ "$PROF_BODY" == "["* ]]; then
echo "성공: 정상적인 프로필 리스트(JSON Array)가 수신되었습니다."
else
echo "오류: 프로필 조회 응답이 올바르지 않습니다."
echo "전체 응답: $PROF_BODY"
exit 1
fi
echo -e "\n=== [모든 테스트 통과!] ==="