agent-Specialization/modules/api_user_manager.py
JOJO c724451d94 fix(security): Docker 多用户模式全面安全加固(安全审计修复)
威胁模型:拥有普通账号的已登录用户攻击服务器。完整审计报告见
_experiments/security_audit_2026-09-02/(不入库),含实测复现记录。

严重/高危修复:
- 彻底移除文件夹打包下载(/api/download/folder 端点删除,gui/api_v1
  目录下载分支改 410):文件型符号链接会被宿主进程解析,实测可读宿主
  任意文件(含 settings.json 全部 LLM 密钥),删除比补校验更彻底
- XFF 伪造防护:get_client_ip 仅信任 ASTRION_TRUSTED_PROXIES
  (默认空=不信 XFF);登录新增账号维度锁定(5 次失败锁 300s);
  注册邀请码加爆破锁定;限流桶/失败表加 2 万键上限+GC 回收
- auth_debug.log 接入大小轮转;/api/client_debug_log 加限流+长度截断
- /host-login 增加 LINUX_SAFETY 检查且仅回环地址可用

容器加固:
- 默认 cpus=1 / memory=1g,新增 pids-limit=512、memory-swap、
  no-new-privileges
- 新增每用户容器配额 MAX_ACTIVE_CONTAINERS_PER_USER(默认 3),
  防止单用户占满全局容器池

api_v1:
- prompts/personalizations 的 name 加白名单校验(^[A-Za-z0-9_-]{1,64}$),
  修复路径穿越写入;对话元数据中的引用名同步加白名单
- workspaces/conversations/messages/upload 四端点加用户维度限流
- 消息体加 MAX_MESSAGE_CHARS 上限(默认 200000,/api/tasks 同步)
- _path_within 加分隔符边界(修复 startswith 前缀碰撞)

其他:
- SVG 预览强制 application/octet-stream(修存储型 XSS 漏网)
- monitor_snapshot 缓存键加 username 维度(修跨用户快照读取)
- ensure/delete_workspace 加 workspace_id 白名单+父目录二次核验
- delete_folder 拒绝删除工作区根(crud_mixin 与容器代理同步修)
- GuiFileManager 死代码加名称校验防复活
- admin_dashboard 静态壳非 admin 访问一律 404
- /api/app/apk/latest 加登录校验+限流(原未认证可拉 133MB)

有意未修(见报告 7.3 遗留清单):容器 egress 过滤(部署层)、
--cap-drop ALL(怕破坏容器内工作流,已先上 no-new-privileges)、
API 用户/子智能体 LLM 按 token 计费(待产品决策)、str(exc) 收口、
WebSocket 限流、network_permission=restricted 容器语义。

验证:全部文件 py_compile 通过;冒烟测试 6/6 通过;关键新逻辑
(名称校验/路径边界/容器代理删除防护/限流回收)已单测级验证;
端点级行为待服务重启后实测复核(清单见报告 7.5)。

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

428 lines
17 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.

