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:
1 parent
d1692d7b10
commit
698a5e497a
7 files changed
+391
-215
No files matched your search
+59
-8
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user