ForcePilot/backend/package/yuxi/channel/gateway/device_auth.py
Kris ecd3c90e80 feat(channel/gateway): 新增完整网关通道模块
新增设备身份管理、认证限流、并发通道、Webhook路由、RBAC权限控制、SSE/轮询降级等全套网关通道功能,包含:
1. 设备身份生成与签名验证
2. 设备令牌认证与速率限制
3. 内存+数据库双重设备注册表
4. 并发通道限流管理
5. Webhook安全处理与路由
6. RBAC权限校验系统
7. OpenAI API兼容适配层
8. Tailscale认证支持
9. HTTP轮询降级机制
2026-05-21 10:26:33 +08:00

109 lines
3.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import base64
import logging
import time
from yuxi.channel.gateway.auth import GatewayAuthMode, GatewayAuthResult
from yuxi.channel.gateway.device_identity import verify_signature
from yuxi.channel.gateway.rbac import GatewayRole
logger = logging.getLogger(__name__)
DEVICE_TOKEN_PREFIX = "dv."
DEVICE_TOKEN_TIMESTAMP_TOLERANCE_SECONDS = 300
def parse_device_token(token: str) -> tuple[str, int, bytes] | None:
"""解析设备令牌,格式: dv.{base64(device_id)}.{base64(timestamp_ms)}.{base64(signature)}"""
if not token.startswith(DEVICE_TOKEN_PREFIX):
return None
parts = token[len(DEVICE_TOKEN_PREFIX) :].split(".")
if len(parts) != 3:
return None
try:
def _add_padding(s: str) -> str:
missing = (4 - len(s) % 4) % 4
return s + "=" * missing if missing else s
device_id = base64.urlsafe_b64decode(_add_padding(parts[0])).decode("utf-8")
timestamp_ms = int(base64.urlsafe_b64decode(_add_padding(parts[1])).decode("utf-8"))
signature = base64.urlsafe_b64decode(_add_padding(parts[2]))
except (ValueError, UnicodeDecodeError, base64.binascii.Error):
return None
return device_id, timestamp_ms, signature
def build_device_token(device_id: str, timestamp_ms: int, signature: bytes) -> str:
"""构建设备令牌"""
device_id_b64 = base64.urlsafe_b64encode(device_id.encode("utf-8")).rstrip(b"=").decode("ascii")
ts_b64 = base64.urlsafe_b64encode(str(timestamp_ms).encode("utf-8")).rstrip(b"=").decode("ascii")
sig_b64 = base64.urlsafe_b64encode(signature).rstrip(b"=").decode("ascii")
return f"{DEVICE_TOKEN_PREFIX}{device_id_b64}.{ts_b64}.{sig_b64}"
def build_challenge(device_id: str, timestamp_ms: int) -> bytes:
return f"{device_id}:{timestamp_ms}".encode("utf-8")
def validate_timestamp(timestamp_ms: int) -> bool:
now_ms = int(time.time() * 1000)
if timestamp_ms > now_ms:
return False
diff_ms = now_ms - timestamp_ms
return diff_ms <= DEVICE_TOKEN_TIMESTAMP_TOLERANCE_SECONDS * 1000
async def authenticate_device(
token: str,
lookup_public_key,
client_ip: str | None = None,
) -> GatewayAuthResult:
parsed = parse_device_token(token)
if parsed is None:
return GatewayAuthResult(
authenticated=False,
mode=GatewayAuthMode.DEVICE_TOKEN,
error="设备令牌格式无效。预期格式: dv.{deviceId}.{ts}.{sig}",
)
device_id, timestamp_ms, signature = parsed
if not validate_timestamp(timestamp_ms):
return GatewayAuthResult(
authenticated=False,
mode=GatewayAuthMode.DEVICE_TOKEN,
error="设备令牌时间戳超出容忍范围±5 分钟)。",
metadata={"device_id": device_id},
)
public_key_pem = await lookup_public_key(device_id)
if public_key_pem is None:
return GatewayAuthResult(
authenticated=False,
mode=GatewayAuthMode.DEVICE_TOKEN,
error=f"未知设备: {device_id}",
metadata={"device_id": device_id},
)
challenge = build_challenge(device_id, timestamp_ms)
if not verify_signature(public_key_pem, challenge, signature):
return GatewayAuthResult(
authenticated=False,
mode=GatewayAuthMode.DEVICE_TOKEN,
error="设备签名验证失败。",
metadata={"device_id": device_id},
)
logger.info(
"Device authenticated: device_id=%s ip=%s",
device_id,
client_ip or "unknown",
)
return GatewayAuthResult(
authenticated=True,
user_id=f"device:{device_id}",
mode=GatewayAuthMode.DEVICE_TOKEN,
metadata={"device_id": device_id},
roles=[GatewayRole.OPERATOR],
)