"""API 专用用户与工作区管理JSON + Bearer Token 哈希)。
支持 API 用户的创建/删除、Token 持久化(哈希 + 加密回显)与基础用量计数。
"""
from __future__ import annotations
import json
import hashlib
import threading
import secrets
import base64
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional, Tuple, Any
from config import (
API_USER_SPACE_DIR,
API_USERS_DB_FILE,
API_TOKENS_FILE,
API_USAGE_FILE,
API_TOKEN_SECRET,
)
from modules.personalization_manager import ensure_personalization_config
try:
from cryptography.fernet import Fernet, InvalidToken # type: ignore
except Exception: # pragma: no cover - 环境缺失时给予友好提示
Fernet = None
InvalidToken = Exception # type: ignore
from modules.i18n import tr
@dataclass
class ApiUserRecord:
username: str
token_sha256: str
created_at: str
note: str = ""
@dataclass
class ApiUserWorkspace:
"""API 用户的单个工作区描述。"""
username: str
workspace_id: str
root: Path
project_path: Path
data_dir: Path # 会话/备份等落盘到这里(每个工作区独立)
logs_dir: Path
uploads_dir: Path # project/.astrion/user_upload
quarantine_dir: Path # 上传隔离区(按用户/工作区划分)
shared_dir: Path # 用户级共享目录prompts/personalization
prompts_dir: Path # 实际使用的 prompts 目录(指向 shared_dir/prompt
personalization_dir: Path # 实际使用的 personalization 目录(指向 shared_dir/personalization
class ApiUserManager:
"""最小化的 API 用户管理:只校验 token 哈希并准备隔离工作区。"""
def __init__(
self,
users_file: str = API_USERS_DB_FILE,
tokens_file: str = API_TOKENS_FILE,
workspace_root: str = API_USER_SPACE_DIR,
usage_file: str = API_USAGE_FILE,
):
self.users_file = Path(users_file)
self.tokens_file = Path(tokens_file)
self.usage_file = Path(usage_file)
self.workspace_root = Path(workspace_root).expanduser().resolve()
self.workspace_root.mkdir(parents=True, exist_ok=True)
self._users: Dict[str, ApiUserRecord] = {}
self._tokens: Dict[str, Dict[str, Any]] = {}
self._usage: Dict[str, Dict[str, Any]] = {}
self._lock = threading.Lock()
self._load_users()
self._load_tokens()
self._load_usage()
# ----------------------- public APIs -----------------------
def list_users(self) -> Dict[str, ApiUserRecord]:
with self._lock:
return dict(self._users)
def get_user_by_token(self, bearer_token: str) -> Optional[ApiUserRecord]:
if not bearer_token:
return None
token_sha = self._sha256(bearer_token)
with self._lock:
for user in self._users.values():
if user.token_sha256 == token_sha:
return user
return None
def ensure_workspace(self, username: str, workspace_id: str = "default") -> ApiUserWorkspace:
"""为 API 用户创建/获取指定工作区。
目录布局(每个用户):
<root>/<username>/
shared/ # 用户级共享prompts/personalization
prompts/
personalization/
workspaces/<ws>/ # 单个工作区
project/
.astrion/user_upload/
data/
conversations/
backups/
logs/
"""
username = username.strip().lower()
ws_id = (workspace_id or "default").strip()
if not ws_id:
ws_id = "default"
# 安全workspace_id 参与路径拼接,必须严格白名单(防 `..`/斜杠穿越)
import re as _re
if not _re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,39}", ws_id) or ".." in ws_id:
raise ValueError(tr("api_user_mgr.invalid_workspace_id"))
user_root = (self.workspace_root / username).resolve()
shared_dir = user_root / "shared"
prompts_dir = shared_dir / "prompts"
personalization_dir = shared_dir / "personalization"
work_root = user_root / "workspaces" / ws_id
project_path = work_root / "project"
data_dir = work_root / "data"
logs_dir = work_root / "logs"
uploads_dir = project_path / ".astrion" / "user_upload"
skills_dir = project_path / ".astrion" / "skills"
for path in (project_path, data_dir, logs_dir, uploads_dir, skills_dir, shared_dir, prompts_dir, personalization_dir):
path.mkdir(parents=True, exist_ok=True)
# 数据子目录(工作区级)
(data_dir / "conversations").mkdir(parents=True, exist_ok=True)
(data_dir / "backups").mkdir(parents=True, exist_ok=True)
# 用户级 personalization 主文件(共享)
ensure_personalization_config(personalization_dir)
# 为 prompts/personalization 创建便捷访问保持向后兼容data_dir 下可作为符号链接)
for name, target in (("prompts", prompts_dir), ("personalization", personalization_dir)):
link = data_dir / name
if not link.exists():
try:
link.symlink_to(target, target_is_directory=True)
except Exception:
# 某些环境禁用 symlink则忽略使用共享目录路径显式传递
pass
# 上传隔离区(按用户/工作区划分)
from config import UPLOAD_QUARANTINE_SUBDIR
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
if not quarantine_root.is_absolute():
quarantine_root = (self.workspace_root.parent / UPLOAD_QUARANTINE_SUBDIR).resolve()
quarantine_dir = (quarantine_root / username / ws_id).resolve()
quarantine_dir.mkdir(parents=True, exist_ok=True)
return ApiUserWorkspace(
username=username,
workspace_id=ws_id,
root=work_root,
project_path=project_path,
data_dir=data_dir,
logs_dir=logs_dir,
uploads_dir=uploads_dir,
quarantine_dir=quarantine_dir,
shared_dir=shared_dir,
prompts_dir=prompts_dir,
personalization_dir=personalization_dir,
)
def create_user(self, username: str, note: str = "") -> Tuple[ApiUserRecord, str]:
"""创建新的 API 用户并返回明文 Token。"""
username = self._normalize_username(username)
with self._lock:
if username in self._users:
raise ValueError(tr("api_user_mgr.user_exists"))
record, token = self._issue_token_locked(username, note=note)
self._save_users()
return record, token
def issue_token(self, username: str, note: str = "") -> Tuple[ApiUserRecord, str]:
"""为已有用户重新生成 token 并返回明文。"""
username = self._normalize_username(username)
with self._lock:
if username not in self._users:
raise ValueError(tr("api_user_mgr.user_not_found"))
record, token = self._issue_token_locked(username, note=note)
self._save_users()
return record, token
def delete_user(self, username: str) -> bool:
username = self._normalize_username(username)
with self._lock:
removed = self._users.pop(username, None) is not None
self._tokens.pop(username, None)
self._usage.pop(username, None)
self._save_users()
self._save_tokens()
self._save_usage()
# 尝试删除对应工作区目录(忽略失败)
try:
import shutil
user_root = (self.workspace_root / username).resolve()
if user_root.exists():
shutil.rmtree(user_root, ignore_errors=True)
except Exception:
pass
return removed
def get_plain_token(self, username: str) -> str:
username = self._normalize_username(username)
with self._lock:
token_entry = self._tokens.get(username) or {}
if not token_entry:
raise ValueError(tr("api_user_mgr.token_not_found"))
# 优先使用加密存储;若无密钥或解密失败,回退到明文字段(本地后台安全场景可接受)
enc = token_entry.get("token_enc")
if enc:
fernet = self._fernet()
if fernet:
try:
return fernet.decrypt(enc.encode("utf-8")).decode("utf-8")
except InvalidToken as exc: # type: ignore
# fall through to plaintext
pass
else:
# 没有密钥,继续尝试明文
pass
plain = token_entry.get("token_plain")
if plain:
return plain
raise RuntimeError(tr("api_user_mgr.missing_token_secret"))
def bump_usage(self, username: str, endpoint: Optional[str] = None):
"""记录 API 请求次数与最近时间,用于后台监控。"""
username = self._normalize_username(username)
now_iso = datetime.utcnow().isoformat() + "Z"
with self._lock:
entry = self._usage.setdefault(username, {"total": 0, "endpoints": {}, "last_request_at": None})
entry["total"] = int(entry.get("total", 0)) + 1
if endpoint:
endpoint_key = endpoint.split("?")[0]
endpoints = entry.setdefault("endpoints", {})
endpoints[endpoint_key] = int(endpoints.get(endpoint_key, 0)) + 1
entry["last_request_at"] = now_iso
self._save_usage()
def get_usage(self, username: str) -> Dict[str, Any]:
username = self._normalize_username(username)
with self._lock:
return dict(self._usage.get(username) or {})
def list_usage(self) -> Dict[str, Dict[str, Any]]:
with self._lock:
return {u: dict(meta) for u, meta in self._usage.items()}
def list_workspaces(self, username: str) -> Dict[str, Dict]:
"""列出用户的所有工作区信息。"""
username = username.strip().lower()
user_root = (self.workspace_root / username / "workspaces").resolve()
if not user_root.exists():
return {}
result = {}
for p in sorted(user_root.iterdir()):
if not p.is_dir():
continue
ws_id = p.name
data_dir = p / "data"
project_path = p / "project"
result[ws_id] = {
"workspace_id": ws_id,
# 不暴露宿主机绝对路径,只返回相对工作区的信息
"project_path": "project",
"data_dir": "data",
"has_conversations": (data_dir / "conversations").exists(),
}
return result
def delete_workspace(self, username: str, workspace_id: str) -> bool:
"""删除指定工作区(仅工作区目录,不删除共享 prompts/personalization"""
username = username.strip().lower()
ws_id = (workspace_id or "").strip()
if not ws_id:
return False
# 安全:同 ensure_workspace 的严格白名单校验,防路径穿越删除任意目录
import re as _re
if not _re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,39}", ws_id) or ".." in ws_id:
return False
work_root = (self.workspace_root / username / "workspaces" / ws_id).resolve()
# 二次防护:解析后的落点必须严格位于该用户的 workspaces 目录内
expected_parent = (self.workspace_root / username / "workspaces").resolve()
if work_root.parent != expected_parent:
return False
if not work_root.exists():
return False
import shutil
shutil.rmtree(work_root, ignore_errors=True)
return True
# ----------------------- internal helpers -----------------------
def _normalize_username(self, username: str) -> str:
candidate = (username or "").strip().lower()
if not candidate:
raise ValueError(tr("api_user_mgr.username_empty"))
return candidate
def _sha256(self, token: str) -> str:
return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
def _load_users(self):
"""加载用户列表,读取 token_sha256不支持明文存储。"""
if not self.users_file.exists():
self._save_users()
return
try:
raw = json.loads(self.users_file.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise RuntimeError(tr("api_user_mgr.users_file_parse_failed", file_path=self.users_file, error=exc))
users = raw.get("users", {}) if isinstance(raw, dict) else {}
for username, payload in users.items():
if not isinstance(payload, dict):
continue
token_sha = (payload.get("token_sha256") or "").strip()
if not token_sha:
continue
record = ApiUserRecord(
username=username.strip().lower(),
token_sha256=token_sha,
created_at=payload.get("created_at") or "",
note=payload.get("note") or "",
)
self._users[record.username] = record
def _save_users(self):
payload = {
"users": {
username: {
"token_sha256": record.token_sha256,
"created_at": record.created_at or datetime.utcnow().isoformat(),
"note": record.note,
}
for username, record in self._users.items()
}
}
self.users_file.parent.mkdir(parents=True, exist_ok=True)
self.users_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
# -------- Token 存储(加密回显) --------
def _fernet(self):
secret = (API_TOKEN_SECRET or "").strip()
if not secret or not Fernet:
return None
key = hashlib.sha256(secret.encode("utf-8")).digest()
fkey = base64.urlsafe_b64encode(key)
return Fernet(fkey)
def _load_tokens(self):
if not self.tokens_file.exists():
self._save_tokens()
return
try:
raw = json.loads(self.tokens_file.read_text(encoding="utf-8"))
tokens = raw.get("tokens", {}) if isinstance(raw, dict) else {}
self._tokens = tokens
except Exception:
self._tokens = {}
def _save_tokens(self):
payload = {"tokens": self._tokens}
self.tokens_file.parent.mkdir(parents=True, exist_ok=True)
self.tokens_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _store_token(self, username: str, token: str, note: str = ""):
fernet = self._fernet()
enc = fernet.encrypt(token.encode("utf-8")).decode("utf-8") if fernet else ""
self._tokens[username] = {
"token_enc": enc,
"token_plain": token, # 便于在缺少密钥时回退,受本地文件权限保护
"note": note,
"created_at": datetime.utcnow().isoformat(),
}
self._save_tokens()
def _issue_token_locked(self, username: str, note: str = "") -> Tuple[ApiUserRecord, str]:
token = secrets.token_urlsafe(32)
token_sha = self._sha256(token)
record = self._users.get(username)
if not record:
record = ApiUserRecord(
username=username,
token_sha256=token_sha,
created_at=datetime.utcnow().isoformat(),
note=note or "",
)
record.token_sha256 = token_sha
record.note = note or record.note
record.created_at = record.created_at or datetime.utcnow().isoformat()
self._users[username] = record
self._store_token(username, token, note=note)
return record, token
# -------- API 请求用量 --------
def _load_usage(self):
if not self.usage_file.exists():
self._save_usage()
return
try:
raw = json.loads(self.usage_file.read_text(encoding="utf-8"))
usage = raw.get("usage", {}) if isinstance(raw, dict) else {}
self._usage = usage
except Exception:
self._usage = {}
def _save_usage(self):
payload = {"usage": self._usage}
self.usage_file.parent.mkdir(parents=True, exist_ok=True)
self.usage_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
__all__ = ["ApiUserManager", "ApiUserRecord", "ApiUserWorkspace"]