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:
1 parent
247ff86f2f
commit
e1e9be8daa
7 files changed
+112
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 会握手失败导致
|
||||
|
||||
Reference in new issue
Block a user