38 KiB
账号鉴权(真登录 + 签名 token)实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 账号+密码(PBKDF2)登录/注册,签发 HMAC 签名 token,网关用 token 验签取权威 pid,杜绝伪造 pid/冒充。
Architecture: 新增隔离库 XWorld.Server.Auth(SQLite 账号库 + HMAC token),网关复用同一 Kestrel 暴露 /register、/login HTTP 端点并把 /ws 改为验 token 取 pid(鉴权 opt-in,未配置则保留旧 ?pid= 不回归)。客户端新增 AuthClient 走 HTTPS 登录拿 token,再用 wss://…/ws?token= 连接。
Tech Stack: .NET 10、Microsoft.Data.Sqlite、Rfc2898DeriveBytes(PBKDF2-SHA256)、HMACSHA256、ASP.NET Core Minimal API、xUnit;客户端 Unity/HybridCLR + UnityWebRequest。
设计依据:docs/superpowers/specs/2026-06-24-account-auth-design.md
文件结构
新增(服务端)
Server/Auth/Auth.csproj(AssemblyName=XWorld.Server.Auth,net10.0)Server/Auth/DuplicateUsernameException.cs— 注册查重异常Server/Auth/AccountStore.cs— SQLite 账号库(建表/建号/验密,PBKDF2)Server/Auth/TokenService.cs— HMAC 签名 token(Issue/Verify)Server/Auth/AuthEndpoints.cs—/register、/login端点映射 + 请求/响应 DTOServer/Auth.Tests/Auth.Tests.csproj— xUnit 测试工程Server/Auth.Tests/AccountStoreTests.cs、TokenServiceTests.cs、AuthEndpointsTests.cs
修改(服务端)
Server/Gateway/GameServerHost.cs—/ws验 token、映射端点、StartAsync加鉴权参数Server/Gateway/Gateway.csproj— 引用Auth.csprojServer/Gateway.Runner/Program.cs—--authDbPath/--authSecret/--tokenTtlHours接线Server/Gateway.Tests/— 新增 WS token 鉴权集成测试Server/Server.sln— 加Auth、Auth.Testsdeploy/windows/install-services.ps1+README.md— 网关加鉴权参数
修改(客户端,Unity 无法在本环境编译/运行,须 Editor 人工核对)
Client/Assets/Script/xmain/GlobalData.cs— 加ProductionAuthBaseClient/Assets/Script/xmain/Client/AuthClient.cs(新增)— HTTPS 登录/注册Client/Assets/Script/xmain/Client/CSharpClientApp.cs— 登录/注册/ConnectLobby/自动登录改用 token
Task 1: 建 Auth 库与测试工程骨架
Files:
-
Create:
Server/Auth/Auth.csproj -
Create:
Server/Auth.Tests/Auth.Tests.csproj -
Modify:
Server/Server.sln -
Step 1: 建 Auth.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>XWorld.Server.Auth</AssemblyName>
<RootNamespace>XWorld.Server.Auth</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
</ItemGroup>
</Project>
- Step 2: 建 Auth.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Auth\Auth.csproj" />
</ItemGroup>
</Project>
版本号对齐仓库现有测试工程(参考
Server/Gateway.Tests/Gateway.Tests.csproj的包版本,若不同则改成与之一致)。
- Step 3: 把两个工程加入 sln
Run:
cd Server && dotnet sln Server.sln add Auth/Auth.csproj Auth.Tests/Auth.Tests.csproj
Expected: Project ... added to the solution. ×2
- Step 4: 还原验证
Run: cd Server && dotnet restore Server.sln
Expected: 还原成功(Microsoft.Data.Sqlite 下载)。
- Step 5: Commit
git add Server/Auth Server/Auth.Tests Server/Server.sln
git commit -m "chore(auth): scaffold XWorld.Server.Auth lib + test project"
Task 2: AccountStore —— 建号 + 验密(PBKDF2)
Files:
-
Create:
Server/Auth/DuplicateUsernameException.cs -
Create:
Server/Auth/AccountStore.cs -
Test:
Server/Auth.Tests/AccountStoreTests.cs -
Step 1: 写失败测试
Server/Auth.Tests/AccountStoreTests.cs:
using System;
using System.Text;
using Xunit;
using XWorld.Server.Auth;
namespace XWorld.Server.Auth.Tests
{
public class AccountStoreTests
{
// 用共享缓存的内存库(连接保持打开期间存活)
static AccountStore NewStore() => new AccountStore("Data Source=acct_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
[Fact]
public void CreateAccount_returns_positive_id()
{
using var s = NewStore();
int id = s.CreateAccount("alice", "pw123456");
Assert.True(id > 0);
}
[Fact]
public void CreateAccount_duplicate_username_rejected_case_insensitive()
{
using var s = NewStore();
s.CreateAccount("Bob", "pw123456");
Assert.Throws<DuplicateUsernameException>(() => s.CreateAccount("bob", "other123"));
}
[Fact]
public void Verify_correct_password_returns_same_id()
{
using var s = NewStore();
int id = s.CreateAccount("carol", "secret-pw");
Assert.Equal(id, s.VerifyCredentials("carol", "secret-pw"));
}
[Fact]
public void Verify_wrong_password_returns_null()
{
using var s = NewStore();
s.CreateAccount("dave", "secret-pw");
Assert.Null(s.VerifyCredentials("dave", "WRONG"));
}
[Fact]
public void Verify_unknown_user_returns_null()
{
using var s = NewStore();
Assert.Null(s.VerifyCredentials("nobody", "x"));
}
[Fact]
public void Password_not_stored_in_plaintext()
{
using var s = NewStore();
s.CreateAccount("erin", "plaintext-marker");
Assert.False(s.DebugRawContains(Encoding.UTF8.GetBytes("plaintext-marker")));
}
}
}
- Step 2: 跑测试确认失败
Run: cd Server && dotnet test Auth.Tests/Auth.Tests.csproj
Expected: 编译失败(AccountStore/DuplicateUsernameException 未定义)。
- Step 3: 写 DuplicateUsernameException
Server/Auth/DuplicateUsernameException.cs:
using System;
namespace XWorld.Server.Auth
{
public sealed class DuplicateUsernameException : Exception
{
public DuplicateUsernameException(string username)
: base("username already taken: " + username) { }
}
}
- Step 4: 写 AccountStore
Server/Auth/AccountStore.cs:
using System;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Data.Sqlite;
namespace XWorld.Server.Auth
{
// SQLite 账号库。保持单条长连接(内存库测试 + 文件库生产均可),低频鉴权用 lock 串行化。
public sealed class AccountStore : IDisposable
{
const int SaltLen = 16;
const int HashLen = 32;
const int Iterations = 100_000;
readonly SqliteConnection _conn;
readonly object _lock = new object();
// dbPath: 文件路径(生产) 或完整连接串(以 "Data Source=" 开头,测试用内存库)
public AccountStore(string dbPath)
{
string cs = dbPath.StartsWith("Data Source=", StringComparison.OrdinalIgnoreCase)
? dbPath
: "Data Source=" + dbPath;
_conn = new SqliteConnection(cs);
_conn.Open();
Exec("PRAGMA journal_mode=WAL;");
Exec(@"CREATE TABLE IF NOT EXISTS accounts(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
pwd_hash BLOB NOT NULL,
salt BLOB NOT NULL,
iter INTEGER NOT NULL,
created_at INTEGER NOT NULL);");
}
public int CreateAccount(string username, string password)
{
if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("username");
if (string.IsNullOrEmpty(password)) throw new ArgumentException("password");
byte[] salt = RandomNumberGenerator.GetBytes(SaltLen);
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, HashLen);
lock (_lock)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "INSERT INTO accounts(username,pwd_hash,salt,iter,created_at) VALUES($u,$h,$s,$i,$t);";
cmd.Parameters.AddWithValue("$u", username);
cmd.Parameters.AddWithValue("$h", hash);
cmd.Parameters.AddWithValue("$s", salt);
cmd.Parameters.AddWithValue("$i", Iterations);
cmd.Parameters.AddWithValue("$t", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
try { cmd.ExecuteNonQuery(); }
catch (SqliteException e) when (e.SqliteErrorCode == 19) // SQLITE_CONSTRAINT (UNIQUE)
{ throw new DuplicateUsernameException(username); }
using var idCmd = _conn.CreateCommand();
idCmd.CommandText = "SELECT last_insert_rowid();";
return (int)(long)idCmd.ExecuteScalar();
}
}
public int? VerifyCredentials(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) return null;
lock (_lock)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT id,pwd_hash,salt,iter FROM accounts WHERE username=$u COLLATE NOCASE;";
cmd.Parameters.AddWithValue("$u", username);
using var r = cmd.ExecuteReader();
if (!r.Read()) return null;
int id = (int)r.GetInt64(0);
byte[] stored = (byte[])r["pwd_hash"];
byte[] salt = (byte[])r["salt"];
int iter = (int)r.GetInt64(3);
byte[] cand = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password), salt, iter, HashAlgorithmName.SHA256, stored.Length);
return CryptographicOperations.FixedTimeEquals(cand, stored) ? id : (int?)null;
}
}
// 测试辅助:原始库字节是否包含某序列(验证明文不入库)
public bool DebugRawContains(byte[] needle)
{
lock (_lock)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT pwd_hash,salt FROM accounts;";
using var r = cmd.ExecuteReader();
while (r.Read())
{
if (Contains((byte[])r["pwd_hash"], needle) || Contains((byte[])r["salt"], needle))
return true;
}
return false;
}
}
static bool Contains(byte[] hay, byte[] needle)
{
if (needle.Length == 0 || hay.Length < needle.Length) return false;
for (int i = 0; i <= hay.Length - needle.Length; i++)
{
bool ok = true;
for (int j = 0; j < needle.Length; j++) if (hay[i + j] != needle[j]) { ok = false; break; }
if (ok) return true;
}
return false;
}
void Exec(string sql)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
public void Dispose() => _conn?.Dispose();
}
}
- Step 5: 跑测试确认通过
Run: cd Server && dotnet test Auth.Tests/Auth.Tests.csproj
Expected: 6 passed。
- Step 6: Commit
git add Server/Auth/AccountStore.cs Server/Auth/DuplicateUsernameException.cs Server/Auth.Tests/AccountStoreTests.cs
git commit -m "feat(auth): SQLite AccountStore with PBKDF2 password hashing"
Task 3: TokenService —— HMAC 签名 token
Files:
-
Create:
Server/Auth/TokenService.cs -
Test:
Server/Auth.Tests/TokenServiceTests.cs -
Step 1: 写失败测试
Server/Auth.Tests/TokenServiceTests.cs:
using System;
using Xunit;
using XWorld.Server.Auth;
namespace XWorld.Server.Auth.Tests
{
public class TokenServiceTests
{
static byte[] Key() => new byte[32]; // 全零密钥,测试用固定值
[Fact]
public void Issue_then_verify_roundtrip_returns_pid()
{
var t = new TokenService(Key());
string tok = t.Issue(4242, TimeSpan.FromHours(1));
Assert.True(t.Verify(tok, out int pid));
Assert.Equal(4242, pid);
}
[Fact]
public void Tampered_token_rejected()
{
var t = new TokenService(Key());
string tok = t.Issue(7, TimeSpan.FromHours(1));
string bad = tok.Substring(0, tok.Length - 1) + (tok[tok.Length - 1] == 'A' ? 'B' : 'A');
Assert.False(t.Verify(bad, out _));
}
[Fact]
public void Expired_token_rejected()
{
var t = new TokenService(Key());
string tok = t.Issue(7, TimeSpan.FromSeconds(-1)); // 已过期
Assert.False(t.Verify(tok, out _));
}
[Fact]
public void Wrong_key_rejected()
{
var signer = new TokenService(Key());
string tok = signer.Issue(7, TimeSpan.FromHours(1));
var other = new TokenService(new byte[32] { 1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 });
Assert.False(other.Verify(tok, out _));
}
[Fact]
public void Garbage_token_rejected()
{
var t = new TokenService(Key());
Assert.False(t.Verify("not-a-token", out _));
Assert.False(t.Verify("", out _));
Assert.False(t.Verify(null, out _));
}
}
}
- Step 2: 跑测试确认失败
Run: cd Server && dotnet test Auth.Tests/Auth.Tests.csproj --filter TokenServiceTests
Expected: 编译失败(TokenService 未定义)。
- Step 3: 写 TokenService
Server/Auth/TokenService.cs:
using System;
using System.Security.Cryptography;
using System.Text;
namespace XWorld.Server.Auth
{
// 无状态 HMAC-SHA256 token。格式: "v1.{pid}.{expUnix}.{base64url(hmac(payload))}"
// 其中 payload = "v1.{pid}.{expUnix}"。
public sealed class TokenService
{
readonly byte[] _key;
public TokenService(byte[] key)
{
if (key == null || key.Length < 16) throw new ArgumentException("key too short");
_key = key;
}
public string Issue(int pid, TimeSpan ttl)
{
long exp = DateTimeOffset.UtcNow.Add(ttl).ToUnixTimeSeconds();
string payload = "v1." + pid + "." + exp;
return payload + "." + B64Url(Sign(payload));
}
public bool Verify(string token, out int pid)
{
pid = 0;
if (string.IsNullOrEmpty(token)) return false;
int lastDot = token.LastIndexOf('.');
if (lastDot <= 0) return false;
string payload = token.Substring(0, lastDot);
string sig = token.Substring(lastDot + 1);
byte[] expected = Sign(payload);
byte[] got;
try { got = FromB64Url(sig); } catch { return false; }
if (!CryptographicOperations.FixedTimeEquals(expected, got)) return false;
string[] parts = payload.Split('.'); // ["v1", pid, exp]
if (parts.Length != 3 || parts[0] != "v1") return false;
if (!int.TryParse(parts[1], out pid)) return false;
if (!long.TryParse(parts[2], out long exp)) return false;
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > exp) return false;
return true;
}
byte[] Sign(string payload)
{
using var h = new HMACSHA256(_key);
return h.ComputeHash(Encoding.UTF8.GetBytes(payload));
}
static string B64Url(byte[] b) =>
Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
static byte[] FromB64Url(string s)
{
string b = s.Replace('-', '+').Replace('_', '/');
switch (b.Length % 4) { case 2: b += "=="; break; case 3: b += "="; break; }
return Convert.FromBase64String(b);
}
}
}
- Step 4: 跑测试确认通过
Run: cd Server && dotnet test Auth.Tests/Auth.Tests.csproj --filter TokenServiceTests
Expected: 5 passed。
- Step 5: Commit
git add Server/Auth/TokenService.cs Server/Auth.Tests/TokenServiceTests.cs
git commit -m "feat(auth): stateless HMAC token service"
Task 4: AuthEndpoints —— /register、/login
Files:
-
Create:
Server/Auth/AuthEndpoints.cs -
Test:
Server/Auth.Tests/AuthEndpointsTests.cs -
Step 1: 写失败测试(真 Kestrel + HttpClient)
Server/Auth.Tests/AuthEndpointsTests.cs:
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using System.Linq;
using Xunit;
using XWorld.Server.Auth;
namespace XWorld.Server.Auth.Tests
{
public class AuthEndpointsTests : IAsyncLifetime
{
WebApplication _app;
HttpClient _http;
AccountStore _store;
public async Task InitializeAsync()
{
_store = new AccountStore("Data Source=ep_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
var tokens = new TokenService(new byte[32]);
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(o => o.Listen(System.Net.IPAddress.Loopback, 0));
_app = builder.Build();
AuthEndpoints.Map(_app, _store, tokens, TimeSpan.FromHours(1));
await _app.StartAsync();
string baseUrl = _app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>().Addresses.First();
_http = new HttpClient { BaseAddress = new Uri(baseUrl) };
}
public async Task DisposeAsync() { _http?.Dispose(); _store?.Dispose(); await _app.DisposeAsync(); }
[Fact]
public async Task Register_then_login_yields_token_and_pid()
{
var reg = await _http.PostAsJsonAsync("/register", new { username = "neo", password = "redpill1" });
Assert.Equal(HttpStatusCode.OK, reg.StatusCode);
var regBody = await reg.Content.ReadFromJsonAsync<AuthEndpoints.AuthResponse>();
Assert.True(regBody.pid > 0);
Assert.False(string.IsNullOrEmpty(regBody.token));
var login = await _http.PostAsJsonAsync("/login", new { username = "neo", password = "redpill1" });
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
var loginBody = await login.Content.ReadFromJsonAsync<AuthEndpoints.AuthResponse>();
Assert.Equal(regBody.pid, loginBody.pid);
}
[Fact]
public async Task Register_duplicate_returns_409()
{
await _http.PostAsJsonAsync("/register", new { username = "dup", password = "pw123456" });
var again = await _http.PostAsJsonAsync("/register", new { username = "dup", password = "pw123456" });
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
}
[Fact]
public async Task Login_wrong_password_returns_401()
{
await _http.PostAsJsonAsync("/register", new { username = "trinity", password = "pw123456" });
var bad = await _http.PostAsJsonAsync("/login", new { username = "trinity", password = "WRONG" });
Assert.Equal(HttpStatusCode.Unauthorized, bad.StatusCode);
}
[Fact]
public async Task Register_empty_fields_returns_400()
{
var r = await _http.PostAsJsonAsync("/register", new { username = "", password = "" });
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
}
}
}
- Step 2: 跑测试确认失败
Run: cd Server && dotnet test Auth.Tests/Auth.Tests.csproj --filter AuthEndpointsTests
Expected: 编译失败(AuthEndpoints 未定义)。
若
Auth.Tests缺 ASP.NET 引用导致WebApplication不可用,在Auth.Tests.csproj的<Project Sdk=...>改为Microsoft.NET.Sdk.Web或加<FrameworkReference Include="Microsoft.AspNetCore.App" />(参考Server/Gateway.Tests的写法)。
- Step 3: 写 AuthEndpoints
Server/Auth/AuthEndpoints.cs:
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace XWorld.Server.Auth
{
public static class AuthEndpoints
{
public sealed class AuthRequest { public string username { get; set; } public string password { get; set; } }
public sealed class AuthResponse { public string token { get; set; } public int pid { get; set; } }
public static void Map(IEndpointRouteBuilder app, AccountStore store, TokenService tokens, TimeSpan tokenTtl)
{
app.MapPost("/register", (AuthRequest req) =>
{
if (req == null || string.IsNullOrWhiteSpace(req.username) || string.IsNullOrEmpty(req.password))
return Results.BadRequest(new { error = "invalid_input" });
int pid;
try { pid = store.CreateAccount(req.username, req.password); }
catch (DuplicateUsernameException) { return Results.Conflict(new { error = "username_taken" }); }
catch (ArgumentException) { return Results.BadRequest(new { error = "invalid_input" }); }
return Results.Ok(new AuthResponse { token = tokens.Issue(pid, tokenTtl), pid = pid });
});
app.MapPost("/login", (AuthRequest req) =>
{
if (req == null || string.IsNullOrWhiteSpace(req.username) || string.IsNullOrEmpty(req.password))
return Results.BadRequest(new { error = "invalid_input" });
int? pid = store.VerifyCredentials(req.username, req.password);
if (pid == null) return Results.Json(new { error = "invalid_credentials" }, statusCode: 401);
return Results.Ok(new AuthResponse { token = tokens.Issue(pid.Value, tokenTtl), pid = pid.Value });
});
}
}
}
- Step 4: 跑测试确认通过
Run: cd Server && dotnet test Auth.Tests/Auth.Tests.csproj --filter AuthEndpointsTests
Expected: 4 passed。
- Step 5: Commit
git add Server/Auth/AuthEndpoints.cs Server/Auth.Tests/AuthEndpointsTests.cs
git commit -m "feat(auth): /register and /login HTTP endpoints"
Task 5: 网关接入 —— /ws 验 token(opt-in)+ 映射端点
Files:
-
Modify:
Server/Gateway/Gateway.csproj(引用 Auth) -
Modify:
Server/Gateway/GameServerHost.cs:32-77(Start 参数 + 端点 + /ws) -
Test:
Server/Gateway.Tests/AuthWsTests.cs(新增) -
Step 1: Gateway 引用 Auth
Server/Gateway/Gateway.csproj 的 <ItemGroup> 加:
<ProjectReference Include="..\Auth\Auth.csproj" />
- Step 2: 写失败测试(WS token 鉴权)
Server/Gateway.Tests/AuthWsTests.cs:
using System;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using XWorld.Server.Auth;
using XWorld.Server.Gateway;
namespace XWorld.Server.Gateway.Tests
{
public class AuthWsTests
{
static string GamesRoot() => System.IO.Path.Combine(AppContext.BaseDirectory, "TestGames");
[Fact]
public async Task Ws_with_valid_token_connects()
{
var key = new byte[32];
var host = new GameServerHost();
await host.StartAsync(GamesRoot(), 100, authSecret: key,
authDbPath: "Data Source=wsok_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
try
{
string token = new TokenService(key).Issue(1234, TimeSpan.FromHours(1));
using var ws = new ClientWebSocket();
var uri = new Uri(host.WsBaseUrl + "/ws?token=" + token);
await ws.ConnectAsync(uri, CancellationToken.None); // 不抛即握手成功
Assert.Equal(WebSocketState.Open, ws.State);
}
finally { await host.StopAsync(); }
}
[Fact]
public async Task Ws_with_invalid_token_rejected()
{
var key = new byte[32];
var host = new GameServerHost();
await host.StartAsync(GamesRoot(), 100, authSecret: key,
authDbPath: "Data Source=wsbad_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
try
{
using var ws = new ClientWebSocket();
var uri = new Uri(host.WsBaseUrl + "/ws?token=garbage");
await Assert.ThrowsAnyAsync<WebSocketException>(() => ws.ConnectAsync(uri, CancellationToken.None));
}
finally { await host.StopAsync(); }
}
}
}
GamesRoot()路径按Server/Gateway.Tests现有用例对 TestGames 的引用方式对齐(参考该工程已有 WS 集成测试如何定位 gamesRoot)。
- Step 3: 跑测试确认失败
Run: cd Server && dotnet test Gateway.Tests/Gateway.Tests.csproj --filter AuthWsTests
Expected: 编译失败(StartAsync 无 authSecret/authDbPath 参数)。
- Step 4: 改 GameServerHost.StartAsync 签名与实现
在 Server/Gateway/GameServerHost.cs:
(a) 顶部加 using XWorld.Server.Auth;(已可见 XWorld.Framework.Protocol/XWorld.Server.Host)。
(b) 类内加字段:
private AccountStore _accounts;
(c) StartAsync 方法签名加两个可选参数(放在现有可选参数之后):
int devDiscoveryPort = DevDiscovery.Port,
byte[] authSecret = null, string authDbPath = null)
(d) 在 var app = builder.Build(); 之前,构造鉴权组件:
TokenService tokens = null;
if (authSecret != null)
{
tokens = new TokenService(authSecret);
_accounts = new AccountStore(string.IsNullOrEmpty(authDbPath)
? "Data Source=accounts.db" : authDbPath);
}
(e) app.UseWebSockets(); 之后、/ws 中间件之前,映射鉴权端点(仅在启用时):
if (tokens != null)
AuthEndpoints.Map(app, _accounts, tokens, TimeSpan.FromDays(7));
(f) 把 /ws 中间件里取 pid 的那段(GameServerHost.cs:53-57)替换为:
int pid;
if (tokens != null)
{
string token = ctx.Request.Query["token"];
if (!tokens.Verify(token, out pid))
{
ctx.Response.StatusCode = 401;
return;
}
}
else if (!int.TryParse(ctx.Request.Query["pid"], out pid))
{
ctx.Response.StatusCode = 400;
return;
}
(g) StopAsync 里释放账号库(在 _app.DisposeAsync() 之后或之前均可):
_accounts?.Dispose();
_accounts = null;
- Step 5: 跑测试确认通过 + 全量不回归
Run: cd Server && dotnet test Gateway.Tests/Gateway.Tests.csproj --filter AuthWsTests
Expected: 2 passed。
Run: cd Server && dotnet test Server.sln
Expected: 全绿(旧用例用 ?pid= 走「未启用鉴权」分支,不回归)。
- Step 6: Commit
git add Server/Gateway/Gateway.csproj Server/Gateway/GameServerHost.cs Server/Gateway.Tests/AuthWsTests.cs
git commit -m "feat(auth): gateway verifies WS token for pid (opt-in), maps auth endpoints"
Task 6: Gateway.Runner 接线 + 部署脚本
Files:
-
Modify:
Server/Gateway.Runner/Program.cs -
Modify:
deploy/windows/install-services.ps1、deploy/windows/README.md -
Step 1: Runner 解析鉴权参数并传入
Server/Gateway.Runner/Program.cs,在 host.StartAsync(...) 之前加:
string authDbPath = GetArg(args, "--authDbPath");
string authSecretB64 = GetArg(args, "--authSecret");
byte[] authSecret = null;
if (!string.IsNullOrEmpty(authDbPath))
{
if (!string.IsNullOrEmpty(authSecretB64))
authSecret = Convert.FromBase64String(authSecretB64);
else
{
// 密钥文件在 DB 同目录 auth.key,不存在则生成 32B 并持久化
string keyPath = System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(authDbPath)) ?? ".", "auth.key");
if (System.IO.File.Exists(keyPath))
authSecret = Convert.FromBase64String(System.IO.File.ReadAllText(keyPath).Trim());
else
{
authSecret = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32);
System.IO.File.WriteAllText(keyPath, Convert.ToBase64String(authSecret));
}
}
}
并把 StartAsync 调用末尾补 , authSecret: authSecret, authDbPath: authDbPath。
- Step 2: 构建验证
Run: cd Server && dotnet build Gateway.Runner/Gateway.Runner.csproj -c Release
Expected: 0 错误。
- Step 3: 部署脚本加鉴权参数
deploy/windows/install-services.ps1 的网关 AppParameters 改为包含鉴权(DB 落在 C:\xworld\):
& $NssmPath set XWorldGateway AppParameters "--gamesRoot `"$GamesRoot`" --port $GatewayPort --authDbPath `"$AuthDbPath`""
并在 param(...) 加 [string]$AuthDbPath = "C:\xworld\accounts.db"。
- Step 4: README 补一节
deploy/windows/README.md 加:网关现需 --authDbPath(首启自动在同目录生成 auth.key,务必备份),/register、/login 经 Caddy 同域 https://game.xworld.link/ 暴露,无需额外反代规则(已整域反代到 5005)。
- Step 5: Commit
git add Server/Gateway.Runner/Program.cs deploy/windows/install-services.ps1 deploy/windows/README.md
git commit -m "feat(auth): wire auth into Gateway.Runner + Windows deploy"
Task 7: 客户端 AuthClient(Unity,须 Editor 人工核对)
Files:
-
Modify:
Client/Assets/Script/xmain/GlobalData.cs -
Create:
Client/Assets/Script/xmain/Client/AuthClient.cs -
Step 1: GlobalData 加常量
Client/Assets/Script/xmain/GlobalData.cs,在 ProductionGatewayUrl 之后加:
public const string ProductionAuthBase = "https://game.xworld.link";//登录/注册 HTTP 端点(与 WSS 同主机,经 Caddy)
- Step 2: 写 AuthClient
Client/Assets/Script/xmain/Client/AuthClient.cs:
using System;
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
namespace XGame
{
// 账号鉴权 HTTP 客户端:POST /login、/register,拿 {token,pid}。
public static class AuthClient
{
[Serializable] class AuthReq { public string username; public string password; }
[Serializable] class AuthResp { public string token; public int pid; }
public static IEnumerator Login(string user, string pwd, Action<bool, string, int, string> done)
=> Post("/login", user, pwd, done);
public static IEnumerator Register(string user, string pwd, Action<bool, string, int, string> done)
=> Post("/register", user, pwd, done);
static IEnumerator Post(string path, string user, string pwd, Action<bool, string, int, string> done)
{
string url = GlobalData.ProductionAuthBase + path;
string body = JsonUtility.ToJson(new AuthReq { username = user, password = pwd });
using (var req = new UnityWebRequest(url, "POST"))
{
req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
string err = req.responseCode == 401 ? "账号或密码错误"
: req.responseCode == 409 ? "用户名已被占用"
: ("网络错误: " + req.error);
done(false, null, 0, err);
yield break;
}
AuthResp resp = null;
try { resp = JsonUtility.FromJson<AuthResp>(req.downloadHandler.text); } catch { }
if (resp == null || string.IsNullOrEmpty(resp.token))
{
done(false, null, 0, "响应解析失败");
yield break;
}
done(true, resp.token, resp.pid, null);
}
}
}
}
- Step 3: Unity 人工核对
在 Unity Editor 打开工程,确认 0 编译错误(AuthClient 属 XWorld.Link 程序集)。本环境用 IDE 诊断辅助检查 AuthClient.cs、GlobalData.cs 无错。
- Step 4: Commit
git add Client/Assets/Script/xmain/GlobalData.cs Client/Assets/Script/xmain/Client/AuthClient.cs
git commit -m "feat(auth): client AuthClient (login/register over HTTPS)"
Task 8: 客户端登录流程改用 token(Unity,须 Editor 人工核对)
Files:
-
Modify:
Client/Assets/Script/xmain/Client/CSharpClientApp.cs -
Step 1: OnLoginClicked 改为走 AuthClient
把 OnLoginClicked(CSharpClientApp.cs:171 起)校验通过后的本地直进逻辑,改为协程调用 AuthClient.Login:
// 校验非空后:
XWCoroutine.StartCo(AuthClient.Login(account, password, (ok, token, pid, err) =>
{
if (!ok) { ShowLoginError(err); return; }
XData.SetString(LoginTokenKey, token);
GlobalData.Token = token;
m_pid = pid; // 新增字段:当前已鉴权 pid
if (rememberPassword) { XData.SetString(LoginAccountKey, account); XData.SetString(LoginPasswordKey, password); }
SaveSession(account, account);
EnterLobby();
}));
在类里加字段
private int m_pid;。OnRegisterClicked同理改调AuthClient.Register(成功后与登录相同:存 token、设 pid、EnterLobby)。
- Step 2: ConnectLobby 改用 token
ConnectLobby(CSharpClientApp.cs:290)里:去掉 ?pid=/WithPlayerId 拼接,改为用已存 token 拼网关 URL:
string token = GlobalData.Token;
if (string.IsNullOrEmpty(token)) token = XData.GetString(LoginTokenKey, "");
string node = GlobalData.CurNode; // 形如 wss://game.xworld.link/ws
if (!string.IsNullOrEmpty(token) &&
(node.StartsWith("ws://", StringComparison.OrdinalIgnoreCase) ||
node.StartsWith("wss://", StringComparison.OrdinalIgnoreCase)))
{
string sep = node.Contains("?") ? "&" : "?";
string url = node + sep + "token=" + UnityWebRequest.EscapeURL(token);
SetLobbyStatus("Connecting lobby...");
lsocket.connect(url, 0, OnLobbyConnected);
return;
}
// 无 token:回退(本地 dev ?pid= 路径保留,便于内网联调)
// ……保留原有 NormalizeNode/SplitHostPort 分支……
GetStablePlayerId/WithPlayerId在生产路径不再使用(dev?pid=分支可保留)。m_pid用于后续业务需要本地 pid 之处。
- Step 3: 自动登录(可选增强)
Start/初始化里:若 XData.GetString(LoginTokenKey,"") 非空,可直接 GlobalData.Token=该值 并尝试进大厅;服务端若 401(token 过期)则回登录页让用户重输。(最小实现:本步可仅在 OnLobbyConnected 失败为 401 类错误时清 LoginTokenKey 并弹登录页。)
- Step 4: Unity 人工核对
IDE 诊断 CSharpClientApp.cs 无错;Editor 编译通过。真机/Editor 端到端:注册→登录→wss://…/ws?token=→大厅连上;错密码提示 401;token 过期回登录页。
- Step 5: Commit
git add Client/Assets/Script/xmain/Client/CSharpClientApp.cs
git commit -m "feat(auth): client login/register via token, connect ws with ?token="
端到端验收
- 服务端:
cd Server && dotnet test Server.sln全绿(含新增 Auth.Tests / AuthWsTests,旧用例不回归)。 - 部署:
Gateway.Runner --authDbPath …启动 →auth.key生成 →POST https://game.xworld.link/register得 token →wss://game.xworld.link/ws?token=连上;篡改/缺 token → 401。 - 客户端(Editor/真机):注册→登录→进大厅;错密码 401 提示;伪造/缺 token 连不上。
注意事项
- 客户端任务(7、8)本环境无 Unity 无法编译/运行,按项目惯例交付「完整代码 + IDE 诊断 + Editor 人工核对」。
- 一次性运维:生产首启生成
auth.key,必须备份(丢失则所有已发 token 失效,需重登)。 - 后续(非本计划):主动吊销/踢人、登录限流/锁定、找回密码、token 刷新。