fix(auth): token 登录将令牌放入 Authorization: Bearer 头

token 换登录调上游 getTokenInfo 时参数传错:原来发静态头
Authorization: Bearer admin + 令牌放 access_token cookie。上游实际
从 Authorization: Bearer <token> 头读取令牌。

- 鉴权头改为 "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) <noreply@anthropic.com>
This commit is contained in:
AdministratorandClaude Opus 4.8 committed 2026-06-02 19:33:38 +08:00
1 parent 247ff86f2f
commit e1e9be8daa
7 files changed
+112 -20

No files matched your search

+1 -1
View File
@@ -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=<shared-secret>`; 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: <service_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 <token>` 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`.
@@ -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: <service_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 <token>`` 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
+3 -2
View File
@@ -195,11 +195,12 @@ auth_login:
require_password: false
password: ""
# token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url
# (带 Authorization 头 + access_token cookie)换出用户名,再登录;用户名不在
# 库里时自动注册。
# (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录;
# 用户名不在库里时自动注册。
token_login:
enabled: false
token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo"
# 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。
service_authorization: "Bearer admin"
timeout_seconds: 10
# 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致
@@ -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 <token>` 头**,上游从该头读取令牌(不再用 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>` 发给上游,因此该 token 必须来自可信入口。(`service_authorization` 配置项已不再参与请求,保留仅为兼容,后续可移除。)
## 相关代码
@@ -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: <service_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 <token>`` 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 <token>'. Retained for backward compat."
),
)
timeout_seconds: float = Field(
default=10.0,
@@ -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 <caller-token>`` (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
+3 -2
View File
@@ -151,11 +151,12 @@ auth_login:
require_password: false
password: ""
# token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url
# (带 Authorization 头 + access_token cookie)换出用户名,再登录;用户名不在
# 库里时自动注册。
# (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录;
# 用户名不在库里时自动注册。
token_login:
enabled: true
token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo"
# 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。
service_authorization: "Bearer admin"
timeout_seconds: 10
# 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致