213 lines
6.4 KiB
Python
213 lines
6.4 KiB
Python
"""外部系统认证策略。
|
|
|
|
认证只消费管理员保存的可信配置和用户加密保存的字段,不允许模型指定认证地址或请求头。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import Any, Protocol
|
|
from urllib.parse import urljoin
|
|
|
|
import httpx
|
|
|
|
|
|
class ExternalAuthError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CredentialField:
|
|
name: str
|
|
label: str
|
|
secret: bool = True
|
|
autocomplete: str = "off"
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"name": self.name,
|
|
"label": self.label,
|
|
"secret": self.secret,
|
|
"autocomplete": self.autocomplete,
|
|
}
|
|
|
|
|
|
class AuthStrategy(Protocol):
|
|
credential_fields: tuple[CredentialField, ...]
|
|
|
|
def headers(
|
|
self,
|
|
*,
|
|
client: httpx.Client,
|
|
base_url: str,
|
|
credentials: dict[str, str],
|
|
config: dict[str, Any],
|
|
) -> dict[str, str]: ...
|
|
|
|
|
|
def _required(credentials: dict[str, str], fields: tuple[CredentialField, ...]) -> None:
|
|
missing = [
|
|
field.label for field in fields if not credentials.get(field.name, "").strip()
|
|
]
|
|
if missing:
|
|
raise ExternalAuthError("请填写" + "、".join(missing))
|
|
|
|
|
|
def _nested_value(payload: Any, path: str) -> Any:
|
|
current = payload
|
|
for part in path.split("."):
|
|
if not isinstance(current, dict):
|
|
return None
|
|
current = current.get(part)
|
|
return current
|
|
|
|
|
|
def _auth_header(
|
|
config: dict[str, Any], token: str, *, default_name: str, default_template: str
|
|
) -> dict[str, str]:
|
|
name = str(config.get("auth_header_name") or default_name).strip()
|
|
template = str(config.get("auth_header_template") or default_template)
|
|
if any(char in name for char in "\r\n:") or any(
|
|
char in template for char in "\r\n"
|
|
):
|
|
raise ExternalAuthError("认证 Header 配置非法")
|
|
if "{token}" not in template:
|
|
raise ExternalAuthError("认证 Header 模板必须包含 {token}")
|
|
return {name: template.replace("{token}", token)}
|
|
|
|
|
|
class PasswordJwtAuth:
|
|
credential_fields: tuple[CredentialField, ...] = (
|
|
CredentialField("username", "用户名", secret=False, autocomplete="username"),
|
|
CredentialField("password", "密码", autocomplete="current-password"),
|
|
)
|
|
|
|
def headers(
|
|
self,
|
|
*,
|
|
client: httpx.Client,
|
|
base_url: str,
|
|
credentials: dict[str, str],
|
|
config: dict[str, Any],
|
|
) -> dict[str, str]:
|
|
_required(credentials, self.credential_fields)
|
|
login_path = str(config.get("login_path") or "/api/auth/token/").strip()
|
|
if not login_path.startswith("/") or "://" in login_path:
|
|
raise ExternalAuthError("login_path 必须是站内绝对路径")
|
|
username_field = str(config.get("username_field") or "username").strip()
|
|
password_field = str(config.get("password_field") or "password").strip()
|
|
token_field = str(config.get("token_field") or "access").strip()
|
|
try:
|
|
with client.stream(
|
|
"POST",
|
|
urljoin(base_url + "/", login_path.lstrip("/")),
|
|
json={
|
|
username_field: credentials["username"],
|
|
password_field: credentials["password"],
|
|
},
|
|
) as response:
|
|
chunks: list[bytes] = []
|
|
total = 0
|
|
for chunk in response.iter_bytes():
|
|
total += len(chunk)
|
|
if total > 65536:
|
|
raise ExternalAuthError("外部系统登录响应超过安全上限")
|
|
chunks.append(chunk)
|
|
status_code = response.status_code
|
|
content = b"".join(chunks)
|
|
except httpx.HTTPError as exc:
|
|
raise ExternalAuthError(
|
|
f"外部系统登录连接失败: {type(exc).__name__}"
|
|
) from exc
|
|
if status_code >= 400:
|
|
raise ExternalAuthError(f"外部系统登录失败(HTTP {status_code})")
|
|
try:
|
|
token = _nested_value(json.loads(content.decode("utf-8-sig")), token_field)
|
|
except (UnicodeDecodeError, ValueError):
|
|
token = None
|
|
if not isinstance(token, str) or not token:
|
|
raise ExternalAuthError(f"外部系统登录响应缺少 {token_field}")
|
|
return _auth_header(
|
|
config,
|
|
token,
|
|
default_name="Authorization",
|
|
default_template="Bearer {token}",
|
|
)
|
|
|
|
|
|
class ApiKeyAuth:
|
|
credential_fields: tuple[CredentialField, ...] = (
|
|
CredentialField("api_key", "API Key"),
|
|
)
|
|
|
|
def headers(
|
|
self,
|
|
*,
|
|
client: httpx.Client,
|
|
base_url: str,
|
|
credentials: dict[str, str],
|
|
config: dict[str, Any],
|
|
) -> dict[str, str]:
|
|
_required(credentials, self.credential_fields)
|
|
return _auth_header(
|
|
config,
|
|
credentials["api_key"],
|
|
default_name="X-API-Key",
|
|
default_template="{token}",
|
|
)
|
|
|
|
|
|
class BearerTokenAuth:
|
|
credential_fields: tuple[CredentialField, ...] = (
|
|
CredentialField("token", "Bearer Token"),
|
|
)
|
|
|
|
def headers(
|
|
self,
|
|
*,
|
|
client: httpx.Client,
|
|
base_url: str,
|
|
credentials: dict[str, str],
|
|
config: dict[str, Any],
|
|
) -> dict[str, str]:
|
|
_required(credentials, self.credential_fields)
|
|
return _auth_header(
|
|
config,
|
|
credentials["token"],
|
|
default_name="Authorization",
|
|
default_template="Bearer {token}",
|
|
)
|
|
|
|
|
|
_AUTH_STRATEGIES: dict[str, AuthStrategy] = {
|
|
"password_jwt": PasswordJwtAuth(),
|
|
"api_key": ApiKeyAuth(),
|
|
"bearer_token": BearerTokenAuth(),
|
|
}
|
|
|
|
|
|
def get_auth_strategy(auth_type: str) -> AuthStrategy:
|
|
strategy = _AUTH_STRATEGIES.get((auth_type or "").strip())
|
|
if strategy is None:
|
|
raise ExternalAuthError(f"不支持的认证方式: {auth_type}")
|
|
return strategy
|
|
|
|
|
|
def auth_catalog() -> list[dict[str, Any]]:
|
|
titles = {
|
|
"password_jwt": "用户名密码换取 Token",
|
|
"api_key": "API Key",
|
|
"bearer_token": "Bearer Token",
|
|
}
|
|
return [
|
|
{
|
|
"auth_type": key,
|
|
"title": titles[key],
|
|
"credential_fields": [
|
|
field.as_dict() for field in strategy.credential_fields
|
|
],
|
|
}
|
|
for key, strategy in _AUTH_STRATEGIES.items()
|
|
]
|