Add admin official account connection UI and persistent session secrets

This commit is contained in:
Pigway
2026-10-04 00:20:43 +08:00
parent 5aa0e9f762
commit b76af5ada3
12 changed files with 400 additions and 12 deletions
@@ -13,11 +13,19 @@ FrameDeviceDriver protocol does not depend on account login or widgets.
The public `/api/v1/devices/{device_id}/widgets` endpoint is unchanged. The old
`drivers.kokonna.widgets.WidgetReader` import remains a compatibility re-export.
No raw RPC proxy or public password-login endpoint is introduced. Future admin
account connection UI must use this module and keep credentials server-side.
No account or session secret is automatically persisted by this module.
No raw RPC proxy is exposed. The admin-only `/official/connection` endpoint
provides login (POST), state/discovery (GET), local frame association (PUT), and
local disconnect (DELETE), under `/api/v1/devices/{device_id}`. Browser requests
use the existing Frame Flow session and CSRF protection; API clients require admin.
`connection.py` persists the official session and selected robot in
`/config/official-{device_id}.secret`, atomically with mode 0600. Passwords are
transient and never stored. These files must not be committed or exposed to users.
Disconnect clears the local secret, including on restart; it does not revoke
remote official account sessions. Expired sessions require logging in again.
No login refresh or stored-password automatic retry is performed.
设备 API Key 仍负责现有设备、图片和图库接口。官方账号功能集中在本目录,
不进入 DisplayService、Renderer、Scheduler 或 FrameDeviceDriver。
账号登录已提供内部客户端能力;页面连接入口、写操作和会话持久化尚未实现。
账号连接页面与会话持久化已实现;额外写操作尚未实现。
所有已发现但未启用的 RPC 方法均拒绝执行,不复用 API Key 的写入开关。
@@ -0,0 +1,162 @@
"""Admin account connection; password transient, session persisted as a secret."""
import json
import os
import tempfile
import threading
from pathlib import Path
from pydantic import Field, SecretStr
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.integrations.kokonna_official.rpc import OfficialRPC
from pigway_frame_flow.models import StrictModel
class LoginRequest(StrictModel):
username: str = Field(min_length=1, max_length=120)
password: SecretStr = Field(min_length=1, max_length=256)
class SelectRequest(StrictModel):
robot_id: int = Field(gt=0)
class OfficialDevice(StrictModel):
id: int
name: str
model: str | None = None
model_code: str | None = None
online: bool = False
class ConnectionState(StrictModel):
authorized: bool
username: str
robot_id: int | None
mock: bool
devices: list[OfficialDevice] = Field(default_factory=list)
class Connection:
def __init__(self, reader, path: Path, secrets: list):
self.reader, self.path, self.secrets = reader, path, secrets
self.username = ""
self.lock = threading.RLock()
if path.exists():
data = json.loads(path.read_text())
reader.rpc = OfficialRPC(data["token"])
reader._robot_id = data["robot_id"]
self.username = data["username"]
self.secrets.append(data["token"])
def _save(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w", dir=self.path.parent, delete=False
) as f:
temp = Path(f.name)
try:
json.dump(
{
"token": self.reader.rpc._token,
"username": self.username,
"robot_id": self.reader._robot_id,
},
f,
)
f.flush()
os.fsync(f.fileno())
temp.chmod(0o600)
os.replace(temp, self.path)
finally:
temp.unlink(missing_ok=True)
def _devices(self, rpc):
if self.reader.mock:
return [
OfficialDevice(
id=1,
name="Mock Frame",
model="13.3-inch",
model_code="B101",
online=True,
)
]
try:
data = rpc.devices()["data"]
return [
OfficialDevice(
id=row["id"],
name=row.get("robotNickname") or "Frame",
model=(row.get("model") or {}).get("name"),
model_code=(row.get("model") or {}).get("code"),
online=bool(row.get("online")),
)
for row in data
]
except (KeyError, TypeError, ValueError, AttributeError):
raise FlowError(
E.CLOUD_UNAVAILABLE, "Official device list unavailable", 502
) from None
def state(self, discovery=False):
with self.lock:
return ConnectionState(
authorized=self.reader.rpc.authorized,
username=self.username,
robot_id=self.reader._robot_id,
mock=self.reader.mock,
devices=self._devices(self.reader.rpc)
if discovery and self.reader.rpc.authorized
else [],
)
def login(self, username, password):
with self.lock:
rpc = OfficialRPC("mock-session" if self.reader.mock else "")
if not self.reader.mock:
rpc.login(username, password)
devices = self._devices(rpc)
self.reader.rpc = rpc
self.reader._robot_id = None
self.reader._cached = None
self.username = username
self.secrets.append(rpc._token)
self._save()
return ConnectionState(
authorized=True,
username=username,
robot_id=None,
mock=self.reader.mock,
devices=devices,
)
def select(self, robot_id):
with self.lock:
if not self.reader.rpc.authorized:
raise FlowError(
E.CONFIG_REQUIRED, "Official account authorization required", 503
)
if not any(d.id == robot_id for d in self._devices(self.reader.rpc)):
raise FlowError(
E.DEVICE_NOT_FOUND, "Official device not in this account", 404
)
self.reader._robot_id = robot_id
self.reader._cached = None
self._save()
return self.state()
def disconnect(self):
with self.lock:
self.reader.rpc.disconnect()
self.reader._robot_id = None
self.reader._cached = None
self.username = ""
self._save()
return self.state()
def widgets(self):
with self.lock:
return self.reader.read()
@@ -46,7 +46,7 @@ class WidgetReader:
configured=False,
catalog=catalog,
observed_at=snapshot["observed_at"],
status="AUTH_REQUIRED",
status="DEVICE_REQUIRED" if self.rpc.authorized else "AUTH_REQUIRED",
)
try:
data = self.rpc.widgets(self._robot_id)