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

38 KiB
Raw Blame History

账号鉴权(真登录 + 签名 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.AuthSQLite 账号库 + 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、Rfc2898DeriveBytesPBKDF2-SHA256)、HMACSHA256、ASP.NET Core Minimal API、xUnit;客户端 Unity/HybridCLR + UnityWebRequest。

设计依据:docs/superpowers/specs/2026-06-24-account-auth-design.md


文件结构

新增(服务端)

  • Server/Auth/Auth.csprojAssemblyName=XWorld.Server.Authnet10.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.csTokenServiceTests.csAuthEndpointsTests.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 — 加 AuthAuth.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

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>disable</Nullable>
    <ImplicitUsings>disable</ImplicitUsings>
    <AssemblyName>XWorld.Server.Auth</AssemblyName>
    <RootNamespace>XWorld.Server.Auth</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
  </ItemGroup>
</Project>
  • Step 2: 建 Auth.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>disable</Nullable>
    <ImplicitUsings>disable</ImplicitUsings>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
    <PackageReference Include="xunit" Version="2.9.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\Auth\Auth.csproj" />
  </ItemGroup>
</Project>

版本号对齐仓库现有测试工程(参考 Server/Gateway.Tests/Gateway.Tests.csproj 的包版本,若不同则改成与之一致)。

  • Step 3: 把两个工程加入 sln

Run:

cd Server && dotnet sln Server.sln add Auth/Auth.csproj Auth.Tests/Auth.Tests.csproj

Expected: Project ... added to the solution. ×2

  • Step 4: 还原验证

Run: cd Server && dotnet restore Server.sln Expected: 还原成功(Microsoft.Data.Sqlite 下载)。

  • Step 5: Commit
git add Server/Auth Server/Auth.Tests Server/Server.sln
git commit -m "chore(auth): scaffold XWorld.Server.Auth lib + test project"

