新增设备身份管理、认证限流、并发通道、Webhook路由、RBAC权限控制、SSE/轮询降级等全套网关通道功能,包含: 1. 设备身份生成与签名验证 2. 设备令牌认证与速率限制 3. 内存+数据库双重设备注册表 4. 并发通道限流管理 5. Webhook安全处理与路由 6. RBAC权限校验系统 7. OpenAI API兼容适配层 8. Tailscale认证支持 9. HTTP轮询降级机制
388 lines
12 KiB
Python
388 lines
12 KiB
Python
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
|