feat: initial commit of PersonalAuthMod

This commit is contained in:
wemadeplay 2026-02-25 17:20:08 +09:00
commit 36d5500b28
9 changed files with 568 additions and 0 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();
}
}
}

44
AuthRouter.cs Normal file
View File

@ -0,0 +1,44 @@
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, _) =>
{
// Rely on native SPT memory session validation via launcherCallbacks.
// We enforce authentication separately at the /login endpoint.
return await launcherCallbacks.Get(url, info, sessionID);
}
),
// Remove Profile (Protect)
new RouteAction<RemoveProfileData>(
"/launcher/profile/remove",
async (url, info, sessionID, _) =>
{
return await launcherCallbacks.RemoveProfile(url, info, sessionID);
}
)
])
{
}
}

154
DatabaseManager.cs Normal file
View File

@ -0,0 +1,154 @@
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 and get ID
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 bool ValidateCredentials(string username, string password)
{
try
{
using var conn = new NpgsqlConnection(_connectionString);
conn.Open();
string storedHash, storedSalt;
using (var cmd = new NpgsqlCommand("SELECT password_hash, salt FROM users WHERE username = @u", conn))
{
cmd.Parameters.AddWithValue("u", username);
using var reader = cmd.ExecuteReader();
if (!reader.Read())
{
Console.WriteLine($"[PersonalAuthMod] ValidateCredentials Failed: User '{username}' not found in DB.");
return false;
}
storedHash = reader.GetString(0);
storedSalt = reader.GetString(1);
}
var hash = HashPassword(password, storedSalt);
if (hash != storedHash)
{
Console.WriteLine($"[PersonalAuthMod] ValidateCredentials Failed: Password mismatch for user '{username}'.");
return false;
}
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[PersonalAuthMod] ValidateCredentials Failed: {ex.Message}");
return false;
}
}
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; }
}

117
ModPatches.cs Normal file
View File

@ -0,0 +1,117 @@
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 globally before deserialization happens.
/// </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)
{
// Capture the session ID for other patches (like profile filtering)
AuthContext.CurrentSessionID = sessionID.ToString();
}
}
/// <summary>
/// Patch LauncherCallbacks.Login to enforce database authentication.
/// Returns true to let the original method execute and fetch the MongoId from memory.
/// Returns false if DB verification fails, aborting the original method.
/// </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)
{
if (string.IsNullOrWhiteSpace(info.Username) || string.IsNullOrWhiteSpace(info.Password))
{
__result = new ValueTask<string>("FAILED");
return false;
}
if (!PersonalAuthMod.Instance!.DbManager.ValidateCredentials(info.Username, info.Password))
{
Console.WriteLine($"[PersonalAuthMod] Login FAILED for user: {info.Username} (Invalid credentials via DB)");
__result = new ValueTask<string>("FAILED");
return false;
}
Console.WriteLine($"[PersonalAuthMod] Login SUCCESS for user: {info.Username}, Validated by DB.");
return true;
}
}
/// <summary>
/// Patch LauncherCallbacks.Register to enforce database registration.
/// Returns true to let the original method execute and create the account in SaveServer.
/// Returns false if DB registration fails (e.g. user exists).
/// </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)
{
if (string.IsNullOrWhiteSpace(info.Username) || string.IsNullOrWhiteSpace(info.Password))
{
__result = new ValueTask<string>("FAILED");
return false;
}
if (!PersonalAuthMod.Instance!.DbManager.RegisterUser(info.Username, info.Password))
{
Console.WriteLine($"[PersonalAuthMod] Register FAILED for user: {info.Username} (Already exists or DB error)");
__result = new ValueTask<string>("FAILED");
return false;
}
Console.WriteLine($"[PersonalAuthMod] Register SUCCESS for user: {info.Username}");
return true;
}
}

26
PersonalAuthMod.cs Normal file
View File

@ -0,0 +1,26 @@
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();
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>

71
README.md Normal file
View File

@ -0,0 +1,71 @@
# 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"
}