9.1 KiB
9.1 KiB
账号鉴权(真登录 + 签名 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.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=<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 改由服务端下发)。
数据流(端到端)
- 注册/登录:客户端 HTTPS POST → 网关验密(PBKDF2 定长比较)→
TokenService.Issue(pid)→ 返回{token,pid}。 - 连接:客户端
wss://…/ws?token=…→ 网关Verify→ 取权威 pid →ConnectCommand。 - 安全性:伪造 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)。