From 5c2379577be374808084b11449098fd2f18e64f8 Mon Sep 17 00:00:00 2001 From: Administrator Date: Tue, 2 Jun 2026 19:33:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20token=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=B0=86=E4=BB=A4=E7=89=8C=E6=94=BE=E5=85=A5=20Authorization:?= =?UTF-8?q?=20Bearer=20=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit token 换登录调上游 getTokenInfo 时参数传错:原来发静态头 Authorization: Bearer admin + 令牌放 access_token cookie。上游实际 从 Authorization: Bearer 头读取令牌。 - 鉴权头改为 "Bearer "+前端传入 token,删除 access_token cookie - service_authorization 配置项弃用(保留兼容,不再参与请求) - 新增回归测试 test_token_login_upstream_call.py - 同步更新 AUTH_LOGIN.md / CLAUDE.md / config.yaml 注释 / login_config 文档 Co-Authored-By: Claude Opus 4.8 (1M context) --- offline-backend-20260512/backend/CLAUDE.md | 2 +- .../backend/app/gateway/routers/auth.py | 13 ++- offline-backend-20260512/backend/config.yaml | 5 +- .../backend/docs/AUTH_LOGIN.md | 9 +- .../harness/deerflow/config/login_config.py | 9 +- .../tests/test_token_login_upstream_call.py | 89 +++++++++++++++++++ offline-backend-20260512/config.yaml | 5 +- 7 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 offline-backend-20260512/backend/tests/test_token_login_upstream_call.py diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index ee3bc593..d66260f1 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -499,7 +499,7 @@ Per-user runtime overrides (whitelisted fields) live at `{base_dir}/users/{user_ - `memory` - Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens) - `auth_login` - Custom login modes layered on the base auth (`deerflow/config/login_config.py`): - `username_login.{require_password, password}` - Password gate for `POST /api/v1/auth/login/username`. When `require_password=true`, the (otherwise passwordless) username login additionally requires `?password=`; empty `password` falls back to `123ewq`. Gate only — it does not change which account logs in. - - `token_login.{enabled, token_info_url, service_authorization, timeout_seconds, verify_ssl, ca_cert_path}` - Token-exchange login (`POST /api/v1/auth/login/token`). The gateway POSTs to `token_info_url` with `Authorization: ` and the supplied token as the `access_token` cookie, reads `data.username` from a `{"state":"200",...}` body, then signs the matching local user in (auto-registering if absent). `token_info_url` is HTTPS — `verify_ssl: false` skips cert verification for self-signed/internal UAT hosts, or set `ca_cert_path` to a PEM bundle (takes precedence, keeps verification on). See [docs/AUTH_LOGIN.md](docs/AUTH_LOGIN.md) for the full request/response contract. + - `token_login.{enabled, token_info_url, service_authorization, timeout_seconds, verify_ssl, ca_cert_path}` - Token-exchange login (`POST /api/v1/auth/login/token`). The gateway POSTs to `token_info_url` with the caller-supplied token placed directly in the `Authorization: Bearer ` header (no cookie; the static `service_authorization` field is deprecated/unused, kept only for backward compat), reads `data.username` from a `{"state":"200",...}` body, then signs the matching local user in (auto-registering if absent). `token_info_url` is HTTPS — `verify_ssl: false` skips cert verification for self-signed/internal UAT hosts, or set `ca_cert_path` to a PEM bundle (takes precedence, keeps verification on). See [docs/AUTH_LOGIN.md](docs/AUTH_LOGIN.md) for the full request/response contract. Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The shared `_get_or_create_user_by_username()` helper (in `routers/auth.py`) backs both `/login/username` and `/login/token`. diff --git a/offline-backend-20260512/backend/app/gateway/routers/auth.py b/offline-backend-20260512/backend/app/gateway/routers/auth.py index e126be2a..22951c3a 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/auth.py +++ b/offline-backend-20260512/backend/app/gateway/routers/auth.py @@ -255,11 +255,11 @@ async def _get_or_create_user_by_username(username: str): async def _fetch_username_from_token(token: str) -> str: """Resolve an upstream access token to a username via the configured endpoint. - Calls ``auth_login.token_login.token_info_url`` exactly like the documented - curl: a POST carrying ``Authorization: `` plus the - token as the ``access_token`` cookie. Expects ``{"state": "200", "data": - {"username": ...}}`` and returns ``data.username``. Any upstream failure, - non-200 ``state``, or missing username raises HTTP 401. + Calls ``auth_login.token_login.token_info_url`` with the caller-supplied + token placed directly in the ``Authorization: Bearer `` header — the + upstream reads the bearer token from there, not from a cookie. Expects + ``{"state": "200", "data": {"username": ...}}`` and returns ``data.username``. + Any upstream failure, non-200 ``state``, or missing username raises HTTP 401. """ config = get_app_config().auth_login.token_login if not config.token_info_url: @@ -276,10 +276,9 @@ async def _fetch_username_from_token(token: str) -> str: upstream = await client.post( config.token_info_url, headers={ - "Authorization": config.service_authorization, + "Authorization": f"Bearer {token}", "Content-Type": "application/x-www-form-urlencoded", }, - cookies={"access_token": token}, ) except httpx.HTTPError as exc: # Surface the concrete failure (SSL handshake, DNS, timeout, ...) in logs diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 0cddb00e..345a2f46 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -195,11 +195,12 @@ auth_login: require_password: false password: "" # token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url - # (带 Authorization 头 + access_token cookie)换出用户名,再登录;用户名不在 - # 库里时自动注册。 + # (把该 token 直接拼成 Authorization: Bearer 头)换出用户名,再登录; + # 用户名不在库里时自动注册。 token_login: enabled: false token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo" + # 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer ",此项不再生效,保留仅兼容。 service_authorization: "Bearer admin" timeout_seconds: 10 # 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致 diff --git a/offline-backend-20260512/backend/docs/AUTH_LOGIN.md b/offline-backend-20260512/backend/docs/AUTH_LOGIN.md index d0e74dc5..486fdc14 100644 --- a/offline-backend-20260512/backend/docs/AUTH_LOGIN.md +++ b/offline-backend-20260512/backend/docs/AUTH_LOGIN.md @@ -90,14 +90,13 @@ Content-Type: application/json POST /api/v1/auth/login/token?token=<上游access_token> ``` -后端据此向 `token_info_url` 发起(与下方 curl 对齐): +后端据此向 `token_info_url` 发起(与下方 curl 对齐):**前端传入的 token 直接拼成 `Authorization: Bearer ` 头**,上游从该头读取令牌(不再用 cookie,也不再用静态 `service_authorization`)。 ```bash curl 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo' \ -X POST \ - -H 'authorization: Bearer admin' \ - -H 'content-type: application/x-www-form-urlencoded' \ - -H 'cookie: access_token=<上游access_token>' + -H 'authorization: Bearer <上游access_token>' \ + -H 'content-type: application/x-www-form-urlencoded' ``` 上游返回示例: @@ -131,7 +130,7 @@ curl 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo' \ - 两个端点都在 `auth_middleware._PUBLIC_EXACT_PATHS` 白名单内(免会话鉴权,自身完成鉴权)。 - 口令门是**最低限度**的共享密钥防护,适合内网/受控环境;非每用户独立密码。 -- token 换登录信任上游 `getTokenInfo` 的鉴权结论,务必保证 `service_authorization` 与 `token_info_url` 配置正确且走内网/HTTPS。 +- token 换登录信任上游 `getTokenInfo` 的鉴权结论,务必保证 `token_info_url` 配置正确且走内网/HTTPS。前端传入的 token 会直接作为 `Authorization: Bearer ` 发给上游,因此该 token 必须来自可信入口。(`service_authorization` 配置项已不再参与请求,保留仅为兼容,后续可移除。) ## 相关代码 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/login_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/login_config.py index ad6366b9..a0b06eeb 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/login_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/login_config.py @@ -33,8 +33,8 @@ class TokenLoginConfig(BaseModel): """Token-exchange login. The frontend passes an upstream access token via the URL. The gateway calls - ``token_info_url`` with ``Authorization: `` and the - token as the ``access_token`` cookie, reads ``data.username`` from the + ``token_info_url`` with that token placed directly in the + ``Authorization: Bearer `` header, reads ``data.username`` from the response, then signs the matching local user in (auto-registering if the username is not yet in the database). """ @@ -49,7 +49,10 @@ class TokenLoginConfig(BaseModel): ) service_authorization: str = Field( default="Bearer admin", - description="Authorization header value used when calling token_info_url.", + description=( + "Deprecated/unused: the Authorization header is now built from the " + "caller's token as 'Bearer '. Retained for backward compat." + ), ) timeout_seconds: float = Field( default=10.0, diff --git a/offline-backend-20260512/backend/tests/test_token_login_upstream_call.py b/offline-backend-20260512/backend/tests/test_token_login_upstream_call.py new file mode 100644 index 00000000..b6d68b84 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_token_login_upstream_call.py @@ -0,0 +1,89 @@ +"""Regression tests for the upstream call shape of token-exchange login. + +The upstream ``getTokenInfo`` endpoint reads the bearer token from the +``Authorization`` header — NOT from an ``access_token`` cookie. These tests pin +that contract for ``_fetch_username_from_token``: + + - ``Authorization`` is exactly ``Bearer `` (the token passed in), + not the static ``service_authorization`` config value. + - No ``access_token`` cookie is sent (the old, wrong placement). +""" + +from __future__ import annotations + +import types + +import pytest + +from app.gateway.routers import auth as auth_module +from app.gateway.routers.auth import _fetch_username_from_token + + +class _FakeResponse: + status_code = 200 + + def json(self) -> dict: + return {"state": "200", "msg": "ok", "data": {"username": "b1admin"}} + + +class _CapturingClient: + """Stand-in for ``httpx.AsyncClient`` that records the post() kwargs.""" + + captured: dict = {} + + def __init__(self, *args, **kwargs) -> None: + type(self).init_kwargs = kwargs + + async def __aenter__(self) -> _CapturingClient: + return self + + async def __aexit__(self, *exc) -> None: + return None + + async def post(self, url, **kwargs): + type(self).captured = {"url": url, **kwargs} + return _FakeResponse() + + +def _patch_config(monkeypatch: pytest.MonkeyPatch) -> None: + token_login = types.SimpleNamespace( + token_info_url="https://upstream.example/getTokenInfo", + service_authorization="Bearer admin", # must NOT be used anymore + timeout_seconds=10.0, + verify_ssl=False, + ca_cert_path="", + ) + fake_cfg = types.SimpleNamespace(auth_login=types.SimpleNamespace(token_login=token_login)) + monkeypatch.setattr(auth_module, "get_app_config", lambda: fake_cfg) + monkeypatch.setattr(auth_module.httpx, "AsyncClient", _CapturingClient) + + +@pytest.mark.asyncio +async def test_sends_caller_token_as_bearer_header(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_config(monkeypatch) + + username = await _fetch_username_from_token("UPSTREAM_TOK_123") + + assert username == "b1admin" + headers = _CapturingClient.captured["headers"] + assert headers["Authorization"] == "Bearer UPSTREAM_TOK_123" + + +@pytest.mark.asyncio +async def test_does_not_use_static_service_authorization(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_config(monkeypatch) + + await _fetch_username_from_token("UPSTREAM_TOK_123") + + headers = _CapturingClient.captured["headers"] + assert headers["Authorization"] != "Bearer admin" + + +@pytest.mark.asyncio +async def test_no_access_token_cookie_is_sent(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_config(monkeypatch) + + await _fetch_username_from_token("UPSTREAM_TOK_123") + + # The token must live in the header now; no cookie param should be passed. + assert "cookies" not in _CapturingClient.captured diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index fd737e7e..adebc8ae 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -151,11 +151,12 @@ auth_login: require_password: false password: "" # token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url - # (带 Authorization 头 + access_token cookie)换出用户名,再登录;用户名不在 - # 库里时自动注册。 + # (把该 token 直接拼成 Authorization: Bearer 头)换出用户名,再登录; + # 用户名不在库里时自动注册。 token_login: enabled: true token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo" + # 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer ",此项不再生效,保留仅兼容。 service_authorization: "Bearer admin" timeout_seconds: 10 # 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致