Files
SimpleFarm/farm/server/auth.py
T
snowy-yang 698a5e497a 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 仍有效且进度保留。
2026-09-23 21:20:25 +08:00

85 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""用户名登录: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