feat: 用户名登录——一账号一个牧场,登录后改 URL 也进不了别人的档

多用户之前是「知道牧场名就能玩」:?farm=boss 直接进别人家里。
现在网页端用用户名登录(用户选择:不设密码、一账号一个牧场,
用户名即身份——互信小圈子模型,防的是随手改 URL,不是蓄意攻击)。

* 新增 farm/server/auth.py:HMAC-SHA256 签名的 HttpOnly Cookie
  (sf_auth,30 天,恒时比较防篡改)。密钥存 <save_dir>/secret.key,
  重启不掉线,也不用建表存 token。
* app.py 三个认 ?farm= 的地方(session_of / SSE / POST /api/farms)
  统一规则:有 Cookie 强制本人的牧场(?farm=/X-Farm-Id 被忽略),
  无 Cookie 保持旧语义——bot/TUI/CLI 脚本零改动照常工作。
* 新路由:POST /api/login(全局 token 照常把它挡在门外)、
  POST /api/logout、GET /api/me(刻意不 guard:首屏只问"登录没")。
* 网页:移除牧场名输入框,改为登录遮罩 + 用户徽章 + 退出按钮;
  首屏先 whoami() 再决定连接还是弹登录。

测试 +13:Cookie 伪造/过期/跨密钥/垃圾串/非法用户名/密钥跨重启复用/
session_of 优先级(starlette Request 用 scope dict 直接构造,不需要
httpx)9 项纯逻辑档 + 4 项 TestClient 档。528 项 OK(skip 39)。

真机验收 16/16:登录/HttpOnly/?farm= 被忽略/动作落自己牧场/篡改
退回未登录/退出/非法名 400;跨重启 Cookie 仍有效且进度保留。
This commit is contained in:
snowy-yang committed 2026-09-23 21:20:25 +08:00
1 parent d1692d7b10
commit 698a5e497a
7 files changed
+390 -214

No files matched your search

