Files
AIC-Project/docs/superpowers/plans/2026-06-24-account-auth.md
T
2026-07-10 10:24:29 +08:00

1019 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 账号鉴权(真登录 + 签名 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 签名 tokenIssue/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
<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**
```xml
<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:
```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<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`
```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<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`
```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 验 tokenopt-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>` 加:
```xml
<ProjectReference Include="..\Auth\Auth.csproj" />
```
- [ ] **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<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) 类内加字段:
```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: 客户端 AuthClientUnity,须 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<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**
```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: 客户端登录流程改用 tokenUnity,须 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 刷新。