Initial commit: Personal Authentication Mod implementation and tests
This commit is contained in:
parent
dd0ff88a18
commit
8658ad5f25
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
)
|
||||
])
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
71
README.md
71
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` 등 저장 (세션 만료 지원)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"db_url": "10.0.1.101",
|
||||
"db_port": 5432,
|
||||
"db_user": "spt",
|
||||
"db_password": "Dptmvlxl1!",
|
||||
"db_name": "spt"
|
||||
}
|
||||
|
|
@ -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=== [모든 테스트 통과!] ==="
|
||||
Loading…
Reference in New Issue