+29
View File
@@ -28,3 +28,32 @@
弹层实现。
- 一个布尔标志被两个子系统共用时,其中一个高频清零,另一个低频读取——
低频的那个等于瞎了。存盘依据必须由"成功的写入"来清。
## 2026-09-23 用户名登录(多用户)
- 用户要求「添加多用户的 需要使用用户名登录」;AskUserQuestion 确认:
**仅用户名**(不设密码)+ **一账号一个牧场**(用户名即牧场名)。
- 新增 `farm/server/auth.py`:`LoginManager` 签发/校验 HMAC-SHA256 签名的
HttpOnly Cookie(`sf_auth`,30 天,SameSite=Lax)。Cookie 值 =
`base64("用户名|过期时间") + "." + hmac`,验签 `hmac.compare_digest`。
密钥 32 字节存 `<save_dir>/secret.key`(0600)——**重启不掉线**,无需建表。
- `app.py`:新增 `POST /api/login`(guard 照常检查全局 token)、
`POST /api/logout`、`GET /api/me`(刻意不 guard,首屏探测用);
`_Services` 加 `auth` 字段与 `user_of()`;**三个认 ?farm= 的地方全部改为
「有 Cookie 强制用自己的牧场」**:`session_of`(?farm=/X-Farm-Id 被忽略)、
SSE `/api/stream`、`POST /api/farms`。无 Cookie 的脚本/AI/CLI 保持旧语义
(互信小圈子,`?farm=` 不受影响),bot/TUI 零改动。
- `webui.py`:移除「牧场名」输入框和「进入牧场」按钮,改为登录遮罩
(`#loginmask` 复用 `.mask/.pick` 样式)+ 用户徽章 `#who` + 退出按钮;
JS 先 `whoami()` 再决定 `connect()` 还是弹登录;令牌输入框保留
(change 时重连)。farmName() = 登录用户。
- 测试 +13:`LoginCookieTestCase` 9 项纯逻辑(伪造/过期/跨密钥/垃圾串/非法名/
密钥复用/session_of 优先级——starlette Request 可直接用 scope dict 构造,
不需要 httpx)+ AuthTestCase 4 项 TestClient 档(照例 skip)。
门禁 528 项 OK(skip 39),ruff/pyright 全绿。
- 真机验收 16/16:登录/Cookie/HttpOnly/?farm= 被忽略/动作落自己牧场/
篡改退回未登录/退出/非法用户名 400/首页元素。跨重启:secret.key 复用,
Cookie 仍认 carol、显式 save 后作物也在。
- 教训:幂等补丁脚本里 `new == ""` 时 `if new in text` 恒真,会误报 ALREADY
跳过删除类替换(这次是删全局 Enter 监听那行,靠核对输出抓回来)。
删除类编辑别走补丁脚本,直接用 Edit 工具。
+49 -191
View File
@@ -1,199 +1,57 @@
# SimpleFarm 项目长期约定
## 是什么
终端农场经营游戏 + 小型多人游戏服务器(v2.0)。游戏跑在服务端进程里,
TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务端不内置 AI)。
## 运行方式(v2.0 起由 uv 统一管理)
- **单一可信来源是 `pyproject.toml`**,`.venv/` 由 uv 创建;`uv.lock` **要提交**(可复现构建依据)。
加/删依赖走 `uv add` / `uv remove`,不要手工编辑依赖表,也不要再列 `pip install`。
- 所有命令带 `uv run ` 前缀:`uv run main.py serve --daemon`,网页 <http://127.0.0.1:8765/>,文档 /docs
- 玩:`uv run main.py play --farm 名字`;单机:`uv run main.py solo`
- AI/脚本入口:`main.py actions | do <动作> '<JSON>' | batch | state --json | watch | bot`
- **测试套件已重建(2026-09-19)**:`uv run python -m unittest discover -s tests`,**500 项**。
但 **pyproject 里不认它**(用户明确选择「只写测试文件」):`tests` 不进 ruff `src`、
不进 pyright `include`、没有 `per-file-ignores`,`httpx` 也保持已移除状态 ——
于是 `TestClient` / 真机 `LiveServer` 那 35 项靠 skip-guard 自动跳过。
代价是:`ruff check .` 仍会扫 `tests/`(命令行给了 `.`),**测试文件必须自己保持 lint 干净**。
覆盖:`test_engine`(110) / `test_actions`(35) / `test_server`(131, skip 35) /
`test_tui_keys`(75) / `test_bot`(36) / `test_client`(113)。
- 质量门禁(改动后都应跑一遍):
## 是什么与运行
终端农场游戏 + 多人服务器(HTTP + SSE 给 AI/脚本;TUI/网页纯客户端)。
uv 管理:所有命令带 `uv run `;依赖只改 pyproject(uv add/remove),uv.lock 要提交;
requirements.txt 是导出产物只读。门禁(改动后必跑):
`uv run ruff format . && uv run ruff check . && uv run pyright && uv run python -m unittest discover -s tests`
(2026-09-19 起把测试并入标准动作;想复核性能再跑 `uv run python tools/bench.py --repeat 5`)
- 不用 uv 的人:`requirements.txt` 是 `uv export` 的产物,只读、不要手工改。
README 与 main.py 顶部保留了一段"没装 uv?把 `uv run ` 去掉即可"的兜底说明,**别删**。
(现 528 项;TestClient/真机档 39 项因无 httpx 自动 skip;`ruff check .` 会扫 tests,
测试文件要自己保持 lint 干净)。性能复核 `uv run python tools/bench.py --repeat 5`。
## 玩法节奏(2026-09-19 起)
- **一天 40 分钟**:`config.DEFAULT_TICK_SECONDS = 2400`,`MAX_TICK_SECONDS = 86400`
(旧上限 600 秒会把 40 分钟悄悄夹回去,已放宽)。`--tick` 可调,调试用 `--tick 3`。
- **没有「手动推进一天」**:`engine.sleep()` / `SLEEP_COOLDOWN` / `/api/sleep` /
TUI 的 `[N]休息` / 网页版按钮**全部移除**,时间只能自然流动。
机器人改为 `RuleBot.wait_for_next_day(timeout)`,CLI `bot --wait` 控制等多久(0 = 直接收工)。
再加任何“快进一天”的入口前先想清楚:它会让慢时钟彻底失效。
- 时长显示统一走 `farm.models.format_duration()`(2400 秒 → `40:00`),
TUI 头部倒计时 / `main.py state` / 网页版(JS 里同名函数)都用它,别再写 `{x}s`。
- 新建牧场时 `FarmSession` 会立刻设好 `next_tick_at`,否则到时钟线程下一轮(≤0.2s)
之间倒计时是 None,客户端会误显示成“已暂停/手动模式”。
## 玩法与存档
- 一天 40 分钟(DEFAULT_TICK_SECONDS=2400);**没有手动快进一天**的入口,别再加。
- 天气 5 种决定水分蒸发,`state.forecast` 明天提前可见;收获进 `inventory` 谷仓、
`sell` 才换钱;建筑每天在 advance_day 里**先于生长**执行。
- 存档 SQLite `saves/farm.db` 一牧场一行:标量列 + 整份 payload JSON(加字段改
`_raw_state()`,不改表结构);SAVE_VERSION=2 / MIN=1 向后兼容;老 JSON 启动时自动
迁移(原文件 *.json.migrated 留底)。`StorageError = sqlite3.Error`,别写 except OSError。
WAL 是持久属性,建库设一次即可。测试钩子:`advance_day(weather="sunny")` 钉死天气。
## 三大系统:天气 / 谷仓 / 建筑(2026-09-19 起)
- **天气**:`config.WEATHER` 5 种(晴 30 / 多云 27 / 雨 24 / 干旱 12 / 暴风雨 7,合计 100),
同种不连出(`WEATHER_REROLLS` 重掷)。`state.weather`=今天、`state.forecast`=明天且
**提前可见**,于是「今晚浇不浇」成了可计算的决策。水分消耗改由天气决定
(晴 1 / 多云 0 / 干旱 2),雨天反补 3;建成温室后蒸发减半。
- **谷仓**:收获 / 收取**不再直接给钱**,进 `state.inventory`(键与 `market` 同构:
`wheat` / `animal:chicken`),靠 `sell` 换金币——市场波动因此从纯装饰变成真决策。
仓满时 `_store()` 抛 `granary_full` **且不改状态**(作物原地不动,卖了再来)。
- **建筑**:`config.BUILDINGS`,第 n 座价 `int(cost * 1.6**owned)`;每天在 `advance_day`
里**先于生长**执行(水井救的正是今天要断水那块地)。喂食器只吃库存饲料、绝不自动花钱。
- **存档**:**SQLite**(`saves/farm.db`,一张 `farms` 表,一牧场一行)。
标量列(day/gold/level/ap/weather/forecast/net_worth)管可查询,
完整状态整份存 `payload` JSON——**加字段不用改表结构**,改 `_raw_state()` 就行。
`SAVE_VERSION=2` 配 `MIN_SAVE_VERSION=1`,旧档缺的新字段走默认值、不作废玩家档;
老 JSON 由 `migrate_json_saves()` 启动时自动导入(原文件改名 `*.json.migrated` 留底)。
`StorageError = sqlite3.Error`(不是 OSError 子类),吞异常别再写 `except OSError`。
WAL 是持久属性,建库时设一次就够;每次连接重设白花 2ms。
- **测试钩子**:`advance_day(weather="sunny")` 可钉死当天天气(不传就随机,测试会飘);
这条链的入口是 `_resolve_weather` / `_evaporation` / `_run_buildings` / `_storm_damage`。
- 动作表新增 `sell` 与 `build`,都是 **0 AP**;`bot` 在 AP=0 时仍会走一次 `step()`,
正是为了执行这些免费动作。
## 架构约定
- 分层:engine 不认识网络;`server/actions.py` 是动作唯一登记表;RemoteFarm 与
FarmEngine 同名同语义(加动作两边都补)。StateView.apply():带 plots=整体替换、
不带=合并字段。farm/keys.py 是按键名唯一来源(全小写),tui.py 不许手写字符串字面量。
- session 的 `dirty`(推送节流,250ms 清)与 `unsaved`(自动存档依据,只有 save() 清)
必须分开——合并会让 autosave 瞎掉。网页禁用 window.prompt/confirm(嵌入预览里被
静默屏蔽),交互一律页面内弹层。
- **用户名登录(2026-09-23)**:仅用户名、一账号一牧场(用户名即 farm_id)。
`auth.py` 签发 HMAC HttpOnly Cookie `sf_auth`(30 天),密钥 `<save_dir>/secret.key`
(重启不掉线)。app.py 三个认 ?farm= 的地方(session_of / SSE / POST /api/farms)
都是**有 Cookie 强制本人牧场、无 Cookie 保持 ?farm= 旧语义**(脚本/AI 兼容)。
动登录逻辑别破坏这条边界;/api/me 刻意不 guard。
- SSE `/api/stream` 是唯一实时通道(单向);写操作一律 POST /api/actions;停机必须
hub.close_all()。uvicorn+zttp HTTP/2:uvicorn 0.53 不内置 h2,必须 `http="zttp"`;
信号接管=子类覆盖 Server.handle_exit(不调 super());as_asgi() 是 pyright 类型桥。
- app.py 结构:create_app 只装配,路由全在模块级 `_register_*`;轻量路径只走
brief_source()(BRIEF_KEYS 白名单要同步);>60 行的函数应视为信号。
- 守护进程:路径只有文件名时用 ensure_parent_dir()(别手写 makedirs(dirname));
pid 由服务进程自己写;端口自检用 port_busy()(判断「能连上」而非「能 bind」)。
## 架构约定(改动时必须遵守)
- **分层**:`farm.engine` 只管规则,不认识网络/终端;`farm.tui` 不认识 socket;
`farm/server/*` 不认识终端。新增玩法动作只改 `farm/server/actions.py` 一张表。
- **接口同构**:`farm.client.RemoteFarm` 必须与 `FarmEngine` 同名同语义,
这样 `tui.py` 不需要区分本地/远程。加动作时两边都要补。两条容易踩的:
`StateView.apply()` 里"带 `plots` = 完整快照(整体替换)、不带 = 精简状态(只合并字段)"——
搞反了播一次种就清空整片农田;`RemoteFarm._normalize()` 必须**先补 scheme 再削尾部斜杠**,
顺序反了 `"http://"` 会被削成 `"http:"`,地址没有主机名 → 请求溜到系统代理 → 报一个
和真实原因无关的 502。空视图(`StateView()`)也要预置 `plots`/`animals` 为空列表,
否则连不上服务器时读 `state.plots` 直接 AttributeError。
- **按键名统一小写**:`farm/keys.py` 是按键名的唯一来源,具名键全小写
(`UP`/`SPACE`/`TAB`/`ESC`/`CTRL_C` … 常量值即小写字符串),Windows 与 POSIX
两条路径必须返回**完全一样**的名字。`tui.py` 一律用 `keys.XXX` 引用,**不要手写
字符串字面量**:历史 bug 就是 Windows 侧产大写 `"UP"`、TUI 里写小写 `"up"`,
方向键/空格/Tab/ESC 全部静默失效(字母键正常,所以很难注意到)。
动 `farm/keys.py` 时,`farm/tui.py` 里所有具名键引用都要跟着核一遍。
读不到键盘时(管道 / 非 Win32 控制台)TUI 会用 `reader.reason` 在底部明示原因。
- **session 的 dirty / unsaved 必须分开**:`dirty` 只管推送节流(Publisher ~250ms 清一次),
`unsaved` 才是自动存档依据(只有成功的 `save()` 清它)。别再合并——合回来 autosave 就瞎了。
网页前端禁用 `window.prompt`/`confirm`(嵌入预览里被静默屏蔽),交互用页面内弹层。
- **多牧场**:一个 farm = 一个玩家 = 库里一行(`saves/farm.db`)+ 一个引擎 + 独立时钟;
时钟由**一个** `FarmClock` 线程统一驱动,不要给每个牧场开线程。
- **存档类动作**:`save` / `reset` 必须由会话(FarmSession)处理,
不能直接落到 `engine.save()`(会写默认路径、覆盖别人的档)。
- **实时通道(v2.1 起为 SSE)**:`GET /api/stream` 是唯一实时通道,**单向**推送
(welcome/state/day/notice/bye),节流 250ms,消息带自增 `seq`,空闲每 15s 发
`: keep-alive` 注释心跳。**写操作一律走 HTTP POST /api/actions**,不再有 WS 回执。
停机必须 `hub.close_all()`(否则长连接拖住 ASGI 服务器的优雅退出)+ 配置
`timeout_graceful_shutdown` 兜底;客户端 stop() 要 shutdown 底层 socket,
光 close() 会等读线程的锁(最长等到下一次心跳)。
- **ASGI 服务器(v2.3 起为 uvicorn + zttp 的 HTTP/2)**:`farm/server/__main__.py` 用
`uvicorn.Config(http="zttp", http2=True)` + `uvicorn.Server`。
**同一端口同时说 HTTP/1.1 与 HTTP/2**(TLS 走 ALPN;明文走 h2c prior-knowledge;
分流由 `AutoZttpProtocol` 完成;浏览器只在 https 下用 h2)。
关键:uvicorn 0.53 **自己不再内置 h2**,HTTP/2 必须装 `zttp` 并显式选 `http="zttp"`,
否则 `Config.load()` 直接 raise(错误信息里就写着 pip install zttp)。
两条停机路径汇到同一处:`POST /api/admin/shutdown` 与 Ctrl+C / SIGTERM / SIGBREAK
都调 `handle.request_stop()`。信号接管靠**子类覆盖 `Server.handle_exit`**
(0.53 的机制是 `capture_signals()` + `handle_exit`,没有 install_signal_handlers 可覆盖);
覆盖后不调 super()、也不写 `_captured_signals`,退出时就不会重放信号。第二次信号 = 强制退出。
`farm/server/app.py` 的 `as_asgi()` 是给 pyright 的类型桥:uvicorn 的
`uvicorn._types.Scope` 与 `starlette.types.Scope` 两个 TypedDict 互不兼容。
另:uvicorn 的启动顺序**也是**先 lifespan、后 bind 端口(旧注释曾写成"相反",已更正)。
- **端口自检**:uvicorn 会设 `SO_REUSEADDR`,Windows 上第二个实例能
"绑上"已被监听的端口并抢走新连接,所以入口启动前用 `daemon.port_busy()`
(判断"能不能连上",而不是"能不能 bind")自检,被占用就退 3 并提示换端口。
## 本机环境(coreutils 残缺禁管道 / LF / 句柄等详见用户级记忆 ~/.workbuddy/MEMORY.md)
- `RemoteFarm.snapshot()` 是本地缓存不发请求;realtime=False 的脚本必须自己 refresh(),
否则轮询读到旧数据像卡死。
- 本机 HTTP 一律走 farm/net.py 的 open_url()(环境有 HTTP_PROXY 没 no_proxy)。
- 幂等补丁脚本注意:`new == ""` 时 `if new in text` 恒真会误报 ALREADY 漏改;
删除类编辑直接用 Edit 工具。
- 批量改同一文件用幂等补丁脚本(read_bytes/write_bytes + APPLIED/ALREADY/MISS);
同一文件一条消息里多次 Edit 会被静默覆盖。
## 序列化与性能约定(2026-09-19 优化后固化)
- **轻量路径不许做完整序列化**:只要标量时(动作回执 / `session.summary()` / `app.brief()` /
排行榜单行)一律走 `engine.brief_source()`,**不要**调 `engine.snapshot()` 或
`state.to_dict()` 再挑字段——那是整局序列化(16 地 + 8 牲畜约 45µs,轻量路径只需 15µs)。
完整快照只在 `GET /api/state`、SSE `state` 消息、`POST /api/actions?full=1` 用。
`app.py` 的 `BRIEF_KEYS` / `BRIEF_TODO_KEYS` 是这两类字段的白名单,加字段要同步。
- `GameState.to_dict()` 里 `net_worth` **只算一次**再复用(`net_worth` / `goal_index` /
`next_goal` 三处都靠它);`net_worth` 是每次访问都重算的属性(约 5µs),别在循环里反复读。
「下一个目标」用模块级 `models.next_goal_after(index)`,不要再加 `next_goal` property。
- **`tools/bench.py` 是性能守卫**,不只是一次性脚本:它给 `to_dict` / `snapshot` /
`brief_source` / `net_worth` 装了调用计数器,谁把轻量路径改回 `snapshot()` 会立刻计数变红。
它用 ASGI 直调(不走 httpx),**不跑 FastAPI lifespan** —— 动了 lifespan 要用
`tests/test_server.py::LiveServerTestCase`(真 in-process uvicorn)复验。
- **`farm/server/app.py` 的结构**:`create_app()` 只做「建 `_Services` + 依次调
`_register_*`」,所有路由/生命周期都住在模块级 `_register_*` 函数里(曾经是一个 475 行的
内嵌闭包大函数)。加路由就加/改对应的 `_register_*`,别把逻辑塞回 `create_app`。
- **函数长度参考**:>60 行的函数应视为信号(重构前 15 个,现在 8 个,最长
`__main__._run_foreground` 131 行,其中大半是"必须内嵌的 uvicorn 停机子类")。
- **重复的等级门槛**统一走 `engine._is_locked/_require_level/_newly_unlocked`,
不要在 `catalog` / `available_actions` / `plant` / `buy_animal` / `_gain_exp` 各写一份。
## 远端与推送
- https://gitea.4i.hk/hhhge/SimpleFarm(public,main)。推送:
`GCM_INTERACTIVE=always git push origin main`(缺它 GCM 拒交互,报
"Failed to authenticate");还不行加 GCM_TRACE=1。沙箱会丢
.git/refs/remotes 写入使 status 显示 [gone]——假象,验证推送看 `git ls-remote`。
## 环境禁忌(本机特有)
- `os.makedirs(os.path.dirname(path), exist_ok=True)` 在 path **只有文件名**时
(`--pid-file pid.txt`)`dirname` 是空串,会以 FileNotFoundError 崩掉。
已抽 `farm/server/daemon.py` 的 `ensure_parent_dir()` 兜底,新代码用它,别再手写。
- `RemoteFarm.snapshot()` 是**本地缓存、不发请求**。关掉实时通道(`realtime=False`)的
脚本必须自己调 `refresh()`,否则轮询永远读到同一份旧数据——服务器在推进却看起来像卡死。
TUI / 网页走 SSE 所以没这个问题;`bot` 走 `available_actions()`(会顺手带回最新状态)。
- Windows 上**文件句柄还开着就 `os.remove()` 会失败**(PermissionError / WinError 32);
若被 `except OSError` 吞掉就是静默不生效(`clear_pid_file` 曾因此永远删不掉 pid 文件)。
一律"先读完 → 关掉句柄 → 再删"。
- Bash 的 coreutils 残缺(`ls`/`head`/`tail`/`grep`/**`cat`**/**`rm`** 缺失),**不要用管道**,会整条命令失败;
查看/删除文件一律写 Python 脚本(`pathlib.unlink()` / `shutil.rmtree()`)——连删临时脚本都别用
shell 的 `rm`(safe-bin shim 依赖 dirname,直接 127)。跑测试也一样:`cd /d/SimpleFarm && uv run python -m unittest ... 2>&1`
必须**整条无管道**(`| tail -40` 会让命令直接 127 退出,看起来像"没输出")。
PowerShell 跑原生命令的 `*>` 重定向会写成 UTF-16 + 中文乱码,**别用它读输出**。
- **写多行 git commit message 不要用 `git commit -m "$(cat <<'EOF' ... EOF)"`**:本机没有 `cat`,
会报 `cat: command not found` 然后 git 以为消息为空直接 `Aborting commit due to empty commit message`
(此时 `git add -A` 已经成功,暂存区是好的)。正确做法:把消息写进一个文件
(用 Write 工具,再 `read_bytes().replace(b"\r\n", b"\n")` 归一化成 LF,避免 CRLF 混进消息),
然后 `git commit -F <文件>`,最后删掉它。**注意顺序**:先 `git add -A` 再建消息文件,
这样消息文件不会进暂存区(而且见下条,它本来也会被忽略)。
- `.gitignore` 里有 **`/_*`**(根目录下划线开头的文件全部忽略):好处是临时补丁脚本
(`_splice_app.py`、`_commit_msg.txt` 之类)默认就不会污染 `git status`,跑完删不删都不碍事;
代价是**放在根目录、以 `_` 开头的正经文件也会被静默忽略**,要提交就换名字或放子目录。
- 环境设了 `HTTP_PROXY` 但没有 `no_proxy`:任何本机 HTTP 访问都要走 `farm/net.py` 的
`open_url()`(回环直连),否则会拿到 502 并误判为"服务器没起来"。
- Windows 下守护进程的 pid 必须由**服务进程自己**写(见 `farm/server/__main__.py`),
父进程的 `Popen().pid` 不可信。
- **写文件必须指定行尾**:Windows 上 `pathlib.write_text()` 会把 `\n` 转成 `\r\n`,
破坏本仓库"统一 LF"的约定(`.gitattributes` 已声明 `eol=lf`)。
用脚本改文件时一律 `read_bytes()/write_bytes()`,或 `write_text(..., newline="\n")`。
- **同一文件在一条消息里多次 Edit 会被静默覆盖**(只落地一部分,随后报出"幽灵"错误)。
批量改同一文件时改用**幂等 Python 补丁脚本**:整文件读入 → 精确匹配替换 →
逐条打印 `ALREADY/APPLIED/MISS`,跑完即删。判断"已应用"要 `if new in text`,
不要比对两行文本(会出现 new 是 old 子串的假阳性)。
- `git status` 报 M 但 `git diff` 为空,是索引 stat 缓存过期,不是真差异;
`git add -A`(内容相同时等于无操作)即恢复干净。
## 代码风格约定(ruff / pyright 已固化在 pyproject.toml)
- 行宽 **100**;字符串统一双引号;`isort` 把 `farm` 视为第一方。
- 类型标注用 PEP 585/604 写法(`dict[str, int]` / `X | None`),不要 `Dict` / `Optional`。
- 吞异常要显式:`contextlib.suppress(...)` 优先于 `try/except/pass`。
- 异常链:转换后重抛用 `raise ... from exc`;有意隐藏原始上下文用 `from None`(**不要省略**,会触发 B904)。
- 路径保持 `str`(不要为了 lint 而转 `Path`);`farm/__init__.py` 的惰性 `__all__` 导出是刻意设计。
- 改完代码的标准动作:`uv run ruff format . && uv run ruff check . && uv run pyright`
## 远端仓库与推送(gitea.4i.hk)
- 远端:**https://gitea.4i.hk/hhhge/SimpleFarm**(public,默认分支 `main`,owner `hhhge`),
本地 `origin` 已指向 `https://gitea.4i.hk/hhhge/SimpleFarm.git` 并设好 upstream。
- **推送认证走 GCM 的 generic provider**(`credential.https://gitea.4i.hk.provider=generic`
写在 `~/.gitconfig` 里)。关键:**非交互 shell 里必须带 `GCM_INTERACTIVE=always`**,
否则 GCM 直接 `Cannot prompt because user interactivity has been disabled`,
接着 git 报 `could not read Username / Failed to authenticate user`,看起来像认证配置坏了。
加上这个环境变量后 GCM 会静默用已有的 OAuth 凭据续期,不需要弹窗也不需要手输:
`GCM_INTERACTIVE=always git push -u origin main`
- `tea` CLI(0.16.0)已用 OAuth 登录该实例,配置在 `%LOCALAPPDATA%\tea\config.yml`
(token 存在系统 keyring,**不在配置文件里**)。可用来建仓与调 API:
`tea repos create --name X --login gitea.4i.hk`(**没有 `--public` 参数,默认就是公开**)、
`tea api <path> -l gitea.4i.hk [-X POST -d '<json>']`。
限制:**OAuth token 不能签发访问令牌**(`POST /users/{u}/tokens` 需要 basic auth,
会返回 401 `auth required`,这是 Gitea 防提权的设计);但**可以用它加 SSH 公钥**
(`POST /user/keys`),所以将来若要免密推送,加一把密钥是可行的。
- 本机 git 走 `http.proxy / https.proxy = http://127.0.0.1:7890`(写在 `~/.gitconfig`)。
- **沙箱怪癖**:`.git/refs/remotes/**` 里"新建目录"的写入跨命令调用会被丢弃,
于是 `git status -sb` 一直显示 `## main...origin/main [gone]`(远端其实好着呢)。
修法是**在同一个命令里**先 `mkdir -p .git/refs/remotes/origin` + 写入 ref 文件,
紧接着跑 `git status`——分开两条命令就又看不到。真实终端里直接 `git fetch` 即可。
## 相关文档
- 项目入口文档:`README.md`(接口表、SSE 协议、边界处理、设计取舍全在里面)
- 当天工作记录:`.workbuddy/memory/2026-09-19.md`
## 文档
README.md 是入口文档(接口表、SSE 协议、设计取舍);当天记录在
.workbuddy/memory/YYYY-MM-DD.md。
+9
View File
@@ -146,6 +146,8 @@ $ uv run main.py do harvest '{"plot":2}'
**多牧场**:用 `?farm=名字` 或请求头 `X-Farm-Id` 指定;不指定就是 `default`。
牧场不存在时会**自动创建**,所以每个客户端只需挑一个名字。
**网页端用用户名登录**(`POST /api/login`,Cookie 绑定同名牧场):登录后 `?farm=`
被忽略、只能操作自己的牧场;脚本/AI 不带 Cookie 时保持旧语义,不受影响。
### 5.1 最短决策闭环
@@ -164,6 +166,9 @@ POST /api/actions → {"action":"<上一步的 action>","params":{.
| GET | `/api/world` | 世界概览:排行榜、在线连接、时钟 |
| GET | `/api/farms` | 牧场列表(按净资产排名) |
| POST | `/api/farms` | `{"farm":"名字","owner":"昵称","tick":10}` 创建/进入 |
| POST | `/api/login` | `{"username":"名字"}` 用户名登录,设置 HttpOnly Cookie |
| POST | `/api/logout` | 退出登录(清除 Cookie) |
| GET | `/api/me` | 当前登录用户;未登录为 `null`(无需令牌) |
| GET | `/api/state` | 完整状态快照 |
| GET | `/api/catalog` | 作物 / 牲畜 / 商品与实时价格 / 建筑 / 天气表 / 仓容 |
| GET | `/api/actions` | 当前合法动作(AI 推荐入口) |
@@ -325,6 +330,10 @@ SSE 长连接在两种协议下都照常工作。
## 7. 多人模型
* **一个牧场 = 一个玩家**:独立引擎、独立时钟,存档在 `saves/farm.db`(SQLite,**一个牧场一行**)。
* **用户名登录**:网页首屏先登录(输入用户名即可,第一次用会自动开同名新牧场)。
Cookie 是 HMAC 签名的 HttpOnly,30 天有效,密钥在 `saves/secret.key`——重启不掉线。
登录后改 URL 也进不了别人的牧场;`curl`/脚本不带 Cookie 时保持 `?farm=` 旧语义
(互信小圈子模型:用户名即身份,不设密码,防的是"随手改 URL"而不是蓄意攻击)。
* 牧场之间完全隔离(金币、土地、市场各自独立),但共享排行榜(`GET /api/farms`)。
* 同牧场可以有多人/多程序同时操作,引擎内的 `RLock` 保证串行,不会出现"两个人同时种同一块地"这类竞态。
* 所有牧场的时钟由**一个** `FarmClock` 线程统一驱动(而不是每个牧场一条线程)。
+59 -8
View File
@@ -6,7 +6,8 @@
与人类可读的 ``message``,AI 可以直接拿 ``error`` 分支决策。
2. **状态紧随结果**:每个写操作都会在响应里附一份精简状态(``state``),
这样 AI 一次请求就能同时"做动作 + 看结果",省一半往返。
3. **多牧场隔离**:用 ``farm`` 参数(或 ``X-Farm-Id`` 头)区分玩家,
3. **多牧场隔离**:网页用用户名登录(Cookie),登录后只能操作自己的牧场;
脚本/AI 用 ``farm`` 参数(或 ``X-Farm-Id`` 头)区分玩家,
每个牧场有自己的引擎、时钟与存档;互不影响。
4. **实时通道**:``GET /api/stream`` 是 SSE 长连接,只负责**服务器 → 客户端**
的推送(状态、跨天、有人来串门);写操作统一走 ``POST /api/actions``。
@@ -23,13 +24,14 @@ from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass, field
from typing import Any
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse
from .. import config
from ..engine import GameError
from ..storage import StorageError
from . import actions as actions_mod
from .auth import COOKIE_NAME, TTL_SECONDS, LoginManager
from .hub import (
CLOSE_MESSAGE,
HEARTBEAT_SECONDS,
@@ -178,7 +180,8 @@ def rules_text() -> str:
"不会替你花钱)、温室免疫暴风雨且水分蒸发减半。同类建筑越造越贵。\n"
f"9. 目标:把净资产(金币 + 土地 + 牲畜 + 饲料 + 地里作物 + **谷仓存货**)"
f"做到 {config.GOALS[-1][0]} 金币。\n"
"10. 多人:每个 farm 是一个独立牧场,用 ?farm=名字 区分,互不影响;"
'10. 多人:网页用用户名登录(POST /api/login "username",Cookie 绑定同名牧场,'
"登录后只能操作自己的);脚本/AI 无 Cookie 时用 ?farm=名字 区分,互不影响;"
"排行榜见 GET /api/world。\n"
"\n推荐决策循环(AI 最短路径):\n"
" GET /api/actions?farm=me → 拿到当前所有**合法**动作(含开销与理由)\n"
@@ -214,6 +217,7 @@ class _Services:
cfg: ServerConfig
manager: SessionManager
auth: LoginManager
hub: ConnectionHub
publisher: Publisher
clock: FarmClock
@@ -233,6 +237,7 @@ class _Services:
return cls(
cfg=cfg,
manager=manager,
auth=LoginManager(cfg.save_dir),
hub=hub,
publisher=Publisher(manager, hub, autosave_interval=cfg.autosave_interval),
clock=FarmClock(manager),
@@ -280,9 +285,21 @@ class _Services:
if not self.authorized(self.token_of(request)):
raise GameError("unauthorized", "缺少或错误的 API Token", status=401)
def user_of(self, request: Request) -> str | None:
"""登录 Cookie 里的用户名;没登录返回 None。"""
return self.auth.verify(request.cookies.get(COOKIE_NAME))
def session_of(self, request: Request, owner: str = "") -> FarmSession:
farm = request.headers.get("x-farm-id") or request.query_params.get("farm") or DEFAULT_FARM
return self.manager.get(farm, create=True, owner=owner)
user = self.user_of(request)
if user:
# 登录后只能玩自己的牧场:?farm= / X-Farm-Id 被忽略,
# 改 URL 碰不到别人的档。没有 Cookie 的脚本保持旧语义(互信小圈子)。
farm = user
else:
farm = (
request.headers.get("x-farm-id") or request.query_params.get("farm") or DEFAULT_FARM
)
return self.manager.get(farm, create=True, owner=user or owner)
@staticmethod
def full_requested(request: Request) -> bool:
@@ -414,6 +431,36 @@ def _register_meta_routes(app: FastAPI, svc: _Services) -> None:
}
def _register_auth_routes(app: FastAPI, svc: _Services) -> None:
@app.post("/api/login", tags=["登录"], summary="用户名登录(设置 HttpOnly Cookie)")
async def login(request: Request, response: Response) -> dict[str, Any]:
"""用户名即身份(互信小圈子模型):登录后 Cookie 绑定同名牧场。"""
svc.guard(request)
body = await svc.body_of(request)
user = validate_farm_id(body.get("username") or body.get("user") or body.get("name"))
response.set_cookie(
COOKIE_NAME,
svc.auth.issue(user),
max_age=TTL_SECONDS,
httponly=True,
samesite="lax",
path="/",
)
return {"ok": True, "user": user, "farm": user, "message": f"欢迎回来,{user}!"}
@app.post("/api/logout", tags=["登录"], summary="退出登录(清除 Cookie)")
async def logout(request: Request, response: Response) -> dict[str, Any]:
svc.guard(request)
response.delete_cookie(COOKIE_NAME, path="/")
return {"ok": True, "message": "已退出登录"}
@app.get("/api/me", tags=["登录"], summary="当前登录的用户")
async def me(request: Request) -> dict[str, Any]:
# 刻意不 guard:只回答"登录了没有",不泄露任何牧场数据;
# 网页首屏第一件事就是问它。
return {"ok": True, "user": svc.user_of(request)}
def _register_world_routes(app: FastAPI, svc: _Services) -> None:
@app.get("/api/world", tags=["世界"], summary="世界概览:排行榜 + 在线连接 + 时钟")
async def world(request: Request) -> dict[str, Any]:
@@ -440,8 +487,10 @@ def _register_world_routes(app: FastAPI, svc: _Services) -> None:
async def create_farm(request: Request) -> dict[str, Any]:
svc.guard(request)
body = await svc.body_of(request)
farm = validate_farm_id(body.get("farm") or body.get("name"))
owner = str(body.get("owner") or "")
# 登录后牧场固定为本人:改 body 也进不了别人的牧场
user = svc.user_of(request)
farm = user or validate_farm_id(body.get("farm") or body.get("name"))
owner = user or str(body.get("owner") or "")
existed = svc.manager.find(farm) is not None
session = svc.manager.get(farm, create=True, owner=owner)
tick = body.get("tick")
@@ -630,7 +679,8 @@ def _register_stream_route(app: FastAPI, svc: _Services) -> None:
"""
svc.guard(request)
params = request.query_params
farm = validate_farm_id(params.get("farm") or DEFAULT_FARM)
# 登录用户只能听自己的牧场;没有 Cookie(脚本/TUI)用 ?farm=
farm = svc.user_of(request) or validate_farm_id(params.get("farm") or DEFAULT_FARM)
# 牧场数达上限等情况会抛 GameError,交给全局处理器回 4xx JSON
session = svc.manager.get(farm, create=True)
@@ -734,6 +784,7 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
_register_error_handlers(app, svc)
_register_pages(app, svc)
_register_meta_routes(app, svc)
_register_auth_routes(app, svc)
_register_world_routes(app, svc)
_register_farm_routes(app, svc)
_register_action_routes(app, svc)
+84
View File
@@ -0,0 +1,84 @@
"""用户名登录:HMAC 签名的 HttpOnly Cookie,无密码。
信任模型是「互信小圈子」(用户选了仅用户名):用户名即身份,Cookie 防的是
"别人随手改 URL 玩你的牧场",不是防蓄意攻击者。要点三个:
* Cookie 值 = base64(payload) + "." + HMAC-SHA256,payload 是 ``用户名|过期时间``。
验签用 ``hmac.compare_digest``(恒时比较),防篡改;过期 30 天。
* 签名密钥存 ``<save_dir>/secret.key``(0600):重启不掉线,也不用建表存 token。
* 没有 Cookie 的请求(脚本 / AI / CLI)不受影响——继续走 ``?farm=``,
兼容语义收在 ``app._Services.session_of`` 一处,这里只管签发与验签。
"""
import base64
import hashlib
import hmac
import os
import secrets
import time
from farm.engine import GameError
from .session import validate_farm_id
COOKIE_NAME = "sf_auth"
TTL_SECONDS = 30 * 86400
# 密钥文件名固定,放存档目录里——它跟存档一样属于"这台服务器的本地秘密"
SECRET_FILE = "secret.key"
class LoginManager:
"""签发与校验登录 Cookie。"""
def __init__(self, secret_dir: str) -> None:
os.makedirs(secret_dir, exist_ok=True)
self.secret = self._load_or_create(os.path.join(secret_dir, SECRET_FILE))
@staticmethod
def _load_or_create(path: str) -> bytes:
try:
with open(path, "rb") as handle:
raw = bytes.fromhex(handle.read().strip().decode("ascii"))
if len(raw) >= 32:
return raw
except (OSError, ValueError, UnicodeDecodeError):
# 文件不存在 / 不是合法 hex / 长度不够:统统当作没有,重新生成
pass
raw = secrets.token_bytes(32)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as handle:
handle.write(raw.hex().encode("ascii"))
return raw
def issue(self, username: str) -> str:
"""给用户名签一枚 Cookie 值。用户名不合法直接抛 bad_farm。"""
user = validate_farm_id(username)
payload = f"{user}|{int(time.time()) + TTL_SECONDS}"
sig = hmac.new(self.secret, payload.encode("utf-8"), hashlib.sha256).hexdigest()
blob = base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii")
return f"{blob}.{sig}"
def verify(self, token: str | None) -> str | None:
"""校验 Cookie 值,返回用户名;任何一步不对都返回 None(当没登录)。"""
if not token or "." not in token:
return None
blob, _, sig = token.rpartition(".")
try:
payload = base64.urlsafe_b64decode(blob.encode("ascii")).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return None
want = hmac.new(self.secret, payload.encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(want, sig):
return None
user, _, exp_text = payload.rpartition("|")
try:
expires = int(exp_text)
except ValueError:
return None
if expires < time.time():
return None
try:
return validate_farm_id(user)
except GameError:
return None
+63 -15
View File
@@ -98,6 +98,8 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
}
.toast.show { opacity: .93; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.badge { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 5px 12px; font-size: 13px; }
.hidden { display: none; }
.animals li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px dashed var(--line); list-style: none; }
ul { padding: 0; margin: 0; }
</style>
@@ -107,9 +109,9 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
<h1>SimpleFarm <span>小型农场游戏服务器</span></h1>
<div class="dot" id="status"><i></i><span id="statustext">未连接</span></div>
<div class="spacer"></div>
<input id="farm" size="10" placeholder="牧场名" />
<span class="badge" id="who">未登录</span>
<input id="token" size="10" placeholder="令牌(可选)" />
<button class="primary" id="join">进入牧场</button>
<button id="logout" class="hidden">退出</button>
</header>
<main>
@@ -176,6 +178,17 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
<div class="toast" id="toast"></div>
<div class="mask" id="loginmask">
<div class="pick">
<h3>登录到你的牧场</h3>
<p style="color:#6b7a66;margin:6px 0 10px">输入用户名即可——第一次用会自动开一个新牧场。</p>
<div class="row" style="justify-content:center">
<input id="loginname" size="14" placeholder="用户名" />
<button class="primary" id="dologin">登录</button>
</div>
</div>
</div>
<div class="mask" id="pickmask">
<div class="pick">
<h3 id="picktitle">要种什么?</h3>
@@ -185,7 +198,7 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
<script>
const $ = (id) => document.getElementById(id);
let es = null, state = null, catalog = null, pollTimer = null;
let es = null, state = null, catalog = null, pollTimer = null, me = null;
const cropNames = {
wheat: "小麦", carrot: "胡萝卜", tomato: "番茄",
@@ -212,7 +225,7 @@ function setStatus(text, cls) {
$("status").className = "dot " + (cls || "");
}
function farmName() { return ($("farm").value || "default").trim() || "default"; }
function farmName() { return me || "default"; }
function token() { return ($("token").value || "").trim(); }
function headers() {
@@ -483,21 +496,50 @@ $("pickmask").addEventListener("click", (ev) => {
if (ev.target.id === "pickmask") $("pickmask").classList.remove("show");
});
$("join").addEventListener("click", () => {
localStorage.setItem("sf.farm", farmName());
localStorage.setItem("sf.token", token());
fetch("/api/farms", {
/* ---- 登录:用户名即牧场身份,Cookie 由服务器签发 ---- */
async function whoami() {
const d = await api("/api/me");
me = (d && d.user) || null;
applyIdentity();
}
function applyIdentity() {
$("who").textContent = me ? "🌾 " + me : "未登录";
$("logout").classList.toggle("hidden", !me);
$("loginmask").classList.toggle("show", !me);
if (me) {
localStorage.setItem("sf.farm", me);
connect();
} else if (es) {
try { es.close(); } catch (e) {}
es = null;
clearInterval(pollTimer);
setStatus("未登录", "");
}
}
async function doLogin() {
const name = ($("loginname").value || "").trim();
if (!name) { toast("先填一个用户名"); return; }
const d = await api("/api/login", {
method: "POST", headers: headers(),
body: JSON.stringify({ farm: farmName() })
}).then((r) => r.json()).then((d) => { toast(d.message || ""); });
connect();
body: JSON.stringify({ username: name })
});
if (d && d.ok) { me = d.user; toast(d.message); applyIdentity(); }
}
$("dologin").addEventListener("click", doLogin);
$("loginname").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
$("logout").addEventListener("click", async () => {
await api("/api/logout", { method: "POST", headers: headers() });
me = null;
applyIdentity();
$("loginname").focus();
});
document.addEventListener("keydown", (e) => { if (e.key === "Enter") $("join").click(); });
/* ---- 启动 ---- */
$("farm").value = localStorage.getItem("sf.farm") || "default";
$("token").value = localStorage.getItem("sf.token") || "";
connect();
$("token").addEventListener("change", () => {
localStorage.setItem("sf.token", token());
if (me) connect();
});
whoami();
setInterval(renderBoard, 5000);
</script>
</body>
@@ -522,6 +564,12 @@ def dashboard_text(host: str, port: int) -> str:
" -H 'Content-Type: application/json' \\\n"
' -d \'{"action":"plant","params":{"plot":1,"crop":"wheat"}}\'\n'
"\n"
"登录(网页用;脚本可以不带 Cookie 继续 ?farm=)\n"
"----------------------------------------------\n"
'POST /api/login {"username":"名字"} → 设置 Cookie,登录后只能操作自己的牧场\n'
"GET /api/me 当前登录用户(未登录为 null)\n"
"POST /api/logout 退出登录\n"
"\n"
"读取类\n"
"------\n"
"GET /api/world 世界概览 + 排行榜\n"
+97
View File
@@ -24,6 +24,7 @@ import urllib.request
from farm import config, net
from farm.engine import GameError
from farm.server.auth import COOKIE_NAME, LoginManager
from farm.server.daemon import DaemonManager, ensure_parent_dir, pid_alive, port_busy
from farm.server.hub import (
CLOSE_MESSAGE,
@@ -53,6 +54,7 @@ try: # pragma: no cover - 环境相关
ServerConfig,
ServerHandle,
_int,
_Services,
as_asgi,
brief,
create_app,
@@ -1111,6 +1113,28 @@ class AuthTestCase(unittest.TestCase):
response = self.client.post("/api/actions?farm=alice", json={"action": "buy_feed"})
self.assertEqual(response.status_code, 401)
def test_login_sets_cookie_and_me_reports_it(self):
response = self.client.post("/api/login?token=s3cret", json={"username": "carol"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["user"], "carol")
self.assertEqual(self.client.get("/api/me").json()["user"], "carol")
def test_login_needs_the_token_too(self):
# 令牌是整台服务器的门,登录不豁免
self.assertEqual(
self.client.post("/api/login", json={"username": "carol"}).status_code, 401
)
def test_logged_in_user_cannot_reach_other_farms(self):
self.client.post("/api/login?token=s3cret", json={"username": "carol"})
data = self.client.get("/api/state?token=s3cret&farm=boss").json()
self.assertEqual(data["farm"], "carol")
def test_logout_clears_the_cookie(self):
self.client.post("/api/login?token=s3cret", json={"username": "carol"})
self.client.post("/api/logout?token=s3cret")
self.assertIsNone(self.client.get("/api/me").json()["user"])
def test_remote_shutdown_can_be_switched_off(self):
app = create_app(
ServerConfig(
@@ -1122,6 +1146,79 @@ class AuthTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 403)
@unittest.skipUnless(HAS_FASTAPI, "未安装 FastAPI")
class LoginCookieTestCase(unittest.TestCase):
"""登录 Cookie 的签发与校验(纯逻辑档,不需要 httpx 也能跑)。"""
def setUp(self):
from fastapi import Request
self.dir = tempfile.mkdtemp(prefix="simplefarm-login-")
self.auth = LoginManager(self.dir)
self._request_cls = Request
def request(self, cookie: str | None = None, query: bytes = b"", headers=()):
raw = [(b"host", b"test")]
if cookie:
raw.append((b"cookie", cookie.encode("ascii")))
raw.extend(headers)
return self._request_cls(
{"type": "http", "headers": raw, "query_string": query, "method": "GET"}
)
def test_issue_then_verify_roundtrips_the_username(self):
self.assertEqual(self.auth.verify(self.auth.issue("小明_2")), "小明_2")
def test_tampered_signature_is_rejected(self):
token = self.auth.issue("carol")
sig = token.rpartition(".")[2]
self.assertIsNone(self.auth.verify(token[:-1] + ("0" if sig[-1] != "0" else "1")))
def test_token_from_another_secret_is_rejected(self):
other = LoginManager(tempfile.mkdtemp(prefix="simplefarm-login2-"))
self.assertIsNone(other.verify(self.auth.issue("carol")))
def test_expired_token_is_rejected(self):
from farm.server import auth as auth_mod
saved = auth_mod.TTL_SECONDS
auth_mod.TTL_SECONDS = -10 # 签出来就是过期的
try:
token = self.auth.issue("carol")
finally:
auth_mod.TTL_SECONDS = saved
self.assertIsNone(self.auth.verify(token))
def test_garbage_tokens_are_rejected(self):
for bad in ("", "abc", "a.b", "!!!.###", self.auth.issue("carol") + "x"):
self.assertIsNone(self.auth.verify(bad))
def test_illegal_username_is_refused_at_issue(self):
with self.assertRaises(GameError):
self.auth.issue("有 空格")
with self.assertRaises(GameError):
self.auth.issue("x" * 30)
def test_secret_file_is_reused_across_restarts(self):
again = LoginManager(self.dir)
self.assertEqual(again.verify(self.auth.issue("carol")), "carol")
def test_session_of_prefers_the_cookie_over_the_url(self):
svc = _Services.create(ServerConfig(save_dir=self.dir, tick_seconds=2400.0))
cookie = f"{COOKIE_NAME}={self.auth.issue('carol')}"
self.assertEqual(
svc.session_of(self.request(cookie=cookie, query=b"farm=boss")).farm_id, "carol"
)
def test_session_of_falls_back_without_a_cookie(self):
svc = _Services.create(ServerConfig(save_dir=self.dir, tick_seconds=2400.0))
self.assertEqual(svc.session_of(self.request(query=b"farm=boss")).farm_id, "boss")
self.assertEqual(
svc.session_of(self.request(headers=[(b"x-farm-id", b"boss")])).farm_id, "boss"
)
self.assertEqual(svc.session_of(self.request()).farm_id, DEFAULT_FARM)
@unittest.skipUnless(HAS_UVICORN and HAS_FASTAPI, "未安装 uvicorn / FastAPI")
class LiveServerTestCase(unittest.TestCase):
"""真起一个服务器进程内的 uvicorn,用真 socket 说话。"""