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