多用户之前是「知道牧场名就能玩」:?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 仍有效且进度保留。
85 lines
3.2 KiB
Python
85 lines
3.2 KiB
Python
"""用户名登录: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
|