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

109 lines
9.1 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)设计
日期:2026-06-24
状态:已与用户确认设计,待写实施计划
## 背景与目标
当前网关 `/ws?pid=<int>` **直接信任客户端传来的明文 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.0netstandard 不要求),仅在连接/登录时被调用,**不进 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=<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 <path>``--authSecret <base64>`(可选)、`--tokenTtlHours <n>`(默认 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<bool ok, string token, int pid, string err>)``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=<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/WSSCaddy,已部署)。密钥 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`)。