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:
@@ -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,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
|
||||||
|
|||||||
@@ -336,6 +336,9 @@ 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:
|
||||||
|
# 回退到 API Key 验证
|
||||||
|
from services.web_panel.routes.apikeys import validate_api_key
|
||||||
|
if not validate_api_key(token):
|
||||||
return {"allowed": False, "reason": "会话无效或已过期"}
|
return {"allowed": False, "reason": "会话无效或已过期"}
|
||||||
|
|
||||||
# 2. 检查插件是否有网络访问权限
|
# 2. 检查插件是否有网络访问权限
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)")
|
||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user