feat: API Key 系统 — CRUD + panel_auth 集成 + 持久化

新增:
- 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 <noreply@anthropic.com>
This commit is contained in:
qinglong
2026-06-13 14:25:55 +08:00
parent e88d472c63
commit 9f6eb29820
6 changed files with 152 additions and 9 deletions
+1 -1
View File
@@ -89,6 +89,6 @@ commands:
permissions: permissions:
- framework.command.test - framework.command.test
source: internal source: internal
last_updated: 12807.090239176 last_updated: 13895.634814802
plugin_commands: {} plugin_commands: {}
total_commands: 18 total_commands: 18
+1 -1
View File
@@ -1,4 +1,4 @@
http_port: 4200 http_port: 4200
last_updated: 12807.105822092 last_updated: 13895.658168292
plugin_routes: {} plugin_routes: {}
websocket_port: 4240 websocket_port: 4240
+4 -1
View File
@@ -336,7 +336,10 @@ class InternetService:
session_store = request.app.get("panel_session_store", {}) session_store = request.app.get("panel_session_store", {})
if token not in 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. 检查插件是否有网络访问权限 # 2. 检查插件是否有网络访问权限
permission_service = self.service_manager.get_service("permission") permission_service = self.service_manager.get_service("permission")
+2 -1
View File
@@ -7,7 +7,7 @@ import hashlib
import secrets import secrets
from pathlib import Path from pathlib import Path
from aiohttp import web 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__) logger = logging.getLogger(__name__)
@@ -78,6 +78,7 @@ class WebPanelManager:
# 注册 API 路由 # 注册 API 路由
auth.setup_routes(app, self.base_path) auth.setup_routes(app, self.base_path)
apikeys.setup_routes(app, self.base_path)
status.setup_routes(app, self.base_path) status.setup_routes(app, self.base_path)
plugins.setup_routes(app, self.base_path) plugins.setup_routes(app, self.base_path)
commands.setup_routes(app, self.base_path) commands.setup_routes(app, self.base_path)
+127
View File
@@ -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)")
+17 -5
View File
@@ -5,24 +5,36 @@ import functools
from aiohttp import web from aiohttp import web
def panel_auth(handler, csrf_protect: bool = False): def panel_auth(handler, csrf_protect: bool = False):
"""面板专用鉴权装饰器 — 基于面板自有的 Session Store 验证 """面板专用鉴权装饰器 — Session Store + API Key 双重验证
csrf_protect=True 时额外检查 X-CSRF-Token 头 (用于写操作) csrf_protect=True 时额外检查 X-CSRF-Token 头 (用于写操作)
""" """
@functools.wraps(handler) @functools.wraps(handler)
async def wrapper(request, *args, **kwargs): async def wrapper(request, *args, **kwargs):
# 1. 获取 Token # 1. 获取 Token (Cookie / Authorization header)
token = request.cookies.get("panel_token") token = request.cookies.get("panel_token")
if not token and request.headers.get("Authorization", "").startswith("Bearer "): if not token and request.headers.get("Authorization", "").startswith("Bearer "):
token = request.headers["Authorization"].split(" ", 1)[1] token = request.headers["Authorization"].split(" ", 1)[1]
is_valid = False is_valid = False
# 2. 从面板 Session Store 验证 # 2a. 从面板 Session Store 验证 (浏览器登录)
session_store = request.app.get('panel_session_store', {}) session_store = request.app.get('panel_session_store', {})
if token and token in session_store: if token and token in session_store:
is_valid = True is_valid = True
request['user'] = session_store[token] 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. 拦截逻辑 # 3. 拦截逻辑
if not is_valid: if not is_valid:
return web.json_response({ return web.json_response({
@@ -30,8 +42,8 @@ def panel_auth(handler, csrf_protect: bool = False):
"status": 401 "status": 401
}, status=401) }, status=401)
# 4. CSRF 检查 — 写操作需要 X-CSRF-Token 头 (同 token) # 4. CSRF 检查 — 仅对 session token (API key 免 CSRF)
if csrf_protect: if csrf_protect and token in session_store:
csrf = request.headers.get("X-CSRF-Token", "") csrf = request.headers.get("X-CSRF-Token", "")
if csrf != token: if csrf != token:
return web.json_response({ return web.json_response({