agent-Specialization/server/gateway_auth.py
JOJO aa9bf377a3 feat(runtime): Gateway 收尾——session 传输暴露、host Bearer 通道、flask 依赖拆解、审批链路收口
- session.* 传输暴露:新增 gateway_api 蓝图(list/create/history),RuntimeService 补 create_session
- host Bearer 通道:gateway_auth 双通道认证(host 模式+回环限定,token 存 DATA_DIR/host_api_token),tasks/approval 路由接入
- api_v1 会话路由转调公共入口消双轨,会话索引/列表补齐 run_mode/model_key/custom_prompt_name/personalization_name
- flask 包依赖拆解:_flask_bridge 延迟桥接 + context 子包 PEP 562 懒加载,import server.tasks/runtime 不再拉起 flask
- 审批链路:mark_expired 终态回写(超时/软停止/取消三路径)+ 终态 TTL 惰性清理(3600s)
- 测试 patch 点随迁;全量 75 测试失败恰为 4 项存量,独立启动验收 4/4 全绿

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
2026-09-08 11:37:51 +08:00

143 lines
4.9 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.

"""Gateway 本机客户端通道认证host Bearer token2026-09-08 新增)。
定位:协议 docs/runtime_protocol.md §6「认证与授权按通道裁剪」的 host 通道
实现——让 CLI/GUI 等本机客户端无需 Web 会话流程Cookie/CSRF/host-login
即可接入 Gateway 公共协议端点。
安全模型与 /host-login 完全对齐host 模式定位为本机单人使用):
- 仅 TERMINAL_SANDBOX_MODE=host且非 LINUX_SAFETY生效
- 仅允许回环地址127.0.0.1/::1/localhost直连调用
- token 为本机生成的随机串,存于运行态数据目录
<DATA_DIR>/host_api_token权限 0600本机客户端读取后经
`Authorization: Bearer <token>` 携带。
"""
from __future__ import annotations
import functools
import hmac
import os
import secrets
from pathlib import Path
from typing import Optional
from flask import current_app, jsonify, request, session
from config import DATA_DIR, TERMINAL_SANDBOX_MODE
from config.terminal import LINUX_SAFETY
from modules.host_workspace_manager import resolve_host_workspace
from modules.i18n import tr
from server.auth_helpers import is_logged_in
_LOOPBACK_ADDRS = {"127.0.0.1", "::1", "localhost"}
_TOKEN_FILENAME = "host_api_token"
def _host_channel_enabled() -> bool:
return (TERMINAL_SANDBOX_MODE or "").lower() == "host" and not LINUX_SAFETY
def host_api_token_path() -> Path:
return Path(DATA_DIR).expanduser() / _TOKEN_FILENAME
def get_or_create_host_api_token() -> Optional[str]:
"""读取不存在则生成host 通道 token非 host 模式返回 None。
生成使用临时文件 + os.replace 原子替换 + 0600 权限。本地单进程
场景下并发首次生成概率极低;万一竞争产生两个 token后写覆盖先写
持旧 token 的一方 401 后重读文件即可自愈。
"""
if not _host_channel_enabled():
return None
path = host_api_token_path()
try:
existing = path.read_text(encoding="utf-8").strip()
if existing:
return existing
except OSError:
pass
token = secrets.token_urlsafe(32)
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(token + "\n", encoding="utf-8")
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except OSError:
return None
return token
def _extract_bearer_token() -> str:
auth_header = request.headers.get("Authorization") or ""
if not auth_header.lower().startswith("bearer "):
return ""
return auth_header.split(" ", 1)[1].strip()
def _verify_host_bearer(token: str) -> bool:
"""校验 host Bearer tokenhost 模式 + 回环地址 + token 匹配。"""
if not token or not _host_channel_enabled():
return False
remote_addr = (request.remote_addr or "").strip()
if remote_addr not in _LOOPBACK_ADDRS:
return False
expected = get_or_create_host_api_token()
if not expected:
return False
return hmac.compare_digest(token, expected)
def _inject_host_identity() -> None:
"""注入与 /host-login 等价的 host 会话身份(复用现有上下文装配逻辑)。
session 字段与 server/auth.py 的 host_login 保持一致(含 login_nonce
保证路由内及下游 is_logged_in()/principal 构造等既有逻辑无差异。
"""
from server.auth import _issue_login_nonce
_, host_workspace = resolve_host_workspace()
session["logged_in"] = True
session["username"] = "host"
session["role"] = "admin"
session["host_mode"] = True
if host_workspace:
workspace_id = host_workspace.get("workspace_id") or "default"
session["host_workspace_id"] = workspace_id
session["workspace_id"] = workspace_id
default_thinking = current_app.config.get("DEFAULT_THINKING_MODE", False)
session["thinking_mode"] = default_thinking
session["run_mode"] = current_app.config.get(
"DEFAULT_RUN_MODE", "deep" if default_thinking else "fast"
)
session.permanent = True
_issue_login_nonce("host")
def api_login_or_host_token_required(view_func):
"""双通道认证装饰器host Bearer token 或 Web session 登录。
- 携带 Bearer token 且匹配 host 通道 → 注入 host 身份并放行
- 否则回退 Web session 检查is_logged_in
Bearer 请求天然跳过 CSRFserver/security.py 已放行 Authorization 头)。
"""
@functools.wraps(view_func)
def wrapped(*args, **kwargs):
token = _extract_bearer_token()
if token and _verify_host_bearer(token):
_inject_host_identity()
return view_func(*args, **kwargs)
if not is_logged_in():
return jsonify({"success": False, "error": tr("auth.session_expired")}), 401
return view_func(*args, **kwargs)
return wrapped
__all__ = [
"api_login_or_host_token_required",
"get_or_create_host_api_token",
"host_api_token_path",
]