ForcePilot/backend/package/yuxi/channel/security/pairing.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

174 lines
6.1 KiB
Python

"""多渠道网关 DM 配对码管理。"""
from __future__ import annotations
import hmac
import secrets
from datetime import timedelta
from typing import TYPE_CHECKING
from sqlalchemy import delete, select
from yuxi.channel.constants import InboundRejectionReason
from yuxi.channel.plugins.protocol import OutboundMessage
from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.model_channel import ChannelPairingRecord
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
from .policy import SecurityCheckResult
from .qr_binding import PairingQRGenerator
if TYPE_CHECKING:
from .registry import SecurityContext
class PairingChecker:
"""SecurityChecker 包装器:在 DM 白名单拒绝且 mode != deny 时生成配对码。"""
name = "pairing"
default_priority = 200
def __init__(self, manager: PairingManager | None = None) -> None:
self.manager = manager or PairingManager()
async def check(self, ctx: SecurityContext) -> SecurityCheckResult | None:
peer_id = ctx.inbound.peer_id or ctx.inbound.sender_id or ""
pairing_cfg = ctx.config_checker or ctx.config.get("pairing", {})
mode = pairing_cfg.get("mode", "code")
try:
code = await self.manager.generate_pairing_code(
ctx.inbound.channel_type,
ctx.inbound.account_id,
peer_id,
pairing_mode=mode,
)
except Exception:
logger.exception("Failed to generate pairing code")
return SecurityCheckResult(
allowed=False,
reason=InboundRejectionReason.DM_NOT_ALLOWED,
)
if mode in ("qr", "both"):
qr_generator = PairingQRGenerator(base_url=pairing_cfg.get("qr_link_base_url"))
qr_content = qr_generator.generate_content(
code,
ctx.inbound.channel_type,
ctx.inbound.account_id,
peer_id,
mode=pairing_cfg.get("qr_content_mode", "plain"),
)
build_qr_reply = getattr(ctx.plugin, "build_pairing_qr_reply", None)
if build_qr_reply is not None:
qr_reply = await build_qr_reply(code, qr_content, ctx.config, ctx.inbound.account_id)
else:
qr_reply = OutboundMessage(content=qr_content or code, content_type="text")
return SecurityCheckResult(
allowed=False,
reason=InboundRejectionReason.DM_PAIRING_REQUIRED,
pairing_code=code,
qr_content=qr_content,
qr_reply=qr_reply,
)
return SecurityCheckResult(
allowed=False,
reason=InboundRejectionReason.DM_PAIRING_REQUIRED,
pairing_code=code,
)
class PairingManager:
CODE_TTL_MINUTES = 10
CODE_LENGTH = 6
def generate_code_string(self) -> str:
"""生成一个独立的 6 位数字配对码字符串(不写入数据库)。"""
return "".join(secrets.choice("0123456789") for _ in range(self.CODE_LENGTH))
async def generate_pairing_code(
self,
channel_type: str,
account_id: str,
peer_id: str,
platform_user_id: str | None = None,
qr_content: str | None = None,
pairing_mode: str = "code",
code: str | None = None,
) -> str:
code = code or self.generate_code_string()
token = secrets.token_urlsafe(32)
expires_at = utc_now_naive() + timedelta(minutes=self.CODE_TTL_MINUTES)
async with pg_manager.get_async_session_context() as session:
await session.execute(
delete(ChannelPairingRecord).where(
ChannelPairingRecord.channel_type == channel_type,
ChannelPairingRecord.account_id == account_id,
ChannelPairingRecord.peer_id == peer_id,
ChannelPairingRecord.status == "pending",
)
)
record = ChannelPairingRecord(
channel_type=channel_type,
account_id=account_id,
peer_id=peer_id,
pairing_code=code,
pairing_token=token,
status="pending",
expires_at=expires_at,
platform_user_id=platform_user_id,
qr_content=qr_content,
pairing_mode=pairing_mode,
)
session.add(record)
await session.commit()
return code
async def verify_pairing_code(
self,
channel_type: str,
account_id: str,
peer_id: str,
code: str,
) -> bool:
record = await self.verify_pairing_code_record(channel_type, account_id, peer_id, code)
return record is not None
async def verify_pairing_code_record(
self,
channel_type: str,
account_id: str,
peer_id: str,
code: str,
) -> ChannelPairingRecord | None:
"""验证配对码并返回匹配的记录,验证成功时标记为已配对。"""
now = utc_now_naive()
async with pg_manager.get_async_session_context() as session:
result = await session.execute(
select(ChannelPairingRecord)
.where(
ChannelPairingRecord.channel_type == channel_type,
ChannelPairingRecord.account_id == account_id,
ChannelPairingRecord.peer_id == peer_id,
ChannelPairingRecord.status == "pending",
ChannelPairingRecord.expires_at > now,
)
.order_by(ChannelPairingRecord.created_at.desc())
.limit(1)
.with_for_update()
)
record = result.scalar_one_or_none()
if record is not None and hmac.compare_digest(record.pairing_code, code):
record.status = "paired"
record.paired_at = now
await session.commit()
return record
return None