From 3aeb6ac5ce35730230ec566f6c8042e719dc9d25 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 13 Aug 2026 17:41:30 +0800 Subject: [PATCH] fix: authenticate WeKnora API client --- .../harness/deerflow/config/llmwiki_config.py | 8 +- .../deerflow/integrations/weknora/client.py | 141 +++++++++++++++--- .../deerflow/integrations/weknora/runtime.py | 2 + .../backend/tests/test_llmwiki_weknora.py | 73 ++++++++- 4 files changed, 200 insertions(+), 24 deletions(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/llmwiki_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/llmwiki_config.py index 5429edb0..5c1afd4b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/llmwiki_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/llmwiki_config.py @@ -38,16 +38,16 @@ class WeKnoraConnectionConfig(BaseModel): admin_email: str = Field( default="", description=( - "Optional WeKnora administrator email used only by DeerFlow's " - "server-side detail-page iframe proxy. Prefer $ENV references." + "Optional WeKnora administrator email used by DeerFlow's server-side " + "WeKnora API client and detail-page iframe proxy. Prefer $ENV references." ), ) admin_password: str = Field( default="", repr=False, description=( - "Optional WeKnora administrator password used only by DeerFlow's " - "server-side detail-page iframe proxy. Prefer $ENV references." + "Optional WeKnora administrator password used by DeerFlow's server-side " + "WeKnora API client and detail-page iframe proxy. Prefer $ENV references." ), ) model_config = ConfigDict(extra="ignore") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/client.py b/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/client.py index 3e475b2a..a6cca5dd 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/client.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/client.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence +import time from typing import Any from urllib.parse import quote @@ -41,9 +42,10 @@ class WeKnoraClient: """Async client for a trusted-network WeKnora endpoint. The caller may inject an ``httpx.AsyncClient`` for connection pooling and - tests. When omitted, each public method creates and closes a short-lived - client. DeerFlow deliberately sends no WeKnora credential; the independently - deployed service or its reverse proxy must allow DeerFlow's internal traffic. + tests. When omitted, each public method creates and closes a short-lived client. + If admin credentials are configured, DeerFlow authenticates to WeKnora with a + cached bearer token; otherwise it keeps the legacy trusted-network anonymous + mode for deployments that allow DeerFlow through a reverse proxy. """ def __init__( @@ -52,13 +54,105 @@ class WeKnoraClient: base_url: str, timeout_seconds: float = 30.0, http_client: httpx.AsyncClient | None = None, + admin_email: str = "", + admin_password: str = "", ) -> None: self.base_url = base_url.rstrip("/") self.timeout_seconds = timeout_seconds self._http_client = http_client + self.admin_email = admin_email.strip() + self.admin_password = admin_password + self._auth_token = "" + self._tenant_id = "" + self._auth_expires_at = 0.0 if not self.base_url: raise ValueError("WeKnora base URL is required") + @property + def _has_admin_credentials(self) -> bool: + return bool(self.admin_email and self.admin_password) + + @staticmethod + def _extract_login_token(payload: Any) -> tuple[str, str]: + if not isinstance(payload, dict): + return "", "" + source = payload.get("data") if isinstance(payload.get("data"), dict) else payload + token = str( + source.get("token") + or source.get("access_token") + or payload.get("token") + or payload.get("access_token") + or "" + ) + tenant = ( + source.get("tenant") + if isinstance(source.get("tenant"), dict) + else source.get("active_tenant") + if isinstance(source.get("active_tenant"), dict) + else payload.get("tenant") + ) + user = source.get("user") if isinstance(source.get("user"), dict) else payload.get("user") + tenant_id = "" + if isinstance(tenant, dict): + tenant_id = str(tenant.get("id") or "") + if not tenant_id and isinstance(user, dict): + tenant_id = str(user.get("tenant_id") or "") + return token, tenant_id + + async def _login(self, *, force_refresh: bool = False) -> None: + if not self._has_admin_credentials: + return + now = time.time() + if self._auth_token and not force_refresh and self._auth_expires_at > now + 60: + return + + owns_client = self._http_client is None + client = self._http_client or httpx.AsyncClient( + timeout=httpx.Timeout(self.timeout_seconds), + follow_redirects=False, + ) + try: + response = await client.post( + f"{self.base_url}/api/v1/auth/login", + json={"email": self.admin_email, "password": self.admin_password}, + headers={"Accept": "application/json"}, + ) + except httpx.TimeoutException as exc: + raise WeKnoraError("WeKnora admin login timed out", status_code=504) from exc + except httpx.HTTPError as exc: + raise WeKnoraError("WeKnora admin login is unavailable", status_code=502) from exc + finally: + if owns_client: + await client.aclose() + + if response.status_code >= 400: + raise WeKnoraError(f"WeKnora admin login failed ({response.status_code})", status_code=502) + try: + payload = response.json() + except ValueError as exc: + raise WeKnoraError("WeKnora admin login returned invalid JSON", status_code=502) from exc + if isinstance(payload, dict) and payload.get("success") is False: + raise WeKnoraError(str(payload.get("message") or "WeKnora admin login failed"), status_code=502) + token, tenant_id = self._extract_login_token(payload) + if not token: + raise WeKnoraError("WeKnora admin login did not return a token", status_code=502) + + self._auth_token = token + self._tenant_id = tenant_id + # WeKnora login responses do not consistently expose expiry. Refresh + # periodically and retry once if upstream says the token is stale. + self._auth_expires_at = now + 25 * 60 + + async def _headers(self, *, force_refresh: bool = False, accept: str = "application/json") -> dict[str, str]: + headers = {"Accept": accept} + if self._has_admin_credentials: + await self._login(force_refresh=force_refresh) + if self._auth_token: + headers["Authorization"] = f"Bearer {self._auth_token}" + if self._tenant_id: + headers["X-Tenant-ID"] = self._tenant_id + return headers + async def _request( self, method: str, @@ -69,19 +163,21 @@ class WeKnoraClient: files: Any = None, data: Mapping[str, Any] | None = None, ) -> Any: - headers = {"Accept": "application/json"} owns_client = self._http_client is None client = self._http_client or httpx.AsyncClient(timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False) try: - response = await client.request( - method, - f"{self.base_url}{path}", - headers=headers, - json=json, - params=params, - files=files, - data=data, - ) + for attempt in range(2): + response = await client.request( + method, + f"{self.base_url}{path}", + headers=await self._headers(force_refresh=attempt > 0), + json=json, + params=params, + files=files, + data=data, + ) + if response.status_code not in {401, 403} or not self._has_admin_credentials or attempt > 0: + break except httpx.TimeoutException as exc: raise WeKnoraError("WeKnora request timed out", status_code=504) from exc except httpx.HTTPError as exc: @@ -92,6 +188,8 @@ class WeKnoraClient: if response.status_code == 404: raise WeKnoraError("WeKnora resource was not found", status_code=404) if response.status_code in {401, 403}: + if self._has_admin_credentials: + raise WeKnoraError("WeKnora rejected DeerFlow's authenticated internal request", status_code=502) raise WeKnoraError( "WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy", status_code=502, @@ -296,12 +394,15 @@ class WeKnoraClient: follow_redirects=True, ) try: - response = await client.request( - "GET", - f"{self.base_url}/api/v1/knowledge/{quote(knowledge_id, safe='')}/preview", - headers={"Accept": "*/*"}, - follow_redirects=True, - ) + for attempt in range(2): + response = await client.request( + "GET", + f"{self.base_url}/api/v1/knowledge/{quote(knowledge_id, safe='')}/preview", + headers=await self._headers(force_refresh=attempt > 0, accept="*/*"), + follow_redirects=True, + ) + if response.status_code not in {401, 403} or not self._has_admin_credentials or attempt > 0: + break except httpx.TimeoutException as exc: raise WeKnoraError("WeKnora preview request timed out", status_code=504) from exc except httpx.HTTPError as exc: @@ -312,6 +413,8 @@ class WeKnoraClient: if response.status_code == 404: raise WeKnoraError("WeKnora document preview was not found", status_code=404) if response.status_code in {401, 403}: + if self._has_admin_credentials: + raise WeKnoraError("WeKnora rejected DeerFlow's authenticated preview request", status_code=502) raise WeKnoraError( "WeKnora rejected DeerFlow's anonymous internal request; allow DeerFlow in WeKnora or its reverse proxy", status_code=502, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/runtime.py b/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/runtime.py index ceaef736..fcc73402 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/runtime.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/runtime.py @@ -40,4 +40,6 @@ def build_weknora_client( return WeKnoraClient( base_url=runtime.api_base_url, http_client=http_client, + admin_email=runtime.admin_email, + admin_password=runtime.admin_password, ) diff --git a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py index 3791c03b..67806c8b 100644 --- a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +++ b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py @@ -230,7 +230,7 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo @pytest.mark.asyncio -async def test_weknora_client_sends_no_credentials_and_normalizes_search() -> None: +async def test_weknora_client_keeps_anonymous_mode_without_admin_credentials() -> None: requests: list[httpx.Request] = [] async def handler(request: httpx.Request) -> httpx.Response: @@ -270,6 +270,77 @@ async def test_weknora_client_sends_no_credentials_and_normalizes_search() -> No assert results[0]["title"] == "Guide" +@pytest.mark.asyncio +async def test_weknora_client_uses_admin_token_when_credentials_are_configured() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path == "/api/v1/auth/login": + assert json.loads(request.content) == {"email": "admin@example.test", "password": "secret"} + return httpx.Response( + 200, + json={ + "success": True, + "data": { + "token": "token-one", + "tenant": {"id": "tenant-one"}, + }, + }, + ) + assert request.headers["Authorization"] == "Bearer token-one" + assert request.headers["X-Tenant-ID"] == "tenant-one" + return httpx.Response(200, json={"success": True, "data": []}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + client = WeKnoraClient( + base_url="http://weknora.local:8080", + http_client=http_client, + admin_email="admin@example.test", + admin_password="secret", + ) + results = await client.search("deployment guide", ["kb-one"]) + + assert results == [] + assert [request.url.path for request in requests] == [ + "/api/v1/auth/login", + "/api/v1/knowledge-search", + ] + + +@pytest.mark.asyncio +async def test_weknora_client_refreshes_admin_token_after_rejection() -> None: + login_count = 0 + search_count = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal login_count, search_count + if request.url.path == "/api/v1/auth/login": + login_count += 1 + return httpx.Response( + 200, + json={"success": True, "data": {"token": f"token-{login_count}"}}, + ) + search_count += 1 + if search_count == 1: + assert request.headers["Authorization"] == "Bearer token-1" + return httpx.Response(401, json={"message": "expired"}) + assert request.headers["Authorization"] == "Bearer token-2" + return httpx.Response(200, json={"success": True, "data": []}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + client = WeKnoraClient( + base_url="http://weknora.local:8080", + http_client=http_client, + admin_email="admin@example.test", + admin_password="secret", + ) + await client.search("deployment guide", ["kb-one"]) + + assert login_count == 2 + assert search_count == 2 + + @pytest.mark.asyncio async def test_weknora_client_merges_required_name_for_description_update() -> None: requests: list[httpx.Request] = []