diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index 3cfbc0969..2c677e942 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -30,6 +30,7 @@ from app.application.configuration import get_configured_system_config from app.application.plugin.routes import register_plugin_api from app.application.plugin.runtime import get_plugin_manager from app.application.scheduling import get_scheduler +from app.application.site.auth import normalize_site_auth_params from app.application.site.mutation import SiteMutationCommand from app.application.site.query import SiteQueryService from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module @@ -790,8 +791,18 @@ def auth_site( """ if not auth_info or not auth_info.site or not auth_info.params: return _SchemaResponse(success=False, message="请输入认证站点和认证参数") - status, msg = SitesHelper().check_user(auth_info.site, auth_info.params) - get_configured_system_config().set(SystemConfigKey.UserSiteAuthParams, auth_info.model_dump()) + sites_helper = SitesHelper() + auth_params = normalize_site_auth_params( + auth_info.site, + auth_info.params, + sites_helper.get_authsites(), + ) + status, msg = sites_helper.check_user(auth_info.site, auth_params) + if status: + get_configured_system_config().set( + SystemConfigKey.UserSiteAuthParams, + {"site": auth_info.site, "params": auth_params}, + ) # 认证成功后,重新初始化插件 get_plugin_manager().init_config() get_scheduler().init_plugin_jobs() diff --git a/app/application/site/auth.py b/app/application/site/auth.py new file mode 100644 index 000000000..243373e31 --- /dev/null +++ b/app/application/site/auth.py @@ -0,0 +1,61 @@ +"""站点认证参数的宿主边界规范化。""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Optional + +from app.schemas.common import JsonData + + +def normalize_site_auth_params( + site: Optional[str], + params: Optional[Mapping[str, JsonData]], + auth_sites: Mapping[str, JsonData], +) -> dict[str, JsonData]: + """把站点字段名规范化为认证资源使用的站点前缀键名。 + + 认证资源的公共认证入口按 ``SITE_PARAMETER`` 读取参数,但旧版 API、 + CLI 配置和外部调用方可能提交资源清单中的原始字段名。保留未知字段, + 只将已知站点字段转换为资源契约键名。 + """ + normalized = dict(params or {}) + if not site or not params or not isinstance(auth_sites, Mapping): + return normalized + + site_config = auth_sites.get(site) + if not isinstance(site_config, Mapping): + return normalized + param_definitions = site_config.get("params") + if not isinstance(param_definitions, Mapping): + return normalized + + for raw_key in param_definitions: + if not isinstance(raw_key, str): + continue + env_key = f"{site.upper()}_{raw_key.upper()}" + if env_key in params: + normalized[env_key] = params[env_key] + elif raw_key in params: + normalized[env_key] = params[raw_key] + if raw_key != env_key: + normalized.pop(raw_key, None) + return normalized + + +def normalize_site_auth_config( + auth_config: Optional[Mapping[str, JsonData]], + auth_sites: Mapping[str, JsonData], +) -> Optional[dict[str, JsonData]]: + """规范化持久化的站点认证配置,兼容旧字段名。""" + if not auth_config: + return None + + site = auth_config.get("site") + params = auth_config.get("params") + if not isinstance(site, str) or not isinstance(params, Mapping): + return None + + normalized = dict(auth_config) + normalized["params"] = normalize_site_auth_params(site, params, auth_sites) + return normalized diff --git a/app/scheduler/reconcile.py b/app/scheduler/reconcile.py index 6fd4a1da5..a849a0e01 100644 --- a/app/scheduler/reconcile.py +++ b/app/scheduler/reconcile.py @@ -19,10 +19,12 @@ from app.application.scheduling import ( # noqa: E402 JobRecoveryPolicy, JobSpec, ) +from app.application.site.auth import normalize_site_auth_config from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module from app.application.workflow import WorkflowSnapshot from app.runtime.log import logger, wrap_for_plugin_instance from app.runtime.scheduling import TimerUtils +from app.runtime.settings import get_runtime_setting from app.scheduler.contract import _SchedulerOwnerBase from app.schemas.message import Message from app.schemas.types import MessageType, SystemConfigKey @@ -478,10 +480,18 @@ class SchedulerReconcileOwner(_SchedulerOwnerBase): return logger.info("用户未认证,正在尝试认证...") auth_conf = get_configured_system_config().get(SystemConfigKey.UserSiteAuthParams) - if auth_conf: - status, msg = SitesHelper().check_user(**auth_conf) + sites_helper = SitesHelper() + if get_runtime_setting("AUTH_SITE"): + status, msg = sites_helper.check_user() else: - status, msg = SitesHelper().check_user() + normalized_auth_conf = normalize_site_auth_config( + auth_conf, + sites_helper.get_authsites(), + ) + if normalized_auth_conf: + status, msg = sites_helper.check_user(**normalized_auth_conf) + else: + status, msg = sites_helper.check_user() if status: self._auth_count = 0 logger.info(f"{msg} 用户认证成功") diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index 3d4093bbd..1f7f95216 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -39,6 +39,7 @@ from app.application.outbox import configure_outbox_dispatcher from app.application.plugin.runtime import get_existing_plugin_manager from app.application.security.url import close_image_proxy_block_log_coalescer from app.application.service import configure_service_directory, reset_service_directory +from app.application.site.auth import normalize_site_auth_config from app.command import CommandChain from app.db.session import ( close_database, @@ -460,7 +461,18 @@ def user_auth(): if sites_helper.auth_level >= 2: return auth_conf = get_configured_system_config().get(SystemConfigKey.UserSiteAuthParams) - status, msg = sites_helper.check_user(**auth_conf) if auth_conf else sites_helper.check_user() + if get_runtime_setting("AUTH_SITE"): + status, msg = sites_helper.check_user() + else: + normalized_auth_conf = normalize_site_auth_config( + auth_conf, + sites_helper.get_authsites(), + ) + status, msg = ( + sites_helper.check_user(**normalized_auth_conf) + if normalized_auth_conf + else sites_helper.check_user() + ) if status: logger.info(f"{msg} 用户认证成功") else: diff --git a/scripts/local_setup.py b/scripts/local_setup.py index eed66ecc0..69af0d7e8 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -2594,13 +2594,19 @@ def _apply_local_system_config_inner(config_payload: dict[str, Any]) -> None: and site_auth_item.get("site") and site_auth_item.get("params") ): - system_config_service.set(SystemConfigKey.UserSiteAuthParams, site_auth_item) try: + from app.application.site.auth import normalize_site_auth_params from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module - status, msg = SitesHelper().check_user( - site_auth_item.get("site"), site_auth_item.get("params") + sites_helper = SitesHelper() + auth_params = normalize_site_auth_params( + site_auth_item.get("site"), + site_auth_item.get("params"), + sites_helper.get_authsites(), ) + site_auth_item = {**site_auth_item, "params": auth_params} + system_config_service.set(SystemConfigKey.UserSiteAuthParams, site_auth_item) + status, msg = sites_helper.check_user(site_auth_item.get("site"), auth_params) if status: print_step(f"站点认证校验成功:{msg}") else: diff --git a/tests/test_site_auth.py b/tests/test_site_auth.py new file mode 100644 index 000000000..67dc6a6cd --- /dev/null +++ b/tests/test_site_auth.py @@ -0,0 +1,166 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from app.api.endpoints import site as site_endpoint +from app.application.site.auth import ( + normalize_site_auth_config, + normalize_site_auth_params, +) +from app.scheduler import reconcile as scheduler_reconcile +from app.schemas.site import SiteAuth +from app.schemas.types import SystemConfigKey +from app.startup.initializers import modules as module_initializer + +HAIDAN_AUTH_SITES = { + "haidan": { + "params": { + "id": {"name": "用户ID"}, + "passkey": {"name": "密钥"}, + } + } +} + + +def test_normalize_site_auth_params_accepts_resource_field_names() -> None: + """站点原始字段名应转换为认证资源读取的环境变量式键名。""" + assert normalize_site_auth_params( + "haidan", + {"id": 63632, "passkey": "secret"}, + HAIDAN_AUTH_SITES, + ) == {"HAIDAN_ID": 63632, "HAIDAN_PASSKEY": "secret"} + + +def test_normalize_site_auth_config_preserves_unknown_fields() -> None: + """认证配置中的扩展字段不能因参数规范化而丢失。""" + assert normalize_site_auth_config( + { + "site": "haidan", + "params": {"id": 63632, "passkey": "secret", "extra": "value"}, + }, + HAIDAN_AUTH_SITES, + ) == { + "site": "haidan", + "params": { + "HAIDAN_ID": 63632, + "HAIDAN_PASSKEY": "secret", + "extra": "value", + }, + } + + +def _patch_auth_endpoint(monkeypatch, helper: Mock, config: Mock) -> None: + """替换认证端点的外部运行时依赖。""" + monkeypatch.setattr(site_endpoint, "SitesHelper", Mock(return_value=helper)) + monkeypatch.setattr(site_endpoint, "get_configured_system_config", Mock(return_value=config)) + monkeypatch.setattr(site_endpoint, "get_plugin_manager", Mock(return_value=Mock())) + monkeypatch.setattr(site_endpoint, "get_scheduler", Mock(return_value=Mock())) + monkeypatch.setattr(site_endpoint, "init_commands", Mock()) + monkeypatch.setattr(site_endpoint, "register_plugin_api", Mock()) + + +def test_auth_site_normalizes_parameters_before_check_and_persist(monkeypatch) -> None: + """认证端点应以规范化键名调用资源并只持久化成功配置。""" + helper = Mock() + helper.get_authsites.return_value = HAIDAN_AUTH_SITES + helper.check_user.return_value = (True, "haidan") + config = Mock() + _patch_auth_endpoint(monkeypatch, helper, config) + + response = site_endpoint.auth_site( + SiteAuth(site="haidan", params={"id": 63632, "passkey": "secret"}), + SimpleNamespace(), + ) + + assert response.success is True + helper.check_user.assert_called_once_with( + "haidan", + {"HAIDAN_ID": 63632, "HAIDAN_PASSKEY": "secret"}, + ) + config.set.assert_called_once_with( + SystemConfigKey.UserSiteAuthParams, + { + "site": "haidan", + "params": {"HAIDAN_ID": 63632, "HAIDAN_PASSKEY": "secret"}, + }, + ) + + +def test_auth_site_does_not_persist_failed_parameters(monkeypatch) -> None: + """失败认证不能覆盖已有配置并屏蔽环境变量认证。""" + helper = Mock() + helper.get_authsites.return_value = HAIDAN_AUTH_SITES + helper.check_user.return_value = (False, "api parameter loss") + config = Mock() + _patch_auth_endpoint(monkeypatch, helper, config) + + response = site_endpoint.auth_site( + SiteAuth(site="haidan", params={"id": 63632, "passkey": "secret"}), + SimpleNamespace(), + ) + + assert response.success is False + config.set.assert_not_called() + + +def test_startup_auth_prefers_environment_configuration(monkeypatch) -> None: + """设置环境变量时启动认证应从环境读取,而不是使用旧持久化配置。""" + helper = Mock() + helper.auth_level = 1 + helper.get_authsites.return_value = HAIDAN_AUTH_SITES + helper.check_user.return_value = (True, "haidan") + config = Mock() + config.get.return_value = { + "site": "haidan", + "params": {"id": 63632, "passkey": "stale"}, + } + + monkeypatch.setattr(module_initializer, "SitesHelper", Mock(return_value=helper)) + monkeypatch.setattr( + module_initializer, + "get_configured_system_config", + Mock(return_value=config), + ) + monkeypatch.setattr(module_initializer, "get_runtime_setting", lambda key: "haidan") + + module_initializer.user_auth() + + helper.check_user.assert_called_once_with() + + +def test_scheduler_auth_prefers_environment_configuration(monkeypatch) -> None: + """定时认证应与启动认证使用相同的环境变量优先级。""" + helper = Mock() + helper.auth_level = 1 + helper.get_authsites.return_value = HAIDAN_AUTH_SITES + helper.check_user.return_value = (True, "haidan") + config = Mock() + config.get.return_value = { + "site": "haidan", + "params": {"id": 63632, "passkey": "stale"}, + } + scheduler_config = SimpleNamespace(site_link="") + owner = scheduler_reconcile.SchedulerReconcileOwner() + owner._auth_count = 0 + owner._auth_message = False + owner._auth_plugin_routes_pending = False + owner._scheduler_services = Mock(return_value=Mock()) + owner.init_plugin_jobs = Mock() + + monkeypatch.setattr(scheduler_reconcile, "SitesHelper", Mock(return_value=helper)) + monkeypatch.setattr( + scheduler_reconcile, + "get_configured_system_config", + Mock(return_value=config), + ) + monkeypatch.setattr( + scheduler_reconcile, + "get_scheduler_runtime_config", + Mock(return_value=scheduler_config), + ) + monkeypatch.setattr(scheduler_reconcile, "get_runtime_setting", lambda key: "haidan") + monkeypatch.setattr(scheduler_reconcile, "get_plugin_manager", Mock(return_value=Mock())) + monkeypatch.setattr(scheduler_reconcile, "register_plugin_api", Mock()) + + owner.user_auth() + + helper.check_user.assert_called_once_with()