# 账号鉴权(真登录 + 签名 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 刷新。