Task 2: AccountStore —— 建号 + 验密(PBKDF2

Files:

  • Create: Server/Auth/DuplicateUsernameException.cs

  • Create: Server/Auth/AccountStore.cs

  • Test: Server/Auth.Tests/AccountStoreTests.cs

  • Step 1: 写失败测试

Server/Auth.Tests/AccountStoreTests.cs

using System;
using System.Text;
using Xunit;
using XWorld.Server.Auth;

namespace XWorld.Server.Auth.Tests
{
    public class AccountStoreTests
    {
        // 用共享缓存的内存库(连接保持打开期间存活)
        static AccountStore NewStore() => new AccountStore("Data Source=acct_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");

        [Fact]
        public void CreateAccount_returns_positive_id()
        {
            using var s = NewStore();
            int id = s.CreateAccount("alice", "pw123456");
            Assert.True(id > 0);
        }

        [Fact]
        public void CreateAccount_duplicate_username_rejected_case_insensitive()
        {
            using var s = NewStore();
            s.CreateAccount("Bob", "pw123456");
            Assert.Throws<DuplicateUsernameException>(() => s.CreateAccount("bob", "other123"));
        }

        [Fact]
        public void Verify_correct_password_returns_same_id()
        {
            using var s = NewStore();
            int id = s.CreateAccount("carol", "secret-pw");
            Assert.Equal(id, s.VerifyCredentials("carol", "secret-pw"));
        }

        [Fact]
        public void Verify_wrong_password_returns_null()
        {
            using var s = NewStore();
            s.CreateAccount("dave", "secret-pw");
            Assert.Null(s.VerifyCredentials("dave", "WRONG"));
        }

        [Fact]
        public void Verify_unknown_user_returns_null()
        {
            using var s = NewStore();
            Assert.Null(s.VerifyCredentials("nobody", "x"));
        }

        [Fact]
        public void Password_not_stored_in_plaintext()
        {
            using var s = NewStore();
            s.CreateAccount("erin", "plaintext-marker");
            Assert.False(s.DebugRawContains(Encoding.UTF8.GetBytes("plaintext-marker")));
        }
    }
}
  • Step 2: 跑测试确认失败

Run: cd Server && dotnet test Auth.Tests/Auth.Tests.csproj Expected: 编译失败(AccountStore/DuplicateUsernameException 未定义)。

  • Step 3: 写 DuplicateUsernameException

Server/Auth/DuplicateUsernameException.cs

using System;

namespace XWorld.Server.Auth
{
    public sealed class DuplicateUsernameException : Exception
    {
        public DuplicateUsernameException(string username)
            : base("username already taken: " + username) { }
    }
}
  • Step 4: 写 AccountStore

Server/Auth/AccountStore.cs

using System;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Data.Sqlite;

namespace XWorld.Server.Auth
{
    // SQLite 账号库。保持单条长连接(内存库测试 + 文件库生产均可),低频鉴权用 lock 串行化。
    public sealed class AccountStore : IDisposable
    {
        const int SaltLen = 16;
        const int HashLen = 32;
        const int Iterations = 100_000;

        readonly SqliteConnection _conn;
        readonly object _lock = new object();

        // dbPath: 文件路径(生产) 或完整连接串(以 "Data Source=" 开头,测试用内存库)
        public AccountStore(string dbPath)
        {
            string cs = dbPath.StartsWith("Data Source=", StringComparison.OrdinalIgnoreCase)
                ? dbPath
                : "Data Source=" + dbPath;
            _conn = new SqliteConnection(cs);
            _conn.Open();
            Exec("PRAGMA journal_mode=WAL;");
            Exec(@"CREATE TABLE IF NOT EXISTS accounts(
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                username TEXT UNIQUE NOT NULL COLLATE NOCASE,
                pwd_hash BLOB NOT NULL,
                salt BLOB NOT NULL,
                iter INTEGER NOT NULL,
                created_at INTEGER NOT NULL);");
        }

        public int CreateAccount(string username, string password)
        {
            if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("username");
            if (string.IsNullOrEmpty(password)) throw new ArgumentException("password");
            byte[] salt = RandomNumberGenerator.GetBytes(SaltLen);
            byte[] hash = Rfc2898DeriveBytes.Pbkdf2(
                Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, HashLen);
            lock (_lock)
            {
                using var cmd = _conn.CreateCommand();
                cmd.CommandText = "INSERT INTO accounts(username,pwd_hash,salt,iter,created_at) VALUES($u,$h,$s,$i,$t);";
                cmd.Parameters.AddWithValue("$u", username);
                cmd.Parameters.AddWithValue("$h", hash);
                cmd.Parameters.AddWithValue("$s", salt);
                cmd.Parameters.AddWithValue("$i", Iterations);
                cmd.Parameters.AddWithValue("$t", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
                try { cmd.ExecuteNonQuery(); }
                catch (SqliteException e) when (e.SqliteErrorCode == 19) // SQLITE_CONSTRAINT (UNIQUE)
                { throw new DuplicateUsernameException(username); }

                using var idCmd = _conn.CreateCommand();
                idCmd.CommandText = "SELECT last_insert_rowid();";
                return (int)(long)idCmd.ExecuteScalar();
            }
        }

        public int? VerifyCredentials(string username, string password)
        {
            if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) return null;
            lock (_lock)
            {
                using var cmd = _conn.CreateCommand();
                cmd.CommandText = "SELECT id,pwd_hash,salt,iter FROM accounts WHERE username=$u COLLATE NOCASE;";
                cmd.Parameters.AddWithValue("$u", username);
                using var r = cmd.ExecuteReader();
                if (!r.Read()) return null;
                int id = (int)r.GetInt64(0);
                byte[] stored = (byte[])r["pwd_hash"];
                byte[] salt = (byte[])r["salt"];
                int iter = (int)r.GetInt64(3);
                byte[] cand = Rfc2898DeriveBytes.Pbkdf2(
                    Encoding.UTF8.GetBytes(password), salt, iter, HashAlgorithmName.SHA256, stored.Length);
                return CryptographicOperations.FixedTimeEquals(cand, stored) ? id : (int?)null;
            }
        }

        // 测试辅助:原始库字节是否包含某序列(验证明文不入库)
        public bool DebugRawContains(byte[] needle)
        {
            lock (_lock)
            {
                using var cmd = _conn.CreateCommand();
                cmd.CommandText = "SELECT pwd_hash,salt FROM accounts;";
                using var r = cmd.ExecuteReader();
                while (r.Read())
                {
                    if (Contains((byte[])r["pwd_hash"], needle) || Contains((byte[])r["salt"], needle))
                        return true;
                }
                return false;
            }
        }

        static bool Contains(byte[] hay, byte[] needle)
        {
            if (needle.Length == 0 || hay.Length < needle.Length) return false;
            for (int i = 0; i <= hay.Length - needle.Length; i++)
            {
                bool ok = true;
                for (int j = 0; j < needle.Length; j++) if (hay[i + j] != needle[j]) { ok = false; break; }
                if (ok) return true;
            }
            return false;
        }

        void Exec(string sql)
        {
            using var cmd = _conn.CreateCommand();
            cmd.CommandText = sql;
            cmd.ExecuteNonQuery();
        }

        public void Dispose() => _conn?.Dispose();
    }
}
  • Step 5: 跑测试确认通过

Run: cd Server && dotnet test Auth.Tests/Auth.Tests.csproj Expected: 6 passed。

  • Step 6: Commit
git add Server/Auth/AccountStore.cs Server/Auth/DuplicateUsernameException.cs Server/Auth.Tests/AccountStoreTests.cs
git commit -m "feat(auth): SQLite AccountStore with PBKDF2 password hashing"

Task 3: TokenService —— HMAC 签名 token

Files:

  • Create: Server/Auth/TokenService.cs

  • Test: Server/Auth.Tests/TokenServiceTests.cs

  • Step 1: 写失败测试

Server/Auth.Tests/TokenServiceTests.cs

using System;
using Xunit;
using XWorld.Server.Auth;

namespace XWorld.Server.Auth.Tests
{
    public class TokenServiceTests
    {
        static byte[] Key() => new byte[32]; // 全零密钥,测试用固定值

        [Fact]
        public void Issue_then_verify_roundtrip_returns_pid()
        {
            var t = new TokenService(Key());
            string tok = t.Issue(4242, TimeSpan.FromHours(1));
            Assert.True(t.Verify(tok, out int pid));
            Assert.Equal(4242, pid);
        }

        [Fact]
        public void Tampered_token_rejected()
        {
            var t = new TokenService(Key());
            string tok = t.Issue(7, TimeSpan.FromHours(1));
            string bad = tok.Substring(0, tok.Length - 1) + (tok[tok.Length - 1] == 'A' ? 'B' : 'A');
            Assert.False(t.Verify(bad, out _));
        }

        [Fact]
        public void Expired_token_rejected()
        {
            var t = new TokenService(Key());
            string tok = t.Issue(7, TimeSpan.FromSeconds(-1)); // 已过期
            Assert.False(t.Verify(tok, out _));
        }

        [Fact]
        public void Wrong_key_rejected()
        {
            var signer = new TokenService(Key());
            string tok = signer.Issue(7, TimeSpan.FromHours(1));
            var other = new TokenService(new byte[32] { 1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 });
            Assert.False(other.Verify(tok, out _));
        }

        [Fact]
        public void Garbage_token_rejected()
        {
            var t = new TokenService(Key());
            Assert.False(t.Verify("not-a-token", out _));
            Assert.False(t.Verify("", out _));
            Assert.False(t.Verify(null, out _));
        }
    }
}
  • Step 2: 跑测试确认失败

Run: cd Server && dotnet test Auth.Tests/Auth.Tests.csproj --filter TokenServiceTests Expected: 编译失败(TokenService 未定义)。

  • Step 3: 写 TokenService

Server/Auth/TokenService.cs

using System;
using System.Security.Cryptography;
using System.Text;

namespace XWorld.Server.Auth
{
    // 无状态 HMAC-SHA256 token。格式: "v1.{pid}.{expUnix}.{base64url(hmac(payload))}"
    // 其中 payload = "v1.{pid}.{expUnix}"。
    public sealed class TokenService
    {
        readonly byte[] _key;

        public TokenService(byte[] key)
        {
            if (key == null || key.Length < 16) throw new ArgumentException("key too short");
            _key = key;
        }

        public string Issue(int pid, TimeSpan ttl)
        {
            long exp = DateTimeOffset.UtcNow.Add(ttl).ToUnixTimeSeconds();
            string payload = "v1." + pid + "." + exp;
            return payload + "." + B64Url(Sign(payload));
        }

        public bool Verify(string token, out int pid)
        {
            pid = 0;
            if (string.IsNullOrEmpty(token)) return false;
            int lastDot = token.LastIndexOf('.');
            if (lastDot <= 0) return false;
            string payload = token.Substring(0, lastDot);
            string sig = token.Substring(lastDot + 1);

            byte[] expected = Sign(payload);
            byte[] got;
            try { got = FromB64Url(sig); } catch { return false; }
            if (!CryptographicOperations.FixedTimeEquals(expected, got)) return false;

            string[] parts = payload.Split('.'); // ["v1", pid, exp]
            if (parts.Length != 3 || parts[0] != "v1") return false;
            if (!int.TryParse(parts[1], out pid)) return false;
            if (!long.TryParse(parts[2], out long exp)) return false;
            if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > exp) return false;
            return true;
        }

        byte[] Sign(string payload)
        {
            using var h = new HMACSHA256(_key);
            return h.ComputeHash(Encoding.UTF8.GetBytes(payload));
        }

        static string B64Url(byte[] b) =>
            Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');

        static byte[] FromB64Url(string s)
        {
            string b = s.Replace('-', '+').Replace('_', '/');
            switch (b.Length % 4) { case 2: b += "=="; break; case 3: b += "="; break; }
            return Convert.FromBase64String(b);
        }
    }
}
  • Step 4: 跑测试确认通过

