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
+391 -215

No files matched your search

+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"