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