Run: cd Server && dotnet test Auth.Tests/Auth.Tests.csproj --filter TokenServiceTests Expected: 5 passed。

  • Step 5: Commit
git add Server/Auth/TokenService.cs Server/Auth.Tests/TokenServiceTests.cs
git commit -m "feat(auth): stateless HMAC token service"

Task 4: AuthEndpoints —— /register、/login

Files:

  • Create: Server/Auth/AuthEndpoints.cs

  • Test: Server/Auth.Tests/AuthEndpointsTests.cs

  • Step 1: 写失败测试(真 Kestrel + HttpClient

Server/Auth.Tests/AuthEndpointsTests.cs

using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using System.Linq;
using Xunit;
using XWorld.Server.Auth;

namespace XWorld.Server.Auth.Tests
{
    public class AuthEndpointsTests : IAsyncLifetime
    {
        WebApplication _app;
        HttpClient _http;
        AccountStore _store;

        public async Task InitializeAsync()
        {
            _store = new AccountStore("Data Source=ep_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
            var tokens = new TokenService(new byte[32]);
            var builder = WebApplication.CreateBuilder();
            builder.Logging.ClearProviders();
            builder.WebHost.ConfigureKestrel(o => o.Listen(System.Net.IPAddress.Loopback, 0));
            _app = builder.Build();
            AuthEndpoints.Map(_app, _store, tokens, TimeSpan.FromHours(1));
            await _app.StartAsync();
            string baseUrl = _app.Services.GetRequiredService<IServer>()
                .Features.Get<IServerAddressesFeature>().Addresses.First();
            _http = new HttpClient { BaseAddress = new Uri(baseUrl) };
        }

        public async Task DisposeAsync() { _http?.Dispose(); _store?.Dispose(); await _app.DisposeAsync(); }

        [Fact]
        public async Task Register_then_login_yields_token_and_pid()
        {
            var reg = await _http.PostAsJsonAsync("/register", new { username = "neo", password = "redpill1" });
            Assert.Equal(HttpStatusCode.OK, reg.StatusCode);
            var regBody = await reg.Content.ReadFromJsonAsync<AuthEndpoints.AuthResponse>();
            Assert.True(regBody.pid > 0);
            Assert.False(string.IsNullOrEmpty(regBody.token));

            var login = await _http.PostAsJsonAsync("/login", new { username = "neo", password = "redpill1" });
            Assert.Equal(HttpStatusCode.OK, login.StatusCode);
            var loginBody = await login.Content.ReadFromJsonAsync<AuthEndpoints.AuthResponse>();
            Assert.Equal(regBody.pid, loginBody.pid);
        }

        [Fact]
        public async Task Register_duplicate_returns_409()
        {
            await _http.PostAsJsonAsync("/register", new { username = "dup", password = "pw123456" });
            var again = await _http.PostAsJsonAsync("/register", new { username = "dup", password = "pw123456" });
            Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
        }

        [Fact]
        public async Task Login_wrong_password_returns_401()
        {
            await _http.PostAsJsonAsync("/register", new { username = "trinity", password = "pw123456" });
            var bad = await _http.PostAsJsonAsync("/login", new { username = "trinity", password = "WRONG" });
            Assert.Equal(HttpStatusCode.Unauthorized, bad.StatusCode);
        }

        [Fact]
        public async Task Register_empty_fields_returns_400()
        {
            var r = await _http.PostAsJsonAsync("/register", new { username = "", password = "" });
            Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
        }
    }
}
  • Step 2: 跑测试确认失败

Run: cd Server && dotnet test Auth.Tests/Auth.Tests.csproj --filter AuthEndpointsTests Expected: 编译失败(AuthEndpoints 未定义)。

Auth.Tests 缺 ASP.NET 引用导致 WebApplication 不可用,在 Auth.Tests.csproj<Project Sdk=...> 改为 Microsoft.NET.Sdk.Web 或加 <FrameworkReference Include="Microsoft.AspNetCore.App" />(参考 Server/Gateway.Tests 的写法)。

  • Step 3: 写 AuthEndpoints

Server/Auth/AuthEndpoints.cs

using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace XWorld.Server.Auth
{
    public static class AuthEndpoints
    {
        public sealed class AuthRequest { public string username { get; set; } public string password { get; set; } }
        public sealed class AuthResponse { public string token { get; set; } public int pid { get; set; } }

        public static void Map(IEndpointRouteBuilder app, AccountStore store, TokenService tokens, TimeSpan tokenTtl)
        {
            app.MapPost("/register", (AuthRequest req) =>
            {
                if (req == null || string.IsNullOrWhiteSpace(req.username) || string.IsNullOrEmpty(req.password))
                    return Results.BadRequest(new { error = "invalid_input" });
                int pid;
                try { pid = store.CreateAccount(req.username, req.password); }
                catch (DuplicateUsernameException) { return Results.Conflict(new { error = "username_taken" }); }
                catch (ArgumentException) { return Results.BadRequest(new { error = "invalid_input" }); }
                return Results.Ok(new AuthResponse { token = tokens.Issue(pid, tokenTtl), pid = pid });
            });

            app.MapPost("/login", (AuthRequest req) =>
            {
                if (req == null || string.IsNullOrWhiteSpace(req.username) || string.IsNullOrEmpty(req.password))
                    return Results.BadRequest(new { error = "invalid_input" });
                int? pid = store.VerifyCredentials(req.username, req.password);
                if (pid == null) return Results.Json(new { error = "invalid_credentials" }, statusCode: 401);
                return Results.Ok(new AuthResponse { token = tokens.Issue(pid.Value, tokenTtl), pid = pid.Value });
            });
        }
    }
}
  • Step 4: 跑测试确认通过

Run: cd Server && dotnet test Auth.Tests/Auth.Tests.csproj --filter AuthEndpointsTests Expected: 4 passed。

  • Step 5: Commit
git add Server/Auth/AuthEndpoints.cs Server/Auth.Tests/AuthEndpointsTests.cs
git commit -m "feat(auth): /register and /login HTTP endpoints"

Task 5: 网关接入 —— /ws 验 tokenopt-in+ 映射端点

Files:

  • Modify: Server/Gateway/Gateway.csproj(引用 Auth

  • Modify: Server/Gateway/GameServerHost.cs:32-77Start 参数 + 端点 + /ws

  • Test: Server/Gateway.Tests/AuthWsTests.cs(新增)

  • Step 1: Gateway 引用 Auth

Server/Gateway/Gateway.csproj<ItemGroup> 加:

    <ProjectReference Include="..\Auth\Auth.csproj" />
  • Step 2: 写失败测试(WS token 鉴权)

Server/Gateway.Tests/AuthWsTests.cs

using System;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using XWorld.Server.Auth;
using XWorld.Server.Gateway;

namespace XWorld.Server.Gateway.Tests
{
    public class AuthWsTests
    {
        static string GamesRoot() => System.IO.Path.Combine(AppContext.BaseDirectory, "TestGames");

        [Fact]
        public async Task Ws_with_valid_token_connects()
        {
            var key = new byte[32];
            var host = new GameServerHost();
            await host.StartAsync(GamesRoot(), 100, authSecret: key,
                authDbPath: "Data Source=wsok_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
            try
            {
                string token = new TokenService(key).Issue(1234, TimeSpan.FromHours(1));
                using var ws = new ClientWebSocket();
                var uri = new Uri(host.WsBaseUrl + "/ws?token=" + token);
                await ws.ConnectAsync(uri, CancellationToken.None); // 不抛即握手成功
                Assert.Equal(WebSocketState.Open, ws.State);
            }
            finally { await host.StopAsync(); }
        }

        [Fact]
        public async Task Ws_with_invalid_token_rejected()
        {
            var key = new byte[32];
            var host = new GameServerHost();
            await host.StartAsync(GamesRoot(), 100, authSecret: key,
                authDbPath: "Data Source=wsbad_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
            try
            {
                using var ws = new ClientWebSocket();
                var uri = new Uri(host.WsBaseUrl + "/ws?token=garbage");
                await Assert.ThrowsAnyAsync<WebSocketException>(() => ws.ConnectAsync(uri, CancellationToken.None));
            }
            finally { await host.StopAsync(); }
        }
    }
}

GamesRoot() 路径按 Server/Gateway.Tests 现有用例对 TestGames 的引用方式对齐(参考该工程已有 WS 集成测试如何定位 gamesRoot)。

  • Step 3: 跑测试确认失败

Run: cd Server && dotnet test Gateway.Tests/Gateway.Tests.csproj --filter AuthWsTests Expected: 编译失败(StartAsyncauthSecret/authDbPath 参数)。

  • Step 4: 改 GameServerHost.StartAsync 签名与实现

Server/Gateway/GameServerHost.cs

(a) 顶部加 using XWorld.Server.Auth;(已可见 XWorld.Framework.Protocol/XWorld.Server.Host)。

(b) 类内加字段:

        private AccountStore _accounts;

(c) StartAsync 方法签名加两个可选参数(放在现有可选参数之后):

            int devDiscoveryPort = DevDiscovery.Port,
            byte[] authSecret = null, string authDbPath = null)

(d) 在 var app = builder.Build(); 之前,构造鉴权组件:

            TokenService tokens = null;
            if (authSecret != null)
            {
                tokens = new TokenService(authSecret);
                _accounts = new AccountStore(string.IsNullOrEmpty(authDbPath)
                    ? "Data Source=accounts.db" : authDbPath);
            }

(e) app.UseWebSockets(); 之后、/ws 中间件之前,映射鉴权端点(仅在启用时):

            if (tokens != null)
                AuthEndpoints.Map(app, _accounts, tokens, TimeSpan.FromDays(7));

(f) 把 /ws 中间件里取 pid 的那段(GameServerHost.cs:53-57)替换为:

                    int pid;
                    if (tokens != null)
                    {
                        string token = ctx.Request.Query["token"];
                        if (!tokens.Verify(token, out pid))
                        {
                            ctx.Response.StatusCode = 401;
                            return;
                        }
                    }
                    else if (!int.TryParse(ctx.Request.Query["pid"], out pid))
                    {
                        ctx.Response.StatusCode = 400;
                        return;
                    }

(g) StopAsync 里释放账号库(在 _app.DisposeAsync() 之后或之前均可):

            _accounts?.Dispose();
            _accounts = null;
  • Step 5: 跑测试确认通过 + 全量不回归

Run: cd Server && dotnet test Gateway.Tests/Gateway.Tests.csproj --filter AuthWsTests Expected: 2 passed。 Run: cd Server && dotnet test Server.sln Expected: 全绿(旧用例用 ?pid= 走「未启用鉴权」分支,不回归)。

  • Step 6: Commit
git add Server/Gateway/Gateway.csproj Server/Gateway/GameServerHost.cs Server/Gateway.Tests/AuthWsTests.cs
git commit -m "feat(auth): gateway verifies WS token for pid (opt-in), maps auth endpoints"

Task 6: Gateway.Runner 接线 + 部署脚本

Files:

  • Modify: Server/Gateway.Runner/Program.cs

  • Modify: deploy/windows/install-services.ps1deploy/windows/README.md

  • Step 1: Runner 解析鉴权参数并传入

Server/Gateway.Runner/Program.cs,在 host.StartAsync(...) 之前加:

            string authDbPath = GetArg(args, "--authDbPath");
            string authSecretB64 = GetArg(args, "--authSecret");
            byte[] authSecret = null;
            if (!string.IsNullOrEmpty(authDbPath))
            {
                if (!string.IsNullOrEmpty(authSecretB64))
                    authSecret = Convert.FromBase64String(authSecretB64);
                else
                {
                    // 密钥文件在 DB 同目录 auth.key,不存在则生成 32B 并持久化
                    string keyPath = System.IO.Path.Combine(
                        System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(authDbPath)) ?? ".", "auth.key");
                    if (System.IO.File.Exists(keyPath))
                        authSecret = Convert.FromBase64String(System.IO.File.ReadAllText(keyPath).Trim());
                    else
                    {
                        authSecret = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32);
                        System.IO.File.WriteAllText(keyPath, Convert.ToBase64String(authSecret));
                    }
                }
            }

并把 StartAsync 调用末尾补 , authSecret: authSecret, authDbPath: authDbPath

  • Step 2: 构建验证

Run: cd Server && dotnet build Gateway.Runner/Gateway.Runner.csproj -c Release Expected: 0 错误。

  • Step 3: 部署脚本加鉴权参数

deploy/windows/install-services.ps1 的网关 AppParameters 改为包含鉴权(DB 落在 C:\xworld\):

& $NssmPath set XWorldGateway AppParameters "--gamesRoot `"$GamesRoot`" --port $GatewayPort --authDbPath `"$AuthDbPath`""

并在 param(...)[string]$AuthDbPath = "C:\xworld\accounts.db"

  • Step 4: README 补一节

deploy/windows/README.md 加:网关现需 --authDbPath(首启自动在同目录生成 auth.key务必备份),/register/login 经 Caddy 同域 https://game.xworld.link/ 暴露,无需额外反代规则(已整域反代到 5005)。

  • Step 5: Commit
git add Server/Gateway.Runner/Program.cs deploy/windows/install-services.ps1 deploy/windows/README.md
git commit -m "feat(auth): wire auth into Gateway.Runner + Windows deploy"

Task 7: 客户端 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 之后加:

    public const string ProductionAuthBase = "https://game.xworld.link";//登录/注册 HTTP 端点(与 WSS 同主机,经 Caddy)
  • Step 2: 写 AuthClient

Client/Assets/Script/xmain/Client/AuthClient.cs

using System;
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;

namespace XGame
{
    // 账号鉴权 HTTP 客户端:POST /login、/register,拿 {token,pid}。
    public static class AuthClient
    {
        [Serializable] class AuthReq { public string username; public string password; }
        [Serializable] class AuthResp { public string token; public int pid; }

        public static IEnumerator Login(string user, string pwd, Action<bool, string, int, string> done)
            => Post("/login", user, pwd, done);

        public static IEnumerator Register(string user, string pwd, Action<bool, string, int, string> done)
            => Post("/register", user, pwd, done);

        static IEnumerator Post(string path, string user, string pwd, Action<bool, string, int, string> done)
        {
            string url = GlobalData.ProductionAuthBase + path;
            string body = JsonUtility.ToJson(new AuthReq { username = user, password = pwd });
            using (var req = new UnityWebRequest(url, "POST"))
            {
                req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
                req.downloadHandler = new DownloadHandlerBuffer();
                req.SetRequestHeader("Content-Type", "application/json");
                yield return req.SendWebRequest();

                if (req.result != UnityWebRequest.Result.Success)
                {
                    string err = req.responseCode == 401 ? "账号或密码错误"
                        : req.responseCode == 409 ? "用户名已被占用"
                        : ("网络错误: " + req.error);
                    done(false, null, 0, err);
                    yield break;
                }
                AuthResp resp = null;
                try { resp = JsonUtility.FromJson<AuthResp>(req.downloadHandler.text); } catch { }
                if (resp == null || string.IsNullOrEmpty(resp.token))
                {
                    done(false, null, 0, "响应解析失败");
                    yield break;
                }
                done(true, resp.token, resp.pid, null);
            }
        }
    }
}
  • Step 3: Unity 人工核对

在 Unity Editor 打开工程,确认 0 编译错误(AuthClient 属 XWorld.Link 程序集)。本环境用 IDE 诊断辅助检查 AuthClient.csGlobalData.cs 无错。

  • Step 4: Commit
git add Client/Assets/Script/xmain/GlobalData.cs Client/Assets/Script/xmain/Client/AuthClient.cs
git commit -m "feat(auth): client AuthClient (login/register over HTTPS)"

Task 8: 客户端登录流程改用 tokenUnity,须 Editor 人工核对)

Files:

  • Modify: Client/Assets/Script/xmain/Client/CSharpClientApp.cs

  • Step 1: OnLoginClicked 改为走 AuthClient

OnLoginClickedCSharpClientApp.cs:171 起)校验通过后的本地直进逻辑,改为协程调用 AuthClient.Login

            // 校验非空后:
            XWCoroutine.StartCo(AuthClient.Login(account, password, (ok, token, pid, err) =>
            {
                if (!ok) { ShowLoginError(err); return; }
                XData.SetString(LoginTokenKey, token);
                GlobalData.Token = token;
                m_pid = pid;                      // 新增字段:当前已鉴权 pid
                if (rememberPassword) { XData.SetString(LoginAccountKey, account); XData.SetString(LoginPasswordKey, password); }
                SaveSession(account, account);
                EnterLobby();
            }));

