"""用户名登录:HMAC 签名的 HttpOnly Cookie,无密码。 信任模型是「互信小圈子」(用户选了仅用户名):用户名即身份,Cookie 防的是 "别人随手改 URL 玩你的牧场",不是防蓄意攻击者。要点三个: * Cookie 值 = base64(payload) + "." + HMAC-SHA256,payload 是 ``用户名|过期时间``。 验签用 ``hmac.compare_digest``(恒时比较),防篡改;过期 30 天。 * 签名密钥存 ``/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