1019 lines
38 KiB
Markdown
1019 lines
38 KiB
Markdown
# 账号鉴权(真登录 + 签名 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
|
||
<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 验 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>` 加:
|
||
```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: 客户端 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<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: 客户端登录流程改用 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 刷新。
|