在类里加字段 private int m_pid;OnRegisterClicked 同理改调 AuthClient.Register(成功后与登录相同:存 token、设 pid、EnterLobby)。

  • Step 2: ConnectLobby 改用 token

ConnectLobbyCSharpClientApp.cs:290)里:去掉 ?pid=/WithPlayerId 拼接,改为用已存 token 拼网关 URL:

            string token = GlobalData.Token;
            if (string.IsNullOrEmpty(token)) token = XData.GetString(LoginTokenKey, "");
            string node = GlobalData.CurNode; // 形如 wss://game.xworld.link/ws
            if (!string.IsNullOrEmpty(token) &&
                (node.StartsWith("ws://", StringComparison.OrdinalIgnoreCase) ||
                 node.StartsWith("wss://", StringComparison.OrdinalIgnoreCase)))
            {
                string sep = node.Contains("?") ? "&" : "?";
                string url = node + sep + "token=" + UnityWebRequest.EscapeURL(token);
                SetLobbyStatus("Connecting lobby...");
                lsocket.connect(url, 0, OnLobbyConnected);
                return;
            }
            // 无 token:回退(本地 dev ?pid= 路径保留,便于内网联调)
            // ……保留原有 NormalizeNode/SplitHostPort 分支……

GetStablePlayerId/WithPlayerId 在生产路径不再使用(dev ?pid= 分支可保留)。m_pid 用于后续业务需要本地 pid 之处。

  • Step 3: 自动登录(可选增强)

