ForcePilot/backend/package/yuxi/channel/gateway/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

388 lines
12 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 hmac
import logging
import secrets
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from enum import StrEnum
from server.utils.auth_utils import AuthUtils
from yuxi.channel.gateway.auth_rate_limiter import (
AUTH_LOCKOUT_SECONDS,
AuthRateScope,
auth_rate_limiter,
)
from yuxi.channel.gateway.rbac import GatewayRole, map_user_role
logger = logging.getLogger(__name__)
AUTH_RATE_LOCKOUT_MINUTES = int(AUTH_LOCKOUT_SECONDS / 60)
LookupPublicKey = Callable[[str], Awaitable[str | None]]
class GatewayAuthMode(StrEnum):
TOKEN = "token"
PASSWORD = "password"
DEVICE_TOKEN = "device_token"
BOOTSTRAP = "bootstrap"
TAILSCALE = "tailscale"
TRUSTED_PROXY = "trusted_proxy"
@dataclass
class TrustedProxyConfig:
user_header: str
required_headers: list[str] = field(default_factory=list)
allow_users: list[str] = field(default_factory=list)
allow_loopback: bool = False
@dataclass
class GatewayAuthResult:
authenticated: bool
user_id: str | None = None
mode: GatewayAuthMode | None = None
error: str | None = None
metadata: dict = field(default_factory=dict)
roles: list[GatewayRole] = field(default_factory=list)
import threading
_bootstrap_token: str | None = None
_bootstrap_token_lock = threading.Lock()
def set_bootstrap_token(token: str | None) -> None:
global _bootstrap_token
with _bootstrap_token_lock:
_bootstrap_token = token
def get_bootstrap_token() -> str | None:
with _bootstrap_token_lock:
return _bootstrap_token
def clear_bootstrap_token() -> None:
global _bootstrap_token
with _bootstrap_token_lock:
_bootstrap_token = None
def generate_bootstrap_token() -> str:
return secrets.token_hex(24)
def _timing_safe_compare(a: str, b: str) -> bool:
if len(a) != len(b):
return False
return hmac.compare_digest(a.encode(), b.encode())
def _authenticate_bootstrap_token(token: str, client_ip: str | None) -> GatewayAuthResult:
scope_key = client_ip or "unknown"
stored = get_bootstrap_token()
is_locked = auth_rate_limiter.is_locked(scope_key, AuthRateScope.BOOTSTRAP)
if is_locked:
return GatewayAuthResult(
authenticated=False,
error=f"Bootstrap 认证尝试次数过多,请 {AUTH_RATE_LOCKOUT_MINUTES} 分钟后重试。",
metadata={"rate_limited": True, "retry_after_seconds": AUTH_LOCKOUT_SECONDS},
)
if not stored:
auth_rate_limiter.record_failure(scope_key, AuthRateScope.BOOTSTRAP)
return GatewayAuthResult(
authenticated=False,
error="Bootstrap token 未配置或已过期。",
)
if not _timing_safe_compare(token, stored):
auth_rate_limiter.record_failure(scope_key, AuthRateScope.BOOTSTRAP)
return GatewayAuthResult(
authenticated=False,
error="Bootstrap token 验证失败。",
)
auth_rate_limiter.reset(scope_key, AuthRateScope.BOOTSTRAP)
return GatewayAuthResult(
authenticated=True,
user_id="bootstrap",
mode=GatewayAuthMode.BOOTSTRAP,
roles=[GatewayRole.ADMIN],
metadata={"bootstrap": True},
)
async def authenticate_tailscale(
headers: dict | None,
client_ip: str | None = None,
remote_addr: str | None = None,
tailscale_whois: Callable[[str], Awaitable] | None = None,
) -> GatewayAuthResult:
from yuxi.channel.gateway.net_utils import (
is_tailscale_proxy_request,
resolve_tailscale_client_ip,
)
from yuxi.channel.gateway.tailscale_auth import (
get_tailscale_user_from_headers,
read_tailscale_whois_identity,
)
tailscale_user = get_tailscale_user_from_headers(headers)
if not tailscale_user:
return GatewayAuthResult(
authenticated=False,
error="缺少 Tailscale 用户身份头信息。",
)
login, name = tailscale_user
if not is_tailscale_proxy_request(remote_addr, headers):
return GatewayAuthResult(
authenticated=False,
error="请求未经过 Tailscale Serve 代理。",
)
real_client_ip = resolve_tailscale_client_ip(remote_addr, headers)
if not real_client_ip:
return GatewayAuthResult(
authenticated=False,
error="无法解析 Tailscale 客户端 IP。",
)
whois_func = tailscale_whois or read_tailscale_whois_identity
whois = await whois_func(real_client_ip)
if not whois or not whois.login:
return GatewayAuthResult(
authenticated=False,
error="Tailscale whois 查询失败。",
)
if whois.login != login:
return GatewayAuthResult(
authenticated=False,
error="Tailscale 用户身份不匹配。",
)
return GatewayAuthResult(
authenticated=True,
user_id=f"tailscale:{whois.login}",
mode=GatewayAuthMode.TAILSCALE,
metadata={"tailscale_login": whois.login, "tailscale_name": whois.name},
roles=[GatewayRole.OPERATOR],
)
def authenticate_trusted_proxy(
headers: dict | None,
client_ip: str | None,
remote_addr: str | None,
config: TrustedProxyConfig,
trusted_proxies: list[str],
) -> GatewayAuthResult:
from yuxi.channel.gateway.net_utils import (
is_loopback_address,
is_trusted_proxy_address,
)
if not headers:
return GatewayAuthResult(
authenticated=False,
error="缺少请求头信息,无法进行受信代理认证。",
)
if not remote_addr or not is_trusted_proxy_address(remote_addr, trusted_proxies):
return GatewayAuthResult(
authenticated=False,
error="请求来源 IP 不在受信代理列表中。",
)
remote_is_loopback = is_loopback_address(remote_addr)
if remote_is_loopback and not config.allow_loopback:
return GatewayAuthResult(
authenticated=False,
error="受信代理不允许回环地址来源,请启用 allow_loopback 配置。",
)
for header_name in config.required_headers:
value = headers.get(header_name.lower())
if not value or not value.strip():
return GatewayAuthResult(
authenticated=False,
error=f"受信代理缺少必要的请求头: {header_name}",
)
user_header_name = config.user_header.lower()
user_header_value = headers.get(user_header_name)
if not user_header_value or not user_header_value.strip():
return GatewayAuthResult(
authenticated=False,
error=f"受信代理未提供用户身份请求头: {config.user_header}",
)
user = user_header_value.strip()
if config.allow_users and user not in config.allow_users:
return GatewayAuthResult(
authenticated=False,
error=f"用户 {user} 不在受信代理的允许列表中。",
)
return GatewayAuthResult(
authenticated=True,
user_id=f"proxy:{user}",
mode=GatewayAuthMode.TRUSTED_PROXY,
metadata={"proxy_user": user, "proxy_remote": remote_addr},
roles=[GatewayRole.ADMIN],
)
async def authenticate_gateway_connect(
auth_header: str | None,
query_token: str | None = None,
client_ip: str | None = None,
shared_password: str | None = None,
lookup_public_key: LookupPublicKey | None = None,
bootstrap_token_param: str | None = None,
headers: dict | None = None,
remote_addr: str | None = None,
trusted_proxy_config: TrustedProxyConfig | None = None,
trusted_proxies: list[str] | None = None,
allow_tailscale: bool = False,
) -> GatewayAuthResult:
scope_key = client_ip or "unknown"
if trusted_proxy_config and trusted_proxies:
result = authenticate_trusted_proxy(
headers=headers,
client_ip=client_ip,
remote_addr=remote_addr,
config=trusted_proxy_config,
trusted_proxies=trusted_proxies,
)
if result.authenticated:
return result
if bootstrap_token_param:
return _authenticate_bootstrap_token(bootstrap_token_param, client_ip)
token = None
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[7:]
elif query_token:
token = query_token
if allow_tailscale:
from yuxi.channel.gateway.net_utils import is_local_direct_request
from yuxi.channel.gateway.tailscale_auth import get_tailscale_user_from_headers
if not is_local_direct_request(remote_addr, headers):
tailscale_user = get_tailscale_user_from_headers(headers)
if tailscale_user and not (shared_password and token):
result = await authenticate_tailscale(
headers=headers,
client_ip=client_ip,
remote_addr=remote_addr,
)
if result.authenticated:
return result
if token and token.startswith("dv."):
return await _authenticate_device_token(token, client_ip, lookup_public_key)
if shared_password and token:
if not _timing_safe_compare(token, shared_password):
auth_rate_limiter.record_failure(scope_key, AuthRateScope.PASSWORD)
return GatewayAuthResult(
authenticated=False,
error="密码认证失败。",
)
auth_rate_limiter.reset(scope_key, AuthRateScope.PASSWORD)
return GatewayAuthResult(
authenticated=True,
user_id="shared",
mode=GatewayAuthMode.PASSWORD,
roles=[GatewayRole.ADMIN],
)
if shared_password and not token:
return GatewayAuthResult(
authenticated=False,
error="缺少认证 token。",
)
if not token:
return GatewayAuthResult(
authenticated=False,
error="缺少认证 token。WebSocket 头部须携带 Bearer token或连接 URL 附加 ?token=xxx 参数。",
)
is_locked = auth_rate_limiter.is_locked(scope_key, AuthRateScope.TOKEN)
if is_locked:
remaining = auth_rate_limiter.remaining_attempts(scope_key, AuthRateScope.TOKEN)
return GatewayAuthResult(
authenticated=False,
error=f"认证尝试次数过多,请 {AUTH_RATE_LOCKOUT_MINUTES} 分钟后重试。",
metadata={"rate_limited": True, "retry_after_seconds": AUTH_LOCKOUT_SECONDS},
)
try:
payload = AuthUtils.verify_access_token(token)
user_id = payload.get("sub")
if not user_id:
raise ValueError("Token 缺少 subject 声明")
user_role = payload.get("role")
roles = [map_user_role(user_role)]
auth_rate_limiter.reset(scope_key, AuthRateScope.TOKEN)
return GatewayAuthResult(
authenticated=True,
user_id=user_id,
mode=GatewayAuthMode.TOKEN,
metadata={"token_payload": payload},
roles=roles,
)
except Exception as e:
auth_rate_limiter.record_failure(scope_key, AuthRateScope.TOKEN)
logger.debug("Token verification failed for scope=%s: %s", scope_key, e)
remaining = auth_rate_limiter.remaining_attempts(scope_key, AuthRateScope.TOKEN)
return GatewayAuthResult(
authenticated=False,
error="Token 验证失败,请检查凭据是否有效。",
metadata={"remaining_attempts": remaining},
)
async def _authenticate_device_token(
token: str,
client_ip: str | None,
lookup_public_key: LookupPublicKey | None,
) -> GatewayAuthResult:
from yuxi.channel.gateway.device_auth import authenticate_device
from yuxi.channel.gateway.device_registry import lookup_public_key as default_lookup
scope_key = client_ip or "unknown"
is_locked = auth_rate_limiter.is_locked(scope_key, AuthRateScope.DEVICE_TOKEN)
if is_locked:
return GatewayAuthResult(
authenticated=False,
error=f"设备认证尝试次数过多,请 {AUTH_RATE_LOCKOUT_MINUTES} 分钟后重试。",
metadata={"rate_limited": True, "retry_after_seconds": AUTH_LOCKOUT_SECONDS},
)
result = await authenticate_device(
token=token,
lookup_public_key=lookup_public_key or default_lookup,
client_ip=client_ip,
)
if not result.authenticated:
auth_rate_limiter.record_failure(scope_key, AuthRateScope.DEVICE_TOKEN)
else:
auth_rate_limiter.reset(scope_key, AuthRateScope.DEVICE_TOKEN)
return result