Ctrl K
ComunidadGuías…

src/webui/device_tokens.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""已配对设备的独立 owner 凭据。

为什么不是「扫码换回访问密码」:访问密码在 STATE 里是 PBKDF2 哈希,服务端
自己也不知道明文,换不出来。更重要的是即便能换,把主密码分发到每台手机上
也是错的——一台设备丢失就只能整体改密码,其它设备跟着全掉线。

所以配对成功签发的是设备令牌:
- 与访问密码平级的 owner 凭据,同样走 ``Authorization: Bearer``;
- 高熵随机串,落盘只存 sha256 摘要(随机 token 不需要 KDF,而且它要在每个
  请求上验证,慢哈希会直接变成每请求成本);
- 可逐台吊销,吊销后该设备立即失去 owner 权限,不影响其它设备与主密码。
"""

from __future__ import annotations

import hashlib
import json
import logging
import secrets
import time
from datetime import datetime, timezone
from pathlib import Path

from aiohttp import web


logger = logging.getLogger("trpg")

MAX_DEVICES = 64
MAX_LABEL_CHARS = 40
# last_seen 每个请求都会变;按秒落盘会把设备清单写成热点文件,这里做节流。
LAST_SEEN_FLUSH_SECONDS = 60


def _now_iso() -> str:
    return (
        datetime.now(timezone.utc)
        .isoformat(timespec="seconds")
        .replace("+00:00", "Z")
    )


def _digest(token: str) -> str:
    return hashlib.sha256(token.strip().encode("utf-8")).hexdigest()


def sanitize_label(value: object) -> str:
    text = " ".join(str(value or "").split())[:MAX_LABEL_CHARS]
    return text


class InstallIdInUse(ValueError):
    """Another paired device is already bound to this install id."""


def normalize_install_id(value: object) -> str:
    """A client install id (canonical id) or ``""``; never a guess."""
    from src.engine.world.contracts import CANONICAL_ID_PATTERN

    text = value if isinstance(value, str) else ""
    return text if CANONICAL_ID_PATTERN.fullmatch(text) else ""


class DeviceTokenStore:
    """已配对设备清单;令牌只以摘要形式落盘,明文只在签发响应里出现一次。"""

    def __init__(self, data_dir: Path, max_devices: int = MAX_DEVICES) -> None:
        self.path = Path(data_dir) / "paired_devices.json"
        self.max_devices = max(1, int(max_devices))
        self._devices: list[dict] = self._load()
        self._last_flush = 0.0

    # ---- 查询 ----

    def entries(self) -> list[dict]:
        """给设置页用的清单;不含任何可用于鉴权的字段。"""
        return [
            {
                "id": device["id"],
                "label": device["label"],
                "created_at": device["created_at"],
                "last_seen_at": device["last_seen_at"],
                "install_id": device.get("install_id", ""),
            }
            for device in sorted(
                self._devices,
                key=lambda item: item["created_at"],
                reverse=True,
            )
        ]

    def verify(self, token: str) -> dict | None:
        """校验 Bearer 是否为有效设备令牌;命中时顺带记录活跃时间。"""
        candidate = str(token or "").strip()
        if not candidate:
            return None
        digest = _digest(candidate)
        for device in self._devices:
            if secrets.compare_digest(device["digest"], digest):
                self._touch(device)
                return device
        return None

    def has(self, device_id: str) -> bool:
        """Whether ``device_id`` is still paired (cheap: no digest work)."""
        target = str(device_id or "")
        return bool(target) and any(device["id"] == target for device in self._devices)

    # ---- 变更 ----

    def issue(self, label: str = "", install_id: str = "") -> tuple[str, dict]:
        if self.install_id_holder(install_id):
            raise InstallIdInUse(install_id)
        token = secrets.token_urlsafe(32)
        device = {
            "id": secrets.token_hex(8),
            "label": sanitize_label(label),
            "digest": _digest(token),
            "created_at": _now_iso(),
            "last_seen_at": "",
            "install_id": normalize_install_id(install_id),
        }
        self._devices.append(device)
        # 超额时淘汰最久未活跃的设备,避免清单被无限撑大。
        while len(self._devices) > self.max_devices:
            self._devices.remove(
                min(
                    self._devices,
                    key=lambda item: item["last_seen_at"] or item["created_at"],
                )
            )
        self._save()
        return token, device

    # ---- 内容同步身份 ----

    def bind_install_id(self, device_id: str, install_id: str) -> str:
        """Return the install id this device is bound to, binding it on first use.

        A device paired before install ids existed has none; its first
        declared content push binds the id it declares. Afterwards the device
        may only declare that id. Returns ``""`` for an unknown device.
        """
        target = str(device_id or "").strip()
        wanted = normalize_install_id(install_id)
        for device in self._devices:
            if device["id"] != target:
                continue
            if not device.get("install_id") and wanted:
                if self.install_id_holder(wanted, excluding=target):
                    raise InstallIdInUse(wanted)
                device["install_id"] = wanted
                self._save()
            return str(device.get("install_id") or "")
        return ""

    def install_id_holder(self, install_id: str, *, excluding: str = "") -> str:
        """The paired device already bound to ``install_id`` (``""`` if none)."""
        wanted = normalize_install_id(install_id)
        if not wanted:
            return ""
        for device in self._devices:
            if device["id"] != excluding and device.get("install_id") == wanted:
                return str(device["id"])
        return ""

    def clear_install_id(self, device_id: str) -> bool:
        """Owner action: let the device bind a new install id (e.g. after a reinstall)."""
        target = str(device_id or "").strip()
        for device in self._devices:
            if device["id"] == target:
                device["install_id"] = ""
                self._save()
                return True
        return False

    def revoke(self, device_id: str) -> bool:
        target = str(device_id or "").strip()
        remaining = [device for device in self._devices if device["id"] != target]
        if len(remaining) == len(self._devices):
            return False
        self._devices = remaining
        self._save()
        return True

    def revoke_all(self) -> int:
        count = len(self._devices)
        self._devices = []
        self._save()
        return count

    # ---- 持久化 ----

    def _touch(self, device: dict) -> None:
        device["last_seen_at"] = _now_iso()
        now = time.monotonic()
        if now - self._last_flush < LAST_SEEN_FLUSH_SECONDS:
            return
        self._last_flush = now
        self._save()

    def _load(self) -> list[dict]:
        if not self.path.exists():
            return []
        try:
            with self.path.open(encoding="utf-8") as file:
                payload = json.load(file)
            raw = payload.get("devices", []) if isinstance(payload, dict) else []
            if not isinstance(raw, list):
                raise ValueError("devices is not a list")
        except (OSError, ValueError, json.JSONDecodeError, KeyError):
            # 清单损坏时按「无已配对设备」继续:宁可让用户重新配对,
            # 也不能把无法解析的内容当成有效凭据放行。
            logger.warning("已配对设备清单损坏,按空清单继续", exc_info=True)
            return []
        devices: list[dict] = []
        for entry in raw:
            if not isinstance(entry, dict):
                continue
            digest = str(entry.get("digest") or "")
            if len(digest) != 64:
                continue
            devices.append(
                {
                    "id": str(entry.get("id") or secrets.token_hex(8)),
                    "label": sanitize_label(entry.get("label")),
                    "digest": digest,
                    "created_at": str(entry.get("created_at") or ""),
                    "last_seen_at": str(entry.get("last_seen_at") or ""),
                    "install_id": normalize_install_id(entry.get("install_id")),
                }
            )
        return devices[-self.max_devices:]

    def _save(self) -> None:
        payload = json.dumps({"devices": self._devices}, ensure_ascii=False, indent=2)
        temporary = self.path.with_suffix(self.path.suffix + ".tmp")
        try:
            self.path.parent.mkdir(parents=True, exist_ok=True)
            temporary.write_text(payload, encoding="utf-8")
            temporary.replace(self.path)
        except OSError:
            logger.exception("已配对设备清单写入失败")


DEVICE_TOKENS_KEY = web.AppKey("device_tokens", DeviceTokenStore)