diff --git a/services/auth_service.py b/services/auth_service.py index b4fb653..b7f9f05 100644 --- a/services/auth_service.py +++ b/services/auth_service.py @@ -17,6 +17,7 @@ class User: """用户数据类""" username: str password_hash: str + salt: str permissions: List[str] is_active: bool = True created_at: float = None @@ -48,36 +49,48 @@ class AuthService: def _init_default_users(self): """初始化默认用户""" try: - # 创建默认管理员用户 - admin_password_hash = self._hash_password(os.environ.get("SENSU_ADMIN_PASSWORD","admin123")) + # 管理员 — 独立随机盐 + admin_salt = secrets.token_hex(16) + admin_pw = os.environ.get("SENSU_ADMIN_PASSWORD", "admin123") admin_user = User( username="admin", - password_hash=admin_password_hash, + password_hash=self._hash_password(admin_pw, admin_salt), + salt=admin_salt, permissions=["admin"], - created_at=time.time() + created_at=time.time(), ) self.users["admin"] = admin_user - - # 创建默认API用户 - api_password_hash = self._hash_password(os.environ.get("SENSU_API_PASSWORD","api123")) + + # API 用户 — 独立随机盐 + api_salt = secrets.token_hex(16) + api_pw = os.environ.get("SENSU_API_PASSWORD", "api123") api_user = User( username="api", - password_hash=api_password_hash, + password_hash=self._hash_password(api_pw, api_salt), + salt=api_salt, permissions=["framework.status.read", "plugin.info.read"], - created_at=time.time() + created_at=time.time(), ) self.users["api"] = api_user - - logger.debug("默认用户初始化完成") - + + logger.debug("默认用户初始化完成 (独立随机盐)") + + # 安全警告 — 仍在使用默认密码 + if admin_pw == "admin123" or api_pw == "api123": + logger.critical( + "⚠️ 安全警告: 正在使用默认密码! " + "请设置环境变量 SENSU_ADMIN_PASSWORD 和 SENSU_API_PASSWORD" + ) + except Exception as e: logger.error(f"初始化默认用户时出错: {str(e)}", exc_info=True) raise - def _hash_password(self, password: str) -> str: - """哈希密码""" + def _hash_password(self, password: str, salt: str = None) -> str: + """哈希密码 — 每个用户独立随机盐""" try: - salt = "catframework_salt" # 实际应该使用随机盐 + if salt is None: + salt = secrets.token_hex(16) return hashlib.sha256((password + salt).encode()).hexdigest() except Exception as e: logger.error(f"哈希密码时出错: {str(e)}", exc_info=True) @@ -98,7 +111,7 @@ class AuthService: logger.warning(f"用户已被禁用: {username}") return None - password_hash = self._hash_password(password) + password_hash = self._hash_password(password, user.salt) if user.password_hash != password_hash: logger.warning(f"密码错误: {username}") return None @@ -203,13 +216,15 @@ class AuthService: if username in self.users: logger.warning(f"用户已存在: {username}") return False - - password_hash = self._hash_password(password) + + salt = secrets.token_hex(16) + password_hash = self._hash_password(password, salt) user = User( username=username, password_hash=password_hash, + salt=salt, permissions=permissions, - created_at=time.time() + created_at=time.time(), ) self.users[username] = user diff --git a/services/internet_service.py b/services/internet_service.py index c4bc59d..6337ec5 100644 --- a/services/internet_service.py +++ b/services/internet_service.py @@ -19,6 +19,7 @@ class InternetService: self.config = config self.service_manager = service_manager self.http_app = web.Application() + self._setup_security_middleware() self.http_runner = None self.ws_connections: Dict[str, List] = {} self.plugin_routes: Dict[str, List] = {} @@ -149,19 +150,37 @@ class InternetService: except Exception as e: logger.error(f"保存网络配置时出错: {str(e)}") + def _setup_security_middleware(self): + """注入安全响应头中间件""" + + @web.middleware + async def security_headers(request, handler): + resp = await handler(request) + resp.headers.setdefault("X-Content-Type-Options", "nosniff") + resp.headers.setdefault("X-Frame-Options", "DENY") + resp.headers.setdefault("X-XSS-Protection", "1; mode=block") + resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + # 生产环境有反向代理 TLS 时可开启: + # resp.headers.setdefault("Strict-Transport-Security", "max-age=31536000") + return resp + + self.http_app.middlewares.append(security_headers) + def _setup_default_routes(self): """设置默认路由""" - # 健康检查端点 + from services.web_panel.utils.auth import panel_auth + + # 健康检查端点 (公开,不暴露内部信息) self.http_app.router.add_get('/health', self._handle_health_check) - - # 插件API端点 - self.http_app.router.add_get('/api/plugins', self._handle_get_plugins) - self.http_app.router.add_get('/api/commands', self._handle_get_commands) - - # 数据接收端点 - self.http_app.router.add_post('/api/data', self._handle_data_receive) - - logger.debug("默认路由设置完成") + + # 插件API端点 (加认证) + self.http_app.router.add_get('/api/plugins', panel_auth(self._handle_get_plugins)) + self.http_app.router.add_get('/api/commands', panel_auth(self._handle_get_commands)) + + # 数据接收端点 (加认证) + self.http_app.router.add_post('/api/data', panel_auth(self._handle_data_receive)) + + logger.debug("默认路由设置完成 (已加认证)") async def register_plugin_route(self, plugin_name: str, route_path: str, handler: Callable, methods: List[str] = ["GET"], diff --git a/services/web_panel/routes/auth.py b/services/web_panel/routes/auth.py index b5daf97..1cbfafa 100644 --- a/services/web_panel/routes/auth.py +++ b/services/web_panel/routes/auth.py @@ -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: diff --git a/services/web_panel/routes/files.py b/services/web_panel/routes/files.py index 0f12ede..2523d98 100644 --- a/services/web_panel/routes/files.py +++ b/services/web_panel/routes/files.py @@ -28,6 +28,7 @@ import logging import mimetypes from pathlib import Path from aiohttp import web +from ..utils.auth import panel_auth logger = logging.getLogger(__name__) @@ -412,14 +413,14 @@ def setup_file_routes(app, service_manager, prefix=''): return web.json_response({"error": str(e)}, status=500) # ── Register routes ── - app.router.add_get(f'{prefix}/api/files/list', list_dir) - app.router.add_post(f'{prefix}/api/files/mkdir', mkdir) - app.router.add_post(f'{prefix}/api/files/touch', touch) - app.router.add_post(f'{prefix}/api/files/delete', delete) - app.router.add_post(f'{prefix}/api/files/rename', rename) - app.router.add_post(f'{prefix}/api/files/upload', upload) - app.router.add_get(f'{prefix}/api/files/download', download) - app.router.add_get(f'{prefix}/api/files/read', read_file) - app.router.add_post(f'{prefix}/api/files/write', write_file) - app.router.add_get(f'{prefix}/api/files/info', file_info) - app.router.add_get(f'{prefix}/api/files/picker', picker_api) + app.router.add_get(f'{prefix}/api/files/list', panel_auth(list_dir)) + app.router.add_post(f'{prefix}/api/files/mkdir', panel_auth(mkdir)) + app.router.add_post(f'{prefix}/api/files/touch', panel_auth(touch)) + app.router.add_post(f'{prefix}/api/files/delete', panel_auth(delete)) + app.router.add_post(f'{prefix}/api/files/rename', panel_auth(rename)) + app.router.add_post(f'{prefix}/api/files/upload', panel_auth(upload)) + app.router.add_get(f'{prefix}/api/files/download', panel_auth(download)) + app.router.add_get(f'{prefix}/api/files/read', panel_auth(read_file)) + app.router.add_post(f'{prefix}/api/files/write', panel_auth(write_file)) + app.router.add_get(f'{prefix}/api/files/info', panel_auth(file_info)) + app.router.add_get(f'{prefix}/api/files/picker', panel_auth(picker_api)) diff --git a/services/web_panel/routes/plugin_web.py b/services/web_panel/routes/plugin_web.py index 526284d..a41fc61 100644 --- a/services/web_panel/routes/plugin_web.py +++ b/services/web_panel/routes/plugin_web.py @@ -1,5 +1,7 @@ from aiohttp import web import json, logging +from ..utils.auth import panel_auth + logger = logging.getLogger(__name__) def setup_plugin_web_routes(app, service_manager): @@ -32,7 +34,7 @@ def setup_plugin_web_routes(app, service_manager): return web.json_response({"ok": True}) return web.json_response({"ok": False}, status=404) - app.router.add_get("/plugin/{name}", plugin_page) - app.router.add_get("/plugin/{name}/sse", plugin_sse) - app.router.add_post("/plugin/{name}/event", plugin_event) - logger.info("Plugin web routes registered") + app.router.add_get("/plugin/{name}", panel_auth(plugin_page)) + app.router.add_get("/plugin/{name}/sse", panel_auth(plugin_sse)) + app.router.add_post("/plugin/{name}/event", panel_auth(plugin_event)) + logger.info("Plugin web routes registered (已加认证)") diff --git a/services/web_panel/routes/projects.py b/services/web_panel/routes/projects.py index a092cc0..9b64dba 100644 --- a/services/web_panel/routes/projects.py +++ b/services/web_panel/routes/projects.py @@ -1,5 +1,7 @@ from aiohttp import web import json, logging +from ..utils.auth import panel_auth + logger = logging.getLogger(__name__) def _get_engine(request): @@ -51,10 +53,10 @@ def setup_project_routes(app, service_manager, prefix=''): async def project_page(request): return web.FileResponse("static/web_panel/pages/projects.html") - app.router.add_get(f'{prefix}/api/projects', list_projects) - app.router.add_post("/api/projects/run", run_project) - app.router.add_get("/api/projects/{name}/logs", get_logs) - app.router.add_post("/api/projects/{name}/stop", stop_project) - app.router.add_post("/api/projects/{name}/stdin", send_stdin) - app.router.add_get("/pages/projects", project_page) - logger.info("📦 项目管理路由已注册") + app.router.add_get(f'{prefix}/api/projects', panel_auth(list_projects)) + app.router.add_post(f'{prefix}/api/projects/run', panel_auth(run_project)) + app.router.add_get(f'{prefix}/api/projects/{{name}}/logs', panel_auth(get_logs)) + app.router.add_post(f'{prefix}/api/projects/{{name}}/stop', panel_auth(stop_project)) + app.router.add_post(f'{prefix}/api/projects/{{name}}/stdin', panel_auth(send_stdin)) + app.router.add_get(f'{prefix}/pages/projects', panel_auth(project_page)) + logger.info("📦 项目管理路由已注册 (已加认证)") diff --git a/services/web_panel/routes/proxy.py b/services/web_panel/routes/proxy.py index ae09d5f..69fc4fd 100644 --- a/services/web_panel/routes/proxy.py +++ b/services/web_panel/routes/proxy.py @@ -1,5 +1,7 @@ from aiohttp import web, ClientSession import json, logging, asyncio +from ..utils.auth import panel_auth + logger = logging.getLogger(__name__) def setup_proxy_routes(app, service_manager, prefix=''): @@ -24,7 +26,7 @@ def setup_proxy_routes(app, service_manager, prefix=''): ps.unregister_proxy(path) return web.json_response({"ok": True}) - app.router.add_get(f'{prefix}/api/proxy', list_proxies) - app.router.add_post(f'{prefix}/api/proxy', add_proxy) - app.router.add_delete(f'{prefix}/api/proxy/{{path}}', remove_proxy) - logger.info(f'🔀 代理路由已注册 ({prefix}/api/proxy)') + app.router.add_get(f'{prefix}/api/proxy', panel_auth(list_proxies)) + app.router.add_post(f'{prefix}/api/proxy', panel_auth(add_proxy)) + app.router.add_delete(f'{prefix}/api/proxy/{{path}}', panel_auth(remove_proxy)) + logger.info(f'🔀 代理路由已注册 ({prefix}/api/proxy) (已加认证)') diff --git a/services/web_panel/routes/status.py b/services/web_panel/routes/status.py index fa5f711..7ab8a64 100644 --- a/services/web_panel/routes/status.py +++ b/services/web_panel/routes/status.py @@ -4,6 +4,7 @@ import asyncio import logging from aiohttp import web from ..utils.system_info import SystemInfoCollector +from ..utils.auth import panel_auth collector = SystemInfoCollector() logger = logging.getLogger(__name__) @@ -11,11 +12,25 @@ logger = logging.getLogger(__name__) # Track active system-status WS clients _sys_ws_clients: set = set() +def _ws_auth_wrapper(handler): + """WebSocket 鉴权包装 — 从 query string 取 token 验证""" + async def wrapper(request): + token = request.query.get("token", "") + session_store = request.app.get("panel_session_store", {}) + if not token or token not in session_store: + ws = web.WebSocketResponse() + await ws.prepare(request) + await ws.send_str(json.dumps({"error": "Unauthorized"})) + await ws.close(code=4001, message="Unauthorized") + return ws + return await handler(request) + return wrapper + def setup_routes(app, prefix=''): - app.router.add_get(f'{prefix}/api/framework', get_framework) - app.router.add_get(f'{prefix}/api/system', get_system) - app.router.add_get(f'{prefix}/api/system/ws', sys_ws_handler) - logger.info(f"📡 系统状态WS端点已注册: {prefix}/api/system/ws") + app.router.add_get(f'{prefix}/api/framework', panel_auth(get_framework)) + app.router.add_get(f'{prefix}/api/system', panel_auth(get_system)) + app.router.add_get(f'{prefix}/api/system/ws', _ws_auth_wrapper(sys_ws_handler)) + logger.info(f"📡 系统状态WS端点已注册: {prefix}/api/system/ws (已加认证)") def _read_version(): """Read version from config file (shared helper)"""