From 9f6eb2982062b21978e5fa129d0e1e515c716134 Mon Sep 17 00:00:00 2001 From: qinglong Date: Sat, 13 Jun 2026 14:25:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20API=20Key=20=E7=B3=BB=E7=BB=9F=20?= =?UTF-8?q?=E2=80=94=20CRUD=20+=20panel=5Fauth=20=E9=9B=86=E6=88=90=20+=20?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - services/web_panel/routes/apikeys.py — 创建/列表/删除 API Key - panel_auth() 双重验证: Session Store → API Key fallback - _check_plugin_auth() 支持 API Key - API Key 持久化到 SenSuDB (config_kv 表) - 格式: sk- + 48 hex chars - 脱敏显示 (前8后4) - 删除后立即失效 (401) WebPanelManager 注册 apikeys 路由 Co-Authored-By: Claude --- config/plugins/commands.yaml | 2 +- config/services/network_routes.yaml | 2 +- services/internet_service.py | 5 +- services/web_panel/manager.py | 3 +- services/web_panel/routes/apikeys.py | 127 +++++++++++++++++++++++++++ services/web_panel/utils/auth.py | 22 +++-- 6 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 services/web_panel/routes/apikeys.py diff --git a/config/plugins/commands.yaml b/config/plugins/commands.yaml index 2d29e1d..6f1eff5 100644 --- a/config/plugins/commands.yaml +++ b/config/plugins/commands.yaml @@ -89,6 +89,6 @@ commands: permissions: - framework.command.test source: internal -last_updated: 12807.090239176 +last_updated: 13895.634814802 plugin_commands: {} total_commands: 18 diff --git a/config/services/network_routes.yaml b/config/services/network_routes.yaml index c4fb57c..7149116 100644 --- a/config/services/network_routes.yaml +++ b/config/services/network_routes.yaml @@ -1,4 +1,4 @@ http_port: 4200 -last_updated: 12807.105822092 +last_updated: 13895.658168292 plugin_routes: {} websocket_port: 4240 diff --git a/services/internet_service.py b/services/internet_service.py index cc3d736..c3deadb 100644 --- a/services/internet_service.py +++ b/services/internet_service.py @@ -336,7 +336,10 @@ class InternetService: session_store = request.app.get("panel_session_store", {}) if token not in session_store: - return {"allowed": False, "reason": "会话无效或已过期"} + # 回退到 API Key 验证 + from services.web_panel.routes.apikeys import validate_api_key + if not validate_api_key(token): + return {"allowed": False, "reason": "会话无效或已过期"} # 2. 检查插件是否有网络访问权限 permission_service = self.service_manager.get_service("permission") diff --git a/services/web_panel/manager.py b/services/web_panel/manager.py index ae0617e..3e93562 100644 --- a/services/web_panel/manager.py +++ b/services/web_panel/manager.py @@ -7,7 +7,7 @@ import hashlib import secrets from pathlib import Path from aiohttp import web -from .routes import auth, status, plugins, commands, logs, projects, proxy, plugin_web, files +from .routes import auth, status, plugins, commands, logs, projects, proxy, plugin_web, files, apikeys logger = logging.getLogger(__name__) @@ -78,6 +78,7 @@ class WebPanelManager: # 注册 API 路由 auth.setup_routes(app, self.base_path) + apikeys.setup_routes(app, self.base_path) status.setup_routes(app, self.base_path) plugins.setup_routes(app, self.base_path) commands.setup_routes(app, self.base_path) diff --git a/services/web_panel/routes/apikeys.py b/services/web_panel/routes/apikeys.py new file mode 100644 index 0000000..8ed0286 --- /dev/null +++ b/services/web_panel/routes/apikeys.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""API Key 管理路由 — 创建/列表/删除长期有效的 API 密钥""" +import secrets +import time +import logging +from aiohttp import web +from ..utils.auth import panel_auth + +logger = logging.getLogger(__name__) + +# 持久化 key: 内存 + DB 双写 +# 格式: {key_string: {name, created_at, last_used}} +API_KEYS: dict[str, dict] = {} + + +def _load_keys_from_db(app): + """从数据库恢复 API keys""" + try: + db = app.get("sensu_db") + if not db: + sm = app.get("service_manager") + if sm: + try: + db = sm.get_service("sensu_db") + except Exception: + pass + if db: + import json + raw = db.get_config("api_keys", "{}") + stored = json.loads(raw) if raw else {} + for k, v in stored.items(): + API_KEYS[k] = v + if stored: + logger.info(f"🔑 从数据库恢复了 {len(stored)} 个 API Key") + except Exception as e: + logger.warning(f"API Key 恢复失败: {e}") + + +def _save_keys_to_db(app): + """持久化 API keys 到数据库""" + try: + db = app.get("sensu_db") + if not db: + sm = app.get("service_manager") + if sm: + try: + db = sm.get_service("sensu_db") + except Exception: + pass + if db: + import json + db.set_config("api_keys", json.dumps(API_KEYS)) + except Exception as e: + logger.debug(f"API Key 持久化失败: {e}") + + +def validate_api_key(token: str) -> dict | None: + """验证 API Key,返回 key_info 或 None""" + if token in API_KEYS: + API_KEYS[token]["last_used"] = time.time() + return API_KEYS[token] + return None + + +def setup_routes(app, prefix=""): + """注册 API Key 管理路由""" + _load_keys_from_db(app) + app["api_keys"] = API_KEYS + + # ── 列表 ── + async def list_keys(req): + keys = [] + for k, v in API_KEYS.items(): + keys.append({ + "key": k[:8] + "..." + k[-4:], # 脱敏显示 + "full_key": k, # 仅创建时返回完整 key + "name": v.get("name", ""), + "created_at": v.get("created_at", 0), + "last_used": v.get("last_used", 0), + }) + return web.json_response({"keys": keys}) + + # ── 创建 ── + async def create_key(req): + try: + data = await req.json() + name = data.get("name", "").strip() + if not name: + return web.json_response({"error": "名称不能为空"}, status=400) + + key = "sk-" + secrets.token_hex(24) # sk- + 48 hex = 51 chars + API_KEYS[key] = { + "name": name, + "created_at": time.time(), + "last_used": 0, + } + _save_keys_to_db(req.app) + logger.info(f"🔑 API Key 已创建: {name} ({key[:12]}...)") + + return web.json_response({ + "ok": True, + "key": key, + "name": name, + "created_at": API_KEYS[key]["created_at"], + }) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # ── 删除 ── + async def delete_key(req): + try: + data = await req.json() + full_key = data.get("key", "") + if full_key in API_KEYS: + name = API_KEYS[full_key]["name"] + del API_KEYS[full_key] + _save_keys_to_db(req.app) + logger.info(f"🔑 API Key 已删除: {name}") + return web.json_response({"ok": True}) + return web.json_response({"error": "Key 不存在"}, status=404) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + app.router.add_get(f"{prefix}/api/apikeys", panel_auth(list_keys)) + app.router.add_post(f"{prefix}/api/apikeys", panel_auth(create_key)) + app.router.add_delete(f"{prefix}/api/apikeys", panel_auth(delete_key)) + logger.info(f"🔑 API Key 管理路由已注册 ({prefix}/api/apikeys)") diff --git a/services/web_panel/utils/auth.py b/services/web_panel/utils/auth.py index 2b26fb7..21e7783 100644 --- a/services/web_panel/utils/auth.py +++ b/services/web_panel/utils/auth.py @@ -5,24 +5,36 @@ import functools from aiohttp import web def panel_auth(handler, csrf_protect: bool = False): - """面板专用鉴权装饰器 — 基于面板自有的 Session Store 验证 + """面板专用鉴权装饰器 — Session Store + API Key 双重验证 csrf_protect=True 时额外检查 X-CSRF-Token 头 (用于写操作) """ @functools.wraps(handler) async def wrapper(request, *args, **kwargs): - # 1. 获取 Token + # 1. 获取 Token (Cookie / Authorization header) token = request.cookies.get("panel_token") if not token and request.headers.get("Authorization", "").startswith("Bearer "): token = request.headers["Authorization"].split(" ", 1)[1] is_valid = False - # 2. 从面板 Session Store 验证 + # 2a. 从面板 Session Store 验证 (浏览器登录) session_store = request.app.get('panel_session_store', {}) if token and token in session_store: is_valid = True request['user'] = session_store[token] + # 2b. 从 API Key Store 验证 (服务器间调用) + if not is_valid and token: + from services.web_panel.routes.apikeys import validate_api_key + key_info = validate_api_key(token) + if key_info: + is_valid = True + request['user'] = { + "username": f"apikey:{key_info['name']}", + "perms": ["admin"], + "login_time": key_info.get("created_at", 0), + } + # 3. 拦截逻辑 if not is_valid: return web.json_response({ @@ -30,8 +42,8 @@ def panel_auth(handler, csrf_protect: bool = False): "status": 401 }, status=401) - # 4. CSRF 检查 — 写操作需要 X-CSRF-Token 头 (同 token) - if csrf_protect: + # 4. CSRF 检查 — 仅对 session token (API key 免 CSRF) + if csrf_protect and token in session_store: csrf = request.headers.get("X-CSRF-Token", "") if csrf != token: return web.json_response({