# 账号鉴权(真登录 + 签名 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` 端点映射 + 请求/响应 DTO - `Server/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.csproj` - `Server/Gateway.Runner/Program.cs` — `--authDbPath`/`--authSecret`/`--tokenTtlHours` 接线 - `Server/Gateway.Tests/` — 新增 WS token 鉴权集成测试 - `Server/Server.sln` — 加 `Auth`、`Auth.Tests` - `deploy/windows/install-services.ps1` + `README.md` — 网关加鉴权参数 **修改(客户端,Unity 无法在本环境编译/运行,须 Editor 人工核对)** - `Client/Assets/Script/xmain/GlobalData.cs` — 加 `ProductionAuthBase` - `Client/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** ```xml net10.0 disable disable XWorld.Server.Auth XWorld.Server.Auth ``` - [ ] **Step 2: 建 Auth.Tests.csproj** ```xml net10.0 disable disable false ``` > 版本号对齐仓库现有测试工程(参考 `Server/Gateway.Tests/Gateway.Tests.csproj` 的包版本,若不同则改成与之一致)。 - [ ] **Step 3: 把两个工程加入 sln** Run: ```bash 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** ```bash 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`: ```csharp 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(() => 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`: ```csharp 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`: ```csharp 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** ```bash 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`: ```csharp 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`: ```csharp 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** ```bash 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`: ```csharp 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() .Features.Get().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(); 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(); 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` 的 `` 改为 `Microsoft.NET.Sdk.Web` 或加 ``(参考 `Server/Gateway.Tests` 的写法)。 - [ ] **Step 3: 写 AuthEndpoints** `Server/Auth/AuthEndpoints.cs`: ```csharp 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** ```bash 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` 的 `` 加: ```xml ``` - [ ] **Step 2: 写失败测试(WS token 鉴权)** `Server/Gateway.Tests/AuthWsTests.cs`: ```csharp 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(() => 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) 类内加字段: ```csharp private AccountStore _accounts; ``` (c) `StartAsync` 方法签名加两个可选参数(放在现有可选参数之后): ```csharp int devDiscoveryPort = DevDiscovery.Port, byte[] authSecret = null, string authDbPath = null) ``` (d) 在 `var app = builder.Build();` 之前,构造鉴权组件: ```csharp 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` 中间件之前,映射鉴权端点(仅在启用时): ```csharp if (tokens != null) AuthEndpoints.Map(app, _accounts, tokens, TimeSpan.FromDays(7)); ``` (f) 把 `/ws` 中间件里取 pid 的那段(`GameServerHost.cs:53-57`)替换为: ```csharp 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()` 之后或之前均可): ```csharp _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** ```bash 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(...)` 之前加: ```csharp 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\`): ```powershell & $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** ```bash 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` 之后加: ```csharp public const string ProductionAuthBase = "https://game.xworld.link";//登录/注册 HTTP 端点(与 WSS 同主机,经 Caddy) ``` - [ ] **Step 2: 写 AuthClient** `Client/Assets/Script/xmain/Client/AuthClient.cs`: ```csharp 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 done) => Post("/login", user, pwd, done); public static IEnumerator Register(string user, string pwd, Action done) => Post("/register", user, pwd, done); static IEnumerator Post(string path, string user, string pwd, Action 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(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** ```bash 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`: ```csharp // 校验非空后: 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: ```csharp 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** ```bash 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 刷新。