mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
e1ac5e0fc8
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
490 lines
18 KiB
Python
490 lines
18 KiB
Python
# 微信公众号的加解密算法与企业微信一样,所以直接使用企业微信的加解密算法文件
|
|
import time
|
|
import traceback
|
|
from langbot.libs.wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt
|
|
import xml.etree.ElementTree as ET
|
|
from quart import Quart, request
|
|
import hashlib
|
|
from typing import Callable
|
|
from langbot.libs.official_account_api.oaevent import OAEvent
|
|
|
|
import asyncio
|
|
|
|
|
|
xml_template = """
|
|
<xml>
|
|
<ToUserName><![CDATA[{to_user}]]></ToUserName>
|
|
<FromUserName><![CDATA[{from_user}]]></FromUserName>
|
|
<CreateTime>{create_time}</CreateTime>
|
|
<MsgType><![CDATA[text]]></MsgType>
|
|
<Content><![CDATA[{content}]]></Content>
|
|
</xml>
|
|
"""
|
|
|
|
_MAX_CALLBACK_BODY_BYTES = 1024 * 1024
|
|
|
|
|
|
class OAClient:
|
|
_STATE_TTL_SECONDS = 600
|
|
_STATE_MAX = 4096
|
|
_MAX_CONTENT_CHARS = 200000
|
|
|
|
def __init__(
|
|
self,
|
|
token: str,
|
|
EncodingAESKey: str,
|
|
AppID: str,
|
|
Appsecret: str,
|
|
logger: None,
|
|
unified_mode: bool = False,
|
|
api_base_url: str = 'https://api.weixin.qq.com',
|
|
):
|
|
self.token = token
|
|
self.aes = EncodingAESKey
|
|
self.appid = AppID
|
|
self.appsecret = Appsecret
|
|
self.base_url = api_base_url
|
|
self.access_token = ''
|
|
self.unified_mode = unified_mode
|
|
self.app = Quart(__name__)
|
|
self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
|
|
|
|
# 只有在非统一模式下才注册独立路由
|
|
if not self.unified_mode:
|
|
self.app.add_url_rule(
|
|
'/callback/command',
|
|
'handle_callback',
|
|
self.handle_callback_request,
|
|
methods=['GET', 'POST'],
|
|
)
|
|
|
|
self._message_handlers = {
|
|
'example': [],
|
|
}
|
|
self.access_token_expiry_time = None
|
|
self.msg_id_map = {}
|
|
self.generated_content = {}
|
|
self._msg_seen_at = {}
|
|
self._generated_at = {}
|
|
self._last_state_prune = 0.0
|
|
self.logger = logger
|
|
|
|
def _prune_state(self) -> None:
|
|
now = time.monotonic()
|
|
if now - self._last_state_prune >= 60:
|
|
self._last_state_prune = now
|
|
for message_id, seen_at in tuple(self._msg_seen_at.items()):
|
|
if now - seen_at > self._STATE_TTL_SECONDS:
|
|
self._msg_seen_at.pop(message_id, None)
|
|
self.msg_id_map.pop(message_id, None)
|
|
for message_id, generated_at in tuple(self._generated_at.items()):
|
|
if now - generated_at > self._STATE_TTL_SECONDS:
|
|
self._generated_at.pop(message_id, None)
|
|
self.generated_content.pop(message_id, None)
|
|
while len(self.msg_id_map) > self._STATE_MAX:
|
|
message_id = next(iter(self.msg_id_map))
|
|
self.msg_id_map.pop(message_id, None)
|
|
self._msg_seen_at.pop(message_id, None)
|
|
while len(self.generated_content) > self._STATE_MAX:
|
|
message_id = next(iter(self.generated_content))
|
|
self.generated_content.pop(message_id, None)
|
|
self._generated_at.pop(message_id, None)
|
|
|
|
def clear(self) -> None:
|
|
self.msg_id_map.clear()
|
|
self.generated_content.clear()
|
|
self._msg_seen_at.clear()
|
|
self._generated_at.clear()
|
|
|
|
async def handle_callback_request(self):
|
|
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
|
return await self._handle_callback_internal(request)
|
|
|
|
async def handle_unified_webhook(self, req):
|
|
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
|
|
|
Args:
|
|
req: Quart Request 对象
|
|
|
|
Returns:
|
|
响应数据
|
|
"""
|
|
return await self._handle_callback_internal(req)
|
|
|
|
async def _handle_callback_internal(self, req):
|
|
"""处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
|
|
|
Args:
|
|
req: Quart Request 对象
|
|
"""
|
|
try:
|
|
# 每隔100毫秒查询是否生成ai回答
|
|
start_time = time.time()
|
|
signature = req.args.get('signature', '')
|
|
timestamp = req.args.get('timestamp', '')
|
|
nonce = req.args.get('nonce', '')
|
|
echostr = req.args.get('echostr', '')
|
|
msg_signature = req.args.get('msg_signature', '')
|
|
if msg_signature is None:
|
|
await self.logger.error('msg_signature不在请求体中')
|
|
raise Exception('msg_signature不在请求体中')
|
|
|
|
if req.method == 'GET':
|
|
# 校验签名
|
|
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
|
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
|
|
|
if check_signature == signature:
|
|
return echostr # 验证成功返回echostr
|
|
else:
|
|
await self.logger.error('拒绝请求')
|
|
raise Exception('拒绝请求')
|
|
elif req.method == 'POST':
|
|
encryt_msg = await req.data
|
|
if len(encryt_msg) > _MAX_CALLBACK_BODY_BYTES:
|
|
raise ValueError('Official Account callback body exceeds the size limit')
|
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
|
ret, xml_msg = await asyncio.to_thread(
|
|
wxcpt.DecryptMsg,
|
|
encryt_msg,
|
|
msg_signature,
|
|
timestamp,
|
|
nonce,
|
|
)
|
|
xml_msg = xml_msg.decode('utf-8')
|
|
|
|
if ret != 0:
|
|
await self.logger.error('消息解密失败')
|
|
raise Exception('消息解密失败')
|
|
|
|
message_data = await self.get_message(xml_msg)
|
|
if message_data:
|
|
event = OAEvent.from_payload(message_data)
|
|
if event:
|
|
await self._handle_message(event)
|
|
|
|
root = await asyncio.to_thread(ET.fromstring, xml_msg)
|
|
from_user = root.find('FromUserName').text # 发送者
|
|
to_user = root.find('ToUserName').text # 机器人
|
|
|
|
timeout = 4.80
|
|
interval = 0.1
|
|
while True:
|
|
content = self.generated_content.pop(message_data['MsgId'], None)
|
|
self._generated_at.pop(message_data['MsgId'], None)
|
|
if content:
|
|
response_xml = xml_template.format(
|
|
to_user=from_user,
|
|
from_user=to_user,
|
|
create_time=int(time.time()),
|
|
content=content,
|
|
)
|
|
|
|
return response_xml
|
|
|
|
if time.time() - start_time >= timeout:
|
|
break
|
|
|
|
await asyncio.sleep(interval)
|
|
|
|
if self.msg_id_map.get(message_data['MsgId'], 1) == 3:
|
|
# response_xml = xml_template.format(
|
|
# to_user=from_user,
|
|
# from_user=to_user,
|
|
# create_time=int(time.time()),
|
|
# content = "请求失效:暂不支持公众号超过15秒的请求,如有需求,请联系 LangBot 团队。"
|
|
# )
|
|
print('请求失效:暂不支持公众号超过15秒的请求,如有需求,请联系 LangBot 团队。')
|
|
return ''
|
|
|
|
except Exception:
|
|
await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}')
|
|
traceback.print_exc()
|
|
|
|
async def get_message(self, xml_msg: str):
|
|
root = await asyncio.to_thread(ET.fromstring, xml_msg)
|
|
|
|
message_data = {
|
|
'ToUserName': root.find('ToUserName').text,
|
|
'FromUserName': root.find('FromUserName').text,
|
|
'CreateTime': int(root.find('CreateTime').text),
|
|
'MsgType': root.find('MsgType').text,
|
|
'Content': root.find('Content').text if root.find('Content') is not None else None,
|
|
'MsgId': int(root.find('MsgId').text) if root.find('MsgId') is not None else None,
|
|
}
|
|
|
|
return message_data
|
|
|
|
async def run_task(self, host: str, port: int, *args, **kwargs):
|
|
"""
|
|
启动 Quart 应用。
|
|
"""
|
|
await self.app.run_task(host=host, port=port, *args, **kwargs)
|
|
|
|
def on_message(self, msg_type: str):
|
|
"""
|
|
注册消息类型处理器。
|
|
"""
|
|
|
|
def decorator(func: Callable[[OAEvent], None]):
|
|
if msg_type not in self._message_handlers:
|
|
self._message_handlers[msg_type] = []
|
|
self._message_handlers[msg_type].append(func)
|
|
return func
|
|
|
|
return decorator
|
|
|
|
async def _handle_message(self, event: OAEvent):
|
|
"""
|
|
处理消息事件。
|
|
"""
|
|
message_id = event.message_id
|
|
self._prune_state()
|
|
if message_id in self.msg_id_map.keys():
|
|
self.msg_id_map[message_id] += 1
|
|
self._msg_seen_at[message_id] = time.monotonic()
|
|
return
|
|
|
|
self.msg_id_map[message_id] = 1
|
|
self._msg_seen_at[message_id] = time.monotonic()
|
|
msg_type = event.type
|
|
if msg_type in self._message_handlers:
|
|
for handler in self._message_handlers[msg_type]:
|
|
await handler(event)
|
|
|
|
async def set_message(self, msg_id: int, content: str):
|
|
self.generated_content[msg_id] = str(content)[: self._MAX_CONTENT_CHARS]
|
|
self._generated_at[msg_id] = time.monotonic()
|
|
self._prune_state()
|
|
|
|
|
|
class OAClientForLongerResponse:
|
|
_MAX_USERS = 4096
|
|
_MAX_MESSAGES_PER_USER = 20
|
|
_MAX_CONTENT_CHARS = 200000
|
|
|
|
def __init__(
|
|
self,
|
|
token: str,
|
|
EncodingAESKey: str,
|
|
AppID: str,
|
|
Appsecret: str,
|
|
LoadingMessage: str,
|
|
logger: None,
|
|
unified_mode: bool = False,
|
|
api_base_url: str = 'https://api.weixin.qq.com',
|
|
):
|
|
self.token = token
|
|
self.aes = EncodingAESKey
|
|
self.appid = AppID
|
|
self.appsecret = Appsecret
|
|
self.base_url = api_base_url
|
|
self.access_token = ''
|
|
self.unified_mode = unified_mode
|
|
self.app = Quart(__name__)
|
|
self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
|
|
|
|
# 只有在非统一模式下才注册独立路由
|
|
if not self.unified_mode:
|
|
self.app.add_url_rule(
|
|
'/callback/command',
|
|
'handle_callback',
|
|
self.handle_callback_request,
|
|
methods=['GET', 'POST'],
|
|
)
|
|
|
|
self._message_handlers = {
|
|
'example': [],
|
|
}
|
|
self.access_token_expiry_time = None
|
|
self.loading_message = LoadingMessage
|
|
self.msg_queue = {}
|
|
self.user_msg_queue = {}
|
|
self._last_queue_cleanup = 0.0
|
|
self.logger = logger
|
|
|
|
def _prune_queues(self) -> None:
|
|
now = time.monotonic()
|
|
if now - self._last_queue_cleanup >= 60:
|
|
self._last_queue_cleanup = now
|
|
for user_id, queue in tuple(self.msg_queue.items()):
|
|
if not queue:
|
|
self.msg_queue.pop(user_id, None)
|
|
for user_id, queue in tuple(self.user_msg_queue.items()):
|
|
if not queue:
|
|
self.user_msg_queue.pop(user_id, None)
|
|
while len(self.msg_queue) > self._MAX_USERS:
|
|
self.msg_queue.pop(next(iter(self.msg_queue)), None)
|
|
while len(self.user_msg_queue) > self._MAX_USERS:
|
|
self.user_msg_queue.pop(next(iter(self.user_msg_queue)), None)
|
|
|
|
def clear(self) -> None:
|
|
self.msg_queue.clear()
|
|
self.user_msg_queue.clear()
|
|
|
|
async def handle_callback_request(self):
|
|
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
|
return await self._handle_callback_internal(request)
|
|
|
|
async def handle_unified_webhook(self, req):
|
|
"""处理回调请求(统一 webhook 模式,显式传递 request)。
|
|
|
|
Args:
|
|
req: Quart Request 对象
|
|
|
|
Returns:
|
|
响应数据
|
|
"""
|
|
return await self._handle_callback_internal(req)
|
|
|
|
async def _handle_callback_internal(self, req):
|
|
"""处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
|
|
|
Args:
|
|
req: Quart Request 对象
|
|
"""
|
|
try:
|
|
signature = req.args.get('signature', '')
|
|
timestamp = req.args.get('timestamp', '')
|
|
nonce = req.args.get('nonce', '')
|
|
echostr = req.args.get('echostr', '')
|
|
msg_signature = req.args.get('msg_signature', '')
|
|
|
|
if msg_signature is None:
|
|
await self.logger.error('msg_signature不在请求体中')
|
|
raise Exception('msg_signature不在请求体中')
|
|
|
|
if req.method == 'GET':
|
|
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
|
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
|
return echostr if check_signature == signature else '拒绝请求'
|
|
|
|
elif req.method == 'POST':
|
|
encryt_msg = await req.data
|
|
if len(encryt_msg) > _MAX_CALLBACK_BODY_BYTES:
|
|
raise ValueError('Official Account callback body exceeds the size limit')
|
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
|
ret, xml_msg = await asyncio.to_thread(
|
|
wxcpt.DecryptMsg,
|
|
encryt_msg,
|
|
msg_signature,
|
|
timestamp,
|
|
nonce,
|
|
)
|
|
xml_msg = xml_msg.decode('utf-8')
|
|
|
|
if ret != 0:
|
|
await self.logger.error('消息解密失败')
|
|
raise Exception('消息解密失败')
|
|
|
|
# 解析 XML
|
|
root = await asyncio.to_thread(ET.fromstring, xml_msg)
|
|
from_user = root.find('FromUserName').text
|
|
to_user = root.find('ToUserName').text
|
|
|
|
if self.msg_queue.get(from_user) and self.msg_queue[from_user][0]['content']:
|
|
queue_top = self.msg_queue[from_user].pop(0)
|
|
queue_content = queue_top['content']
|
|
|
|
# 弹出用户消息
|
|
if self.user_msg_queue.get(from_user) and self.user_msg_queue[from_user]:
|
|
self.user_msg_queue[from_user].pop(0)
|
|
self._prune_queues()
|
|
|
|
response_xml = xml_template.format(
|
|
to_user=from_user,
|
|
from_user=to_user,
|
|
create_time=int(time.time()),
|
|
content=queue_content,
|
|
)
|
|
return response_xml
|
|
|
|
else:
|
|
response_xml = xml_template.format(
|
|
to_user=from_user,
|
|
from_user=to_user,
|
|
create_time=int(time.time()),
|
|
content=self.loading_message,
|
|
)
|
|
|
|
if self.user_msg_queue.get(from_user) and self.user_msg_queue[from_user][0]['content']:
|
|
return response_xml
|
|
else:
|
|
message_data = await self.get_message(xml_msg)
|
|
|
|
if message_data:
|
|
event = OAEvent.from_payload(message_data)
|
|
if event:
|
|
self.user_msg_queue.setdefault(from_user, []).append(
|
|
{
|
|
'content': str(event.message)[: self._MAX_CONTENT_CHARS],
|
|
}
|
|
)
|
|
self.user_msg_queue[from_user] = self.user_msg_queue[from_user][
|
|
-self._MAX_MESSAGES_PER_USER :
|
|
]
|
|
self._prune_queues()
|
|
await self._handle_message(event)
|
|
|
|
return response_xml
|
|
|
|
except Exception:
|
|
await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}')
|
|
traceback.print_exc()
|
|
|
|
async def get_message(self, xml_msg: str):
|
|
root = await asyncio.to_thread(ET.fromstring, xml_msg)
|
|
|
|
message_data = {
|
|
'ToUserName': root.find('ToUserName').text,
|
|
'FromUserName': root.find('FromUserName').text,
|
|
'CreateTime': int(root.find('CreateTime').text),
|
|
'MsgType': root.find('MsgType').text,
|
|
'Content': root.find('Content').text if root.find('Content') is not None else None,
|
|
'MsgId': int(root.find('MsgId').text) if root.find('MsgId') is not None else None,
|
|
}
|
|
|
|
return message_data
|
|
|
|
async def run_task(self, host: str, port: int, *args, **kwargs):
|
|
"""
|
|
启动 Quart 应用。
|
|
"""
|
|
await self.app.run_task(host=host, port=port, *args, **kwargs)
|
|
|
|
def on_message(self, msg_type: str):
|
|
"""
|
|
注册消息类型处理器。
|
|
"""
|
|
|
|
def decorator(func: Callable[[OAEvent], None]):
|
|
if msg_type not in self._message_handlers:
|
|
self._message_handlers[msg_type] = []
|
|
self._message_handlers[msg_type].append(func)
|
|
return func
|
|
|
|
return decorator
|
|
|
|
async def _handle_message(self, event: OAEvent):
|
|
"""
|
|
处理消息事件。
|
|
"""
|
|
|
|
msg_type = event.type
|
|
if msg_type in self._message_handlers:
|
|
for handler in self._message_handlers[msg_type]:
|
|
await handler(event)
|
|
|
|
async def set_message(self, from_user: int, message_id: int, content: str):
|
|
if from_user not in self.msg_queue:
|
|
self.msg_queue[from_user] = []
|
|
|
|
self.msg_queue[from_user].append(
|
|
{
|
|
'msg_id': message_id,
|
|
'content': str(content)[: self._MAX_CONTENT_CHARS],
|
|
}
|
|
)
|
|
self.msg_queue[from_user] = self.msg_queue[from_user][-self._MAX_MESSAGES_PER_USER :]
|
|
self._prune_queues()
|