From 8658ad5f25feeb656abcc27eb5b54558aee51fbb Mon Sep 17 00:00:00 2001 From: wemadeplay Date: Fri, 6 Feb 2026 18:00:26 +0900 Subject: [PATCH] Initial commit: Personal Authentication Mod implementation and tests --- AuthConfig.cs | 88 +++++++++++++++++ AuthRouter.cs | 57 +++++++++++ DatabaseManager.cs | 219 +++++++++++++++++++++++++++++++++++++++++ ModMetadata.cs | 23 +++++ ModPatches.cs | 192 ++++++++++++++++++++++++++++++++++++ PersonalAuthMod.cs | 27 +++++ PersonalAuthMod.csproj | 38 +++++++ README.md | 71 ++++++++++++- configs.jsonc | 7 ++ test_mod.sh | 80 +++++++++++++++ 10 files changed, 801 insertions(+), 1 deletion(-) create mode 100644 AuthConfig.cs create mode 100644 AuthRouter.cs create mode 100644 DatabaseManager.cs create mode 100644 ModMetadata.cs create mode 100644 ModPatches.cs create mode 100644 PersonalAuthMod.cs create mode 100644 PersonalAuthMod.csproj create mode 100644 configs.jsonc create mode 100755 test_mod.sh diff --git a/AuthConfig.cs b/AuthConfig.cs new file mode 100644 index 0000000..ea91491 --- /dev/null +++ b/AuthConfig.cs @@ -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(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(); + } + } +} diff --git a/AuthRouter.cs b/AuthRouter.cs new file mode 100644 index 0000000..6d6f9b5 --- /dev/null +++ b/AuthRouter.cs @@ -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( + "/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( + "/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); + } + ) + ]) + { + } +} diff --git a/DatabaseManager.cs b/DatabaseManager.cs new file mode 100644 index 0000000..2e8a2ca --- /dev/null +++ b/DatabaseManager.cs @@ -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); + } +} diff --git a/ModMetadata.cs b/ModMetadata.cs new file mode 100644 index 0000000..683eb51 --- /dev/null +++ b/ModMetadata.cs @@ -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? Contributors { get; init; } + public override List? Incompatibilities { get; init; } + public override Dictionary? ModDependencies { get; init; } + public override bool? IsBundleMod { get; init; } +} diff --git a/ModPatches.cs b/ModPatches.cs new file mode 100644 index 0000000..8ded27c --- /dev/null +++ b/ModPatches.cs @@ -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; + +/// +/// AsyncLocal to pass the password from the patch to the router +/// +public static class AuthContext +{ + private static readonly AsyncLocal _currentPassword = new(); + private static readonly AsyncLocal _currentSessionID = new(); + + public static string? CurrentPassword + { + get => _currentPassword.Value; + set => _currentPassword.Value = value; + } + + public static string? CurrentSessionID + { + get => _currentSessionID.Value; + set => _currentSessionID.Value = value; + } +} + +/// +/// Patch HttpRouter.HandleRoute to capture session ID and extract password globally. +/// +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(); + obj.Remove("password"); + body = obj.ToJsonString(); + } + } + catch + { + } + } +} + +/// +/// Patch ProfileController.GetMiniProfiles to filter the list based on the authenticated user. +/// +public class ProfileControllerGetMiniProfilesPatch : AbstractPatch +{ + protected override MethodBase GetTargetMethod() + { + return typeof(ProfileController).GetMethod(nameof(ProfileController.GetMiniProfiles))!; + } + + [PatchPostfix] + public static void Postfix(ref List __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(); + return; + } + + var username = dbManager.GetUsernameBySession(sessionID); + if (string.IsNullOrEmpty(username)) + { + __result = new List(); + 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}"); + } + } +} + +/// +/// Patch LauncherCallbacks.Login to enforce database authentication. +/// +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 __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("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("FAILED"); + return false; // Skip original method + } + + Console.WriteLine($"[PersonalAuthMod] Login SUCCESS for user: {info.Username}, Session: {sessionId}"); + __result = new ValueTask(sessionId); + return false; // Skip original method + } +} + +/// +/// Patch LauncherCallbacks.Register to enforce database registration. +/// +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 __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("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("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; + } +} diff --git a/PersonalAuthMod.cs b/PersonalAuthMod.cs new file mode 100644 index 0000000..221fa50 --- /dev/null +++ b/PersonalAuthMod.cs @@ -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; + } +} diff --git a/PersonalAuthMod.csproj b/PersonalAuthMod.csproj new file mode 100644 index 0000000..19814a8 --- /dev/null +++ b/PersonalAuthMod.csproj @@ -0,0 +1,38 @@ + + + + net9.0 + enable + enable + PersonalAuthMod + Library + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index dbf855c..bb8f42b 100644 --- a/README.md +++ b/README.md @@ -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` 등 저장 (세션 만료 지원) diff --git a/configs.jsonc b/configs.jsonc new file mode 100644 index 0000000..0551423 --- /dev/null +++ b/configs.jsonc @@ -0,0 +1,7 @@ +{ + "db_url": "10.0.1.101", + "db_port": 5432, + "db_user": "spt", + "db_password": "Dptmvlxl1!", + "db_name": "spt" +} \ No newline at end of file diff --git a/test_mod.sh b/test_mod.sh new file mode 100755 index 0000000..e53deb7 --- /dev/null +++ b/test_mod.sh @@ -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=== [모든 테스트 통과!] ==="