security: 公网生产环境加固 — P0/P1 全部修复
API 认证 (16 个未保护端点 → 全部加 panel_auth): - 文件管理器: 11 端点 (list/mkdir/delete/upload/download/read/write/...) - 项目管理: 6 端点 (list/run/stop/logs/stdin/page), 修复硬编码路径前缀 - 代理管理: 3 端点 (list/add/remove) - 系统状态: 2 HTTP + 1 WS (token 校验) - 插件页面: 3 端点 (page/sse/event) - 内部路由: 3 端点 (plugins/commands/data) 认证系统加固: - 密码哈希: 固定盐 → 每用户独立 secrets.token_hex(16) 随机盐 - 默认密码警告: 启动时检测并打印 critical 级别日志 - 登录频率限制: 5 次失败 / IP → 锁定 60 秒, 返回 429 基础设施: - 安全响应头: X-Content-Type-Options/X-Frame-Options/X-XSS-Protection/Referrer-Policy - WebSocket 鉴权: query string token 校验 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import secrets
|
||||
import time
|
||||
import logging
|
||||
from aiohttp import web
|
||||
from ..utils.auth import panel_auth
|
||||
@@ -9,9 +10,41 @@ from ..utils.auth import panel_auth
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 全局 Session 存储 (内存型)
|
||||
# 格式: { "token_string": { "username": "...", "perms": [...] } }
|
||||
PANEL_SESSION_STORE = {}
|
||||
|
||||
# 登录频率限制 — {ip: [fail_count, lock_until_timestamp]}
|
||||
_LOGIN_FAILS: dict[str, list] = {}
|
||||
_MAX_FAILS = 5
|
||||
_LOCK_SECONDS = 60
|
||||
|
||||
|
||||
def _check_rate_limit(ip: str) -> bool:
|
||||
"""检查 IP 是否被限流。返回 True = 允许尝试"""
|
||||
now = time.time()
|
||||
entry = _LOGIN_FAILS.get(ip)
|
||||
if entry:
|
||||
fail_count, lock_until = entry
|
||||
if now < lock_until:
|
||||
return False # still locked
|
||||
if now >= lock_until + _LOCK_SECONDS:
|
||||
_LOGIN_FAILS.pop(ip, None) # expired, reset
|
||||
return True
|
||||
|
||||
|
||||
def _record_fail(ip: str):
|
||||
now = time.time()
|
||||
entry = _LOGIN_FAILS.get(ip, [0, 0])
|
||||
entry[0] += 1
|
||||
if entry[0] >= _MAX_FAILS:
|
||||
entry[1] = now + _LOCK_SECONDS
|
||||
logger.warning(f"🔒 IP {ip} 登录锁定 {_LOCK_SECONDS}s ({_MAX_FAILS} 次失败)")
|
||||
_LOGIN_FAILS[ip] = entry
|
||||
|
||||
|
||||
def _clear_fails(ip: str):
|
||||
_LOGIN_FAILS.pop(ip, None)
|
||||
|
||||
|
||||
def setup_routes(app, prefix=''):
|
||||
"""注册面板认证路由"""
|
||||
# 🟢 关键:将 Session Store 挂载到 app,供拦截器读取
|
||||
@@ -26,16 +59,25 @@ def setup_routes(app, prefix=''):
|
||||
async def handle_login(req):
|
||||
"""处理面板登录"""
|
||||
try:
|
||||
# 频率限制
|
||||
ip = req.remote
|
||||
if not _check_rate_limit(ip):
|
||||
return web.json_response(
|
||||
{"success": False, "msg": "尝试次数过多,请 60 秒后重试"},
|
||||
status=429,
|
||||
)
|
||||
|
||||
data = await req.json()
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
|
||||
cfg = req.app.get('panel_config', {})
|
||||
cfg_user = cfg.get('username', 'admin')
|
||||
cfg_pass = cfg.get('password', 'admin')
|
||||
|
||||
|
||||
# 校验配置中的账号密码
|
||||
if username == cfg_user and password == cfg_pass:
|
||||
_clear_fails(ip)
|
||||
# 登录成功:生成 Token
|
||||
token = secrets.token_hex(16)
|
||||
|
||||
@@ -54,7 +96,8 @@ async def handle_login(req):
|
||||
resp.set_cookie("panel_token", token, max_age=259200, httponly=True, samesite="Lax")
|
||||
return resp
|
||||
else:
|
||||
logger.warning(f"❌ 面板登录失败: 用户 {username} 密码错误")
|
||||
_record_fail(ip)
|
||||
logger.warning(f"❌ 面板登录失败: 用户 {username} 密码错误 (IP: {ip})")
|
||||
return web.json_response({"success": False, "msg": "用户名或密码错误"}, status=401)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user