fix(plugin): isolate login preferences by server origin and preserve official credentials

This commit is contained in:
2026-10-05 00:25:57 +08:00
parent d04791dd31
commit eb445f88f4
11 changed files with 144 additions and 78 deletions
+1
View File
@@ -65,6 +65,7 @@ internal sealed class ClientRouting : IDisposable
private readonly ManualLogSource log;
private readonly ClientConfig config;
internal string ServerOrigin => config.server_origin;
private readonly ResourcePolicy resources;
private readonly LocalResourceServer localResourceServer;
@@ -9,6 +9,7 @@
<BD2GameVersion>2.35.10</BD2GameVersion>
</PropertyGroup>
<ItemGroup>
<Compile Include="../Shared/ServerLoginPreferences.cs" Link="ServerLoginPreferences.cs" />
<Reference Include="BepInEx">
<HintPath>$(BD2BepInExDir)\core\BepInEx.dll</HintPath>
<Private>false</Private>
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Reflection;
using BD2.GameNames;
using Bd2Login;
using BepInEx.Logging;
using HarmonyLib;
using gamfs.Platform;
namespace Bd2LocalIdentity;
internal static class LocalLoginState
{
private static string Origin;
internal static void Install(Harmony harmony, string serverOrigin, ManualLogSource log)
{
Origin = ServerLoginPreferences.NormalizeOrigin(new Uri(serverOrigin));
PropertyInfo automatic = typeof(PlatformManager).GetGameProperty(
nameof(PlatformManager.IsAutoLogin), BindingFlags.Public | BindingFlags.Instance);
PropertyInfo checkbox = typeof(PlatformManager).GetGameProperty(
nameof(PlatformManager.UseAutoLoginPC), BindingFlags.Public | BindingFlags.Instance);
MethodInfo accessToken = Game.Getter(() => BDNetwork.CommonPacket.AccessToken);
Validate(automatic);
Validate(checkbox);
if (accessToken == null) throw new MissingMethodException("CommonPacket.AccessToken getter was not found");
harmony.Patch(automatic.GetGetMethod(), prefix: new HarmonyMethod(typeof(LocalLoginState), nameof(AutoLoginGetter)));
harmony.Patch(automatic.GetSetMethod(), prefix: new HarmonyMethod(typeof(LocalLoginState), nameof(AutoLoginSetter)));
harmony.Patch(checkbox.GetGetMethod(), prefix: new HarmonyMethod(typeof(LocalLoginState), nameof(CheckboxGetter)));
harmony.Patch(checkbox.GetSetMethod(), prefix: new HarmonyMethod(typeof(LocalLoginState), nameof(CheckboxSetter)));
// Skip the original getter's PlayerPrefs access. LoginUI's postfix
// supplies its in-memory OAuth token when installed; this value also
// supports LocalIdentity alone without writing the official token key.
harmony.Patch(accessToken, prefix: new HarmonyMethod(typeof(LocalLoginState), nameof(BootstrapToken)));
log.LogInfo("Login preferences isolated by server origin; official login keys are untouched");
}
private static void Validate(PropertyInfo property)
{
if (property == null || property.PropertyType != typeof(bool) ||
property.GetGetMethod() == null || property.GetSetMethod() == null)
throw new MissingMemberException("PlatformManager auto-login property was not found (client version mismatch)");
}
private static bool AutoLoginGetter(ref bool __result)
{
__result = ServerLoginPreferences.IsAutoLogin(Origin);
return false;
}
private static bool AutoLoginSetter(bool __0)
{
ServerLoginPreferences.SetAutoLogin(Origin, __0);
return false;
}
private static bool CheckboxGetter(ref bool __result)
{
__result = ServerLoginPreferences.UseAutoLoginPC(Origin);
return false;
}
private static bool CheckboxSetter(bool __0)
{
ServerLoginPreferences.SetUseAutoLoginPC(Origin, __0);
return false;
}
private static bool BootstrapToken(ref string __result)
{
__result = "bd2-local-development-user";
return false;
}
}
+1 -5
View File
@@ -31,11 +31,6 @@ public sealed class Plugin : BaseUnityPlugin
{
Log = Logger;
Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message));
// The non-SDK branch still needs a local bootstrap identity for
// MaintenanceInfo. OAuth LoginUI replaces this value after its
// browser/device transaction completes.
PlayerPrefs.SetString("AccessToken", "bd2-local-development-user");
PlayerPrefs.Save();
Routing = ClientRouting.Load(Logger);
if (Interlocked.Exchange(ref ShutdownHooksInstalled, 1) == 0)
{
@@ -53,6 +48,7 @@ public sealed class Plugin : BaseUnityPlugin
nameof(UseSdkPrefix),
BindingFlags.Static | BindingFlags.NonPublic);
Harmony harmony = new Harmony(Guid);
LocalLoginState.Install(harmony, Routing.ServerOrigin, Logger);
harmony.Patch(getter, prefix: new HarmonyMethod(prefix));
TryInstall("OS time zone device country", () => SystemTimeZoneRegion.Install(harmony, Logger));
+2 -28
View File
@@ -5,6 +5,7 @@ using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
using Bd2Login;
namespace Bd2LoginUI;
@@ -180,24 +181,7 @@ internal sealed class WindowsDpapiRefreshCredentialStore : IRefreshCredentialSto
PlayerPrefs.Save();
}
private static string PreferenceFor(string origin)
{
byte[] input = Encoding.UTF8.GetBytes(origin);
byte[] digest;
using (SHA256 sha = SHA256.Create())
{
digest = sha.ComputeHash(input);
}
try
{
return PreferencePrefix + Hex(digest);
}
finally
{
Clear(input);
Clear(digest);
}
}
private static string PreferenceFor(string origin) => ServerLoginPreferences.Key(PreferencePrefix, origin);
private static byte[] Entropy(string origin)
{
@@ -306,16 +290,6 @@ internal sealed class WindowsDpapiRefreshCredentialStore : IRefreshCredentialSto
}
}
private static string Hex(byte[] value)
{
StringBuilder builder = new StringBuilder(value.Length * 2);
foreach (byte item in value)
{
builder.Append(item.ToString("x2"));
}
return builder.ToString();
}
private static void Clear(byte[] bytes)
{
if (bytes != null)
+13 -32
View File
@@ -5,6 +5,7 @@ using System.Reflection;
using System.Text;
using System.Threading;
using BD2.GameNames;
using Bd2Login;
using UnityEngine;
using UnityEngine.Networking;
@@ -32,15 +33,14 @@ internal static class LoginController
internal static bool LoginInProgress;
internal static MemoryAccessTokenStore AccessTokens;
internal static IRefreshCredentialStore RefreshCredentials;
internal static bool AccessTokenPrefix(ref string __result)
internal static void AccessTokenPostfix(ref string __result)
{
if (Authentication != null && Authentication.mode == "oauth")
{
__result = AccessTokens.Get();
return false;
return;
}
__result = LocalAccessToken;
return false;
}
internal static void ClearPCLocalDataPostfix()
@@ -49,9 +49,7 @@ internal static class LoginController
EstablishedGameSession = false;
RuntimeProbeFailures = 0;
ServerInstanceID = null;
PlayerPrefs.DeleteKey("AccessToken");
DeleteCurrentRefresh();
PlayerPrefs.Save();
}
internal static bool SendMaintenancePrefix(object __instance, bool __0)
@@ -79,8 +77,6 @@ internal static class LoginController
DisposeGameRelay();
ServerRoot = currentRoot;
EnsureGameRelay();
PlayerPrefs.DeleteKey("AccessToken");
PlayerPrefs.Save();
}
if (Volatile.Read(ref SessionRecoveryInProgress) != 0 && Authentication != null &&
Authentication.mode == "oauth" && AccessTokens.IsUsable(NormalizedServerOrigin()))
@@ -169,8 +165,6 @@ internal static class LoginController
try
{
AccessTokens.Clear();
PlayerPrefs.DeleteKey("AccessToken");
PlayerPrefs.Save();
ContinueMaintenance = true;
SendMaintenance.Invoke(introUI, new object[] { true });
}
@@ -181,17 +175,12 @@ internal static class LoginController
return;
}
ValidateOAuthTransport(ServerRoot);
// Earlier development builds stored both local identifiers and OAuth
// access credentials in this key. OAuth credentials now live only in
// process memory, so remove any legacy plaintext before proceeding.
PlayerPrefs.DeleteKey("AccessToken");
if (!RefreshCredentials.IsSupported)
{
PlayerPrefs.SetInt("IsAutoLogin", 0);
PlayerPrefs.SetInt("StandaloneAutoLogin", 0);
ServerLoginPreferences.SetAutoLogin(NormalizedServerOrigin(), false);
ServerLoginPreferences.SetUseAutoLoginPC(NormalizedServerOrigin(), false);
Log?.LogWarning("Secure refresh credential storage is unavailable; automatic login is disabled on this platform");
}
PlayerPrefs.Save();
if (LoginInProgress)
{
return;
@@ -213,8 +202,8 @@ internal static class LoginController
ShowLoginPanel(introUI);
return;
}
if (PlayerPrefs.GetInt("IsAutoLogin", 0) != 0 &&
PlayerPrefs.GetInt("StandaloneAutoLogin", 0) != 0 &&
if (ServerLoginPreferences.IsAutoLogin(NormalizedServerOrigin()) &&
ServerLoginPreferences.UseAutoLoginPC(NormalizedServerOrigin()) &&
CanAttemptAutomaticLogin())
{
LoginInProgress = true;
@@ -375,10 +364,8 @@ internal static class LoginController
AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in);
result.access_token = null;
result.refresh_token = null;
PlayerPrefs.SetInt("IsAutoLogin", 0);
PlayerPrefs.SetInt("StandaloneAutoLogin", 0);
PlayerPrefs.DeleteKey("AccessToken");
PlayerPrefs.Save();
ServerLoginPreferences.SetAutoLogin(NormalizedServerOrigin(), false);
ServerLoginPreferences.SetUseAutoLoginPC(NormalizedServerOrigin(), false);
Log?.LogWarning("Login succeeded, but automatic login remains disabled because this platform has no supported secure credential store");
ContinueWithMaintenance(introUI, false);
return;
@@ -393,7 +380,7 @@ internal static class LoginController
}
try
{
bool autoLogin = PlayerPrefs.GetInt("StandaloneAutoLogin", 0) != 0;
bool autoLogin = ServerLoginPreferences.UseAutoLoginPC(NormalizedServerOrigin());
if (autoLogin)
{
StoreRefresh(result);
@@ -405,9 +392,7 @@ internal static class LoginController
}
AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in);
result.access_token = null;
PlayerPrefs.SetInt("IsAutoLogin", autoLogin ? 1 : 0);
PlayerPrefs.DeleteKey("AccessToken");
PlayerPrefs.Save();
ServerLoginPreferences.SetAutoLogin(NormalizedServerOrigin(), autoLogin);
ContinueWithMaintenance(introUI, false);
}
catch (Exception ex)
@@ -557,8 +542,6 @@ internal static class LoginController
}
AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in);
result.access_token = null;
PlayerPrefs.DeleteKey("AccessToken");
PlayerPrefs.Save();
refreshToken = null;
attemptID = null;
}
@@ -705,11 +688,9 @@ internal static class LoginController
private static void ClearSavedLogin()
{
AccessTokens.Clear();
PlayerPrefs.SetInt("IsAutoLogin", 0);
PlayerPrefs.SetInt("StandaloneAutoLogin", 0);
PlayerPrefs.DeleteKey("AccessToken");
ServerLoginPreferences.SetAutoLogin(NormalizedServerOrigin(), false);
ServerLoginPreferences.SetUseAutoLoginPC(NormalizedServerOrigin(), false);
DeleteCurrentRefresh();
PlayerPrefs.Save();
}
private static void DeleteCurrentRefresh()
+2 -11
View File
@@ -2,6 +2,7 @@ using System;
using System.IO;
using System.Reflection;
using BD2.GameNames;
using Bd2Login;
using BepInEx.Logging;
using static BD2.GameNames.Game;
@@ -28,17 +29,7 @@ internal static class LoginRuntime
return string.Equals(NormalizeOrigin(left), NormalizeOrigin(right), StringComparison.Ordinal);
}
private static string NormalizeOrigin(Uri uri)
{
if (uri == null || !uri.IsAbsoluteUri || string.IsNullOrEmpty(uri.Host))
{
throw new InvalidOperationException("authentication server origin is unavailable");
}
string host = uri.IdnHost.ToLowerInvariant();
int port = uri.IsDefaultPort ? -1 : uri.Port;
UriBuilder builder = new UriBuilder(uri.Scheme.ToLowerInvariant(), host, port);
return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/');
}
private static string NormalizeOrigin(Uri uri) => ServerLoginPreferences.NormalizeOrigin(uri);
internal static void ValidateOAuthTransport(Uri uri)
{
+1
View File
@@ -9,6 +9,7 @@
<BD2GameVersion>2.35.10</BD2GameVersion>
</PropertyGroup>
<ItemGroup>
<Compile Include="../Shared/ServerLoginPreferences.cs" Link="ServerLoginPreferences.cs" />
<Reference Include="BepInEx">
<HintPath>$(BD2BepInExDir)\core\BepInEx.dll</HintPath>
<Private>false</Private>
+1 -1
View File
@@ -97,7 +97,7 @@ public sealed class Plugin : BaseUnityPlugin
harmony.Patch(SendMaintenance, prefix: maintenancePrefix);
harmony.Patch(
accessTokenGetter,
prefix: new HarmonyMethod(typeof(LoginController), nameof(AccessTokenPrefix)));
postfix: new HarmonyMethod(typeof(LoginController), nameof(AccessTokenPostfix)));
harmony.Patch(
clearPCLocalData,
postfix: new HarmonyMethod(typeof(LoginController), nameof(ClearPCLocalDataPostfix)));
+3 -1
View File
@@ -29,7 +29,9 @@ dotnet build .\plugins\CaptureEnvironment\CaptureEnvironment.csproj -c Release -
认证策略由服务端同目录的 `authentication.json` 决定。`mode=local` 保持本地自动登录;公网或联机服可设为 `oauth`,并在 `providers` 中开启 `discord`、`google` 或两者。LoginUI 从当前连接的服务器读取这项策略,客户端不能自行启用服务端未开放的 provider。
OAuth access token 只保存在客户端进程内存中。自动登录 refresh credential 按规范化服务器 origin 隔离:Windows 使用当前用户作用域的 DPAPI 加密,再把密文按 origin 哈希键写入 PlayerPrefs 对应的用户注册表;macOS 使用系统 Keychain Services。没有受支持安全凭据存储的平台会禁用自动登录,不会回退到明文文件或 PlayerPrefs。除 Windows 的 DPAPI 密文外,PlayerPrefs 只保存原版的 `IsAutoLogin` 与 `StandaloneAutoLogin` 非敏感选择。
OAuth access token 只保存在客户端进程内存中。自动登录 refresh credential 按规范化服务器 origin 隔离:Windows 使用当前用户作用域的 DPAPI 加密,再把密文按 `BD2OAuthRefreshV1_<origin 哈希>` 键写入 PlayerPrefs 对应的用户注册表;macOS 使用系统 Keychain Services。没有受支持安全凭据存储的平台会禁用自动登录,不会回退到明文文件或 PlayerPrefs。
自动登录选择也按服务器 origin 隔离,使用 `BD2LoginV1_IsAutoLogin_<origin 哈希>` 和 `BD2LoginV1_StandaloneAutoLogin_<origin 哈希>`。LocalIdentity 接管原版 `PlatformManager.IsAutoLogin` 与 `UseAutoLoginPC` 的全部读写,原有勾选框、确认和清除登录流程使用这些独立键;LoginUI 使用同一套键。scheme、主机和非默认端口区分服务器,主机大小写、默认端口和尾部斜杠不产生不同状态。插件不读取、覆盖或删除官方 `IsAutoLogin`、`StandaloneAutoLogin`、`AccessToken`,LocalIdentity 的启动标识通过 getter 在内存中提供。旧版写入的官方键不自动迁移或清理,避免影响官方账号;升级后各服务器需要重新选择自动登录。
启动原版抓包环境:
+45
View File
@@ -0,0 +1,45 @@
using System;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
namespace Bd2Login;
// Both plugins use the same normalized origin and key format. Never use the
// official login preference keys, even before the authentication policy loads.
internal static class ServerLoginPreferences
{
internal static string NormalizeOrigin(Uri uri)
{
if (uri == null || !uri.IsAbsoluteUri || string.IsNullOrEmpty(uri.Host) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) || uri.UserInfo.Length != 0)
throw new InvalidOperationException("authentication server origin is unavailable or invalid");
var builder = new UriBuilder(uri.Scheme.ToLowerInvariant(), uri.IdnHost.ToLowerInvariant(),
uri.IsDefaultPort ? -1 : uri.Port);
return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/');
}
internal static string Key(string prefix, string origin)
{
string normalized = NormalizeOrigin(new Uri(origin, UriKind.Absolute));
using SHA256 sha = SHA256.Create();
byte[] digest = sha.ComputeHash(Encoding.UTF8.GetBytes(normalized));
var hex = new StringBuilder(digest.Length * 2);
foreach (byte value in digest) hex.Append(value.ToString("x2"));
return prefix + hex;
}
internal static bool IsAutoLogin(string origin) => Read("IsAutoLogin", origin);
internal static bool UseAutoLoginPC(string origin) => Read("StandaloneAutoLogin", origin);
internal static void SetAutoLogin(string origin, bool value) => Write("IsAutoLogin", origin, value);
internal static void SetUseAutoLoginPC(string origin, bool value) => Write("StandaloneAutoLogin", origin, value);
private static bool Read(string setting, string origin) =>
PlayerPrefs.GetInt(Key("BD2LoginV1_" + setting + "_", origin), 0) != 0;
private static void Write(string setting, string origin, bool value)
{
PlayerPrefs.SetInt(Key("BD2LoginV1_" + setting + "_", origin), value ? 1 : 0);
PlayerPrefs.Save();
}
}