# 账号鉴权(真登录 + 签名 token)设计 日期:2026-06-24 状态:已与用户确认设计,待写实施计划 ## 背景与目标 当前网关 `/ws?pid=` **直接信任客户端传来的明文 pid**(`Server/Gateway/GameServerHost.cs:49-77`),pid 是客户端对账号做 FNV 哈希得到(`CSharpClientApp.GetStablePlayerId`)。后果:任何人可在 URL 里填任意 pid 冒充任意玩家、劫持会话(`SessionManager` 以 pid 为键,同 pid 视为重连)。这是公网上线的硬安全缺口(memory 多处记为「未做鉴权」)。 **目标**:引入真账号体系——账号+密码(PBKDF2 哈希)验证后签发 HMAC 签名 token,网关用 token 验签取**权威 pid**,杜绝伪造 pid / 冒充。 **范围(用户已定)**: - 支持:**注册新账号**(开放注册)、**账号密码登录**。 - 不做:游客登录、邀请码。 - 账号存 **SQLite 单文件库**。 - token 用 **HMAC 签名、无状态**(不易主动吊销,靠过期;将来需吊销再叠加会话表)。 非目标(本期不做,列为后续):主动吊销/踢人、限流(先靠 Caddy)、找回密码、多设备管理、邮箱/手机验证。 ## 架构 新增**隔离子系统** `Server/Auth/`(库 `XWorld.Server.Auth`,net10.0,netstandard 不要求),仅在连接/登录时被调用,**不进 tick 热路径**,与单线程 tick actor 兼容。 ``` 客户端 Caddy(TLS) 网关 Kestrel (:5005) │ POST /login {user,pwd} ──HTTPS──▶ game.xworld.link ──▶ AuthEndpoints │ ◀────────── {token, pid} ──────────────────────────── └─ AccountStore.Verify → TokenService.Issue │ │ wss://…/ws?token= ─WSS──▶ ─────────────────────▶ /ws 中间件 │ └─ TokenService.Verify → pid → ConnectCommand ``` ### 组件与职责 **`AccountStore`(SQLite)** - 依赖 `Microsoft.Data.Sqlite`(新增 NuGet)。 - 表:`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)`。 - `int CreateAccount(string username, string password)` → 新账号 id;用户名重复抛/返回明确错误(映射 409)。 - `int? VerifyCredentials(string username, string password)` → 命中且密码正确返回 id,否则 null。 - 密码哈希:`Rfc2898DeriveBytes`(PBKDF2-SHA256),每用户 16 字节随机盐,迭代数 ≥100000(存 `iter` 便于将来升级),输出 32 字节。比较用定长 `CryptographicOperations.FixedTimeEquals`。 - 连接:单文件 `accounts.db`,路径由网关参数 `--authDbPath` 指定;开 WAL;`AccountStore` 内部串行化写(或每次开短连接 + `busy_timeout`)。**只在 register/login 时访问**。 **`TokenService`(HMAC 签名,无状态)** - token 文本:`base64url(payload)` + `"."` + `base64url(HMAC-SHA256(secret, payload))`,`payload = "{pid}.{expUnixSeconds}"`(v1 固定前缀便于演进,记为 `v1.{pid}.{exp}`)。 - `string Issue(int pid, TimeSpan ttl)`:默认 ttl 7 天(网关参数 `--tokenTtlHours` 可配)。 - `bool Verify(string token, out int pid)`:重算 HMAC 定长比较 + 校验未过期;任一不符返回 false。 - 密钥:32 字节,来源优先级 `--authSecret`(base64)> 环境变量 `XWORLD_AUTH_SECRET` > DB 同目录 `auth.key` 文件(不存在则生成随机 32 字节并以受限权限持久化)。**密钥仅服务端,绝不下发客户端**。 **网关 HTTP 端点(复用同一 Kestrel,经 Caddy 暴露在 `game.xworld.link`)** - 在 `GameServerHost` 建 app 时映射(或抽到 `AuthEndpoints.Map(app, store, tokens)`): - `POST /register`:body `{ "username", "password" }`(JSON)。校验非空/长度 → `CreateAccount` → 成功签 token 返回 `{ "token", "pid" }`;重复用户名 → 409 `{ "error": "username_taken" }`;非法输入 → 400。 - `POST /login`:body `{ "username", "password" }` → `VerifyCredentials` → 成功 `{ "token", "pid" }`;失败 → 401 `{ "error": "invalid_credentials" }`。 - 端点不依赖 ServerLoop,纯同步/短异步,避免阻塞 tick。 **网关 `/ws` 改造(`GameServerHost.cs:49-77`)** - **鉴权 opt-in**(与现有 `publicKeyPem==null` 关签名同范式):`StartAsync` 新增 `authSecret`/`authDbPath`(或一个组装好的 `TokenService`/`AccountStore`)参数。 - 配置了鉴权:`/ws` 读 `?token=`,`TokenService.Verify` 失败/缺失 → 401,不建连接;成功取 token 内 **pid**(忽略任何 `?pid=`)。 - 未配置鉴权(本地 dev / 现有测试):保留旧 `?pid=` 路径,零回归。 - pid 自此**只来自 token**(权威,DB 账号 id)。 **`Gateway.Runner` 接线** - 新增参数:`--authDbPath `、`--authSecret `(可选)、`--tokenTtlHours `(默认 168)。 - 生产由 `install-services.ps1` 传 `--authDbPath C:\xworld\accounts.db`(密钥走 `auth.key` 自动生成或 `--authSecret`)。`deploy/windows/install-services.ps1` 与 README 同步更新。 ### 客户端(XWorld.Link 热更层) **`AuthClient`(新增,`Client/Assets/Script/xmain/.../AuthClient.cs`)** - 新增常量 `GlobalData.ProductionAuthBase = "https://game.xworld.link"`(与 WSS 同主机,HTTP 端点经 Caddy)。 - `IEnumerator Login(user, pwd, Action)`、`Register(...)`:`UnityWebRequest.Post` 到 `GlobalData.ProductionAuthBase + "/login"`(或 `/register`)。解析 JSON `{token,pid}`。 - 失败按状态码给出可读错误(409 用户名已占用 / 401 账号密码错 / 网络错)。 **`CSharpClientApp` 改造** - `OnLoginClicked` / `OnRegisterClicked`:改为调 `AuthClient`(协程),成功后 `XData.SetString(LoginTokenKey, token)`、记 pid、设 `GlobalData.Token`,再 `EnterLobby`;失败 `ShowLoginError`。 - `ConnectLobby`:用 `wss://game.xworld.link/ws?token=`(替掉 `WithPlayerId`/`?pid=` 拼接)。token 从内存/`XData` 取。 - 自动登录:启动时若 `XData` 有未过期 token(客户端可只存 + 直接试连,过期由服务端 401 反馈再回登录页),跳过手填。配合已有 remember/auto UI。 - `GetStablePlayerId` 退役(pid 改由服务端下发)。 ### 数据流(端到端) 1. 注册/登录:客户端 HTTPS POST → 网关验密(PBKDF2 定长比较)→ `TokenService.Issue(pid)` → 返回 `{token,pid}`。 2. 连接:客户端 `wss://…/ws?token=…` → 网关 `Verify` → 取权威 pid → `ConnectCommand`。 3. 安全性:伪造 pid 不可能(无服务端密钥无法签出有效 token);冒充他人需其凭据或服务端密钥;明文 pid 路径在生产关闭。 ## 错误处理 - 注册:用户名空/过长/非法字符 → 400;重复 → 409;DB 写失败 → 500(日志含原因,不含密码)。 - 登录:账号不存在或密码错 → **统一 401 `invalid_credentials`**(不区分,防用户名枚举)。 - WS:缺 token / 验签失败 / 过期 → 401,不建连接、不 Submit。 - 客户端:网络失败/超时 → 提示重试;401 → 回登录页清本地 token。 ## 测试(服务端 TDD,沿用 `Server.Host.Tests`/`Gateway.Tests` 套路,临时文件或内存 SQLite) - `AccountStore`:建号成功返 id;重复用户名(含大小写 NOCASE)被拒;验密——对的过、错的拒、不存在的拒;密码不以明文存(读 BLOB 断言非明文);盐不同则相同密码哈希不同。 - `TokenService`:Issue→Verify 往返取回同 pid;篡改 payload/sig 任一被拒;过期被拒;换密钥被拒。 - 端点(集成,真 Kestrel + HttpClient):register→拿 token→/ws 用该 token 连上(pid 与返回一致);重复 register 409;错密码 login 401。 - WS:有效 token 建连且 pid 来自 token;缺/坏/过期 token → 401 且不建连接;未配鉴权时 `?pid=` 仍可用(回归)。 - 目标:`dotnet test Server/Server.sln` 全绿(现有用例不回归)。 ## 安全默认与后续 - 全程 HTTPS/WSS(Caddy,已部署)。密钥 32B 仅服务端。密码 PBKDF2-SHA256+盐+≥10万迭代+定长比较。登录失败不泄露用户名是否存在。 - **后续(本期不做)**:主动吊销/踢人(叠加 SQLite 会话表或短 token+刷新)、登录限流/锁定(Caddy 或网关计数)、找回密码、邮箱验证、token 刷新机制。 ## 受影响文件清单(实施时) - 新增:`Server/Auth/`(`XWorld.Server.Auth.csproj`、`AccountStore.cs`、`TokenService.cs`、`AuthEndpoints.cs`)、`Server/Auth.Tests/`。 - 改:`Server/Gateway/GameServerHost.cs`(/ws 验 token + 映射端点 + Start 参数)、`Server/Gateway.Runner/Program.cs`(参数接线)、`Server/Server.sln`(加工程)、`deploy/windows/install-services.ps1` + `README.md`。 - 客户端:新增 `AuthClient.cs`;改 `CSharpClientApp.cs`(登录/注册/ConnectLobby/自动登录)、`GlobalData.cs`(`ProductionAuthBase`)。