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=== [모든 테스트 통과!] ==="