Start/初始化里:若 XData.GetString(LoginTokenKey,"") 非空,可直接 GlobalData.Token=该值 并尝试进大厅;服务端若 401(token 过期)则回登录页让用户重输。(最小实现:本步可仅在 OnLobbyConnected 失败为 401 类错误时清 LoginTokenKey 并弹登录页。)

  • Step 4: Unity 人工核对

IDE 诊断 CSharpClientApp.cs 无错;Editor 编译通过。真机/Editor 端到端:注册→登录→wss://…/ws?token=→大厅连上;错密码提示 401;token 过期回登录页。

  • Step 5: Commit
git add Client/Assets/Script/xmain/Client/CSharpClientApp.cs
git commit -m "feat(auth): client login/register via token, connect ws with ?token="

端到端验收

  • 服务端:cd Server && dotnet test Server.sln 全绿(含新增 Auth.Tests / AuthWsTests,旧用例不回归)。
  • 部署:Gateway.Runner --authDbPath … 启动 → auth.key 生成 → POST https://game.xworld.link/register 得 token → wss://game.xworld.link/ws?token= 连上;篡改/缺 token → 401。
  • 客户端(Editor/真机):注册→登录→进大厅;错密码 401 提示;伪造/缺 token 连不上。

注意事项

  • 客户端任务(7、8)本环境无 Unity 无法编译/运行,按项目惯例交付「完整代码 + IDE 诊断 + Editor 人工核对」。
  • 一次性运维:生产首启生成 auth.key必须备份(丢失则所有已发 token 失效,需重登)。
  • 后续(非本计划):主动吊销/踢人、登录限流/锁定、找回密码、token 刷新。