mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-10 18:06:48 +08:00
fix(auth): restore API token compatibility
This commit is contained in:
@@ -113,9 +113,10 @@ def _decode_token(token: str | None, purpose: str) -> TokenPayload:
|
|||||||
|
|
||||||
def _get_api_token(
|
def _get_api_token(
|
||||||
token_query: Annotated[str | None, Security(api_token_query)] = None,
|
token_query: Annotated[str | None, Security(api_token_query)] = None,
|
||||||
|
key_header: Annotated[str | None, Security(api_key_header)] = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""从 URL 查询参数读取兼容 API Token。"""
|
"""优先从请求头、其次从查询参数读取兼容 API Token。"""
|
||||||
return token_query
|
return key_header or token_query
|
||||||
|
|
||||||
|
|
||||||
def _get_api_key(
|
def _get_api_key(
|
||||||
@@ -314,7 +315,7 @@ def _verify_key(key: str | None, expected_key: str, key_type: str) -> str:
|
|||||||
def verify_apitoken(
|
def verify_apitoken(
|
||||||
token: Annotated[str | None, Security(_get_api_token)],
|
token: Annotated[str | None, Security(_get_api_token)],
|
||||||
) -> str:
|
) -> str:
|
||||||
"""校验 URL 查询参数中的兼容 API Token。"""
|
"""校验请求头或 URL 查询参数中的兼容 API Token。"""
|
||||||
value = _verify_key(token, get_runtime_setting("API_TOKEN"), "token")
|
value = _verify_key(token, get_runtime_setting("API_TOKEN"), "token")
|
||||||
validate_api_credential_identity()
|
validate_api_credential_identity()
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ async def user_message(
|
|||||||
_: _SchemaTokenPayload = Depends(verify_apitoken),
|
_: _SchemaTokenPayload = Depends(verify_apitoken),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
用户消息响应,配置请求中需要添加参数:token=API_TOKEN&source=消息配置名
|
用户消息响应;推荐通过 X-API-KEY 请求头传递 API_TOKEN,查询参数 token 仅保留兼容。
|
||||||
"""
|
"""
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class _MessageIngressRequest:
|
|||||||
|
|
||||||
url: str
|
url: str
|
||||||
payload: Mapping[str, Any]
|
payload: Mapping[str, Any]
|
||||||
|
headers: Mapping[str, str]
|
||||||
source: str
|
source: str
|
||||||
timeout: float
|
timeout: float
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ class MessageIngressPort(Protocol):
|
|||||||
url: str,
|
url: str,
|
||||||
payload: Mapping[str, Any],
|
payload: Mapping[str, Any],
|
||||||
*,
|
*,
|
||||||
|
headers: Mapping[str, str],
|
||||||
timeout: float,
|
timeout: float,
|
||||||
) -> Optional[int]:
|
) -> Optional[int]:
|
||||||
"""同步投递 payload,并返回 HTTP 状态码或 None。"""
|
"""同步投递 payload,并返回 HTTP 状态码或 None。"""
|
||||||
@@ -40,6 +42,7 @@ class MessageIngressPort(Protocol):
|
|||||||
url: str,
|
url: str,
|
||||||
payload: Mapping[str, Any],
|
payload: Mapping[str, Any],
|
||||||
*,
|
*,
|
||||||
|
headers: Mapping[str, str],
|
||||||
timeout: float,
|
timeout: float,
|
||||||
) -> Optional[int]:
|
) -> Optional[int]:
|
||||||
"""异步投递 payload,并返回 HTTP 状态码或 None。"""
|
"""异步投递 payload,并返回 HTTP 状态码或 None。"""
|
||||||
@@ -78,14 +81,17 @@ def _message_ingress_snapshot() -> MessageIngressPort:
|
|||||||
|
|
||||||
|
|
||||||
def build_message_ingress_url(source: str | None) -> str:
|
def build_message_ingress_url(source: str | None) -> str:
|
||||||
"""按当前运行配置构造安全编码的本地消息入口 URL。"""
|
"""构造仅含非敏感来源参数的本地消息入口 URL。"""
|
||||||
query = {"token": get_runtime_setting('API_TOKEN')}
|
query: dict[str, str] = {}
|
||||||
if source:
|
if source:
|
||||||
query["source"] = source
|
query["source"] = source
|
||||||
return (
|
base_url = f"http://127.0.0.1:{get_runtime_setting('PORT')}/api/v1/message"
|
||||||
f"http://127.0.0.1:{get_runtime_setting('PORT')}/api/v1/message?"
|
return f"{base_url}?{urlencode(query)}" if query else base_url
|
||||||
f"{urlencode(query)}"
|
|
||||||
)
|
|
||||||
|
def build_message_ingress_headers() -> dict[str, str]:
|
||||||
|
"""把本地回环凭据放入请求头,避免访问日志记录明文 Token。"""
|
||||||
|
return {"X-API-KEY": str(get_runtime_setting("API_TOKEN") or "")}
|
||||||
|
|
||||||
|
|
||||||
def _message_ingress_request(
|
def _message_ingress_request(
|
||||||
@@ -97,6 +103,7 @@ def _message_ingress_request(
|
|||||||
return _MessageIngressRequest(
|
return _MessageIngressRequest(
|
||||||
url=build_message_ingress_url(source),
|
url=build_message_ingress_url(source),
|
||||||
payload=dict(payload),
|
payload=dict(payload),
|
||||||
|
headers=build_message_ingress_headers(),
|
||||||
source=source or "-",
|
source=source or "-",
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
@@ -142,6 +149,7 @@ def forward_message_to_host(
|
|||||||
status_code = _message_ingress_snapshot().post(
|
status_code = _message_ingress_snapshot().post(
|
||||||
request.url,
|
request.url,
|
||||||
request.payload,
|
request.payload,
|
||||||
|
headers=request.headers,
|
||||||
timeout=request.timeout,
|
timeout=request.timeout,
|
||||||
)
|
)
|
||||||
return _message_ingress_confirmed(request, status_code)
|
return _message_ingress_confirmed(request, status_code)
|
||||||
@@ -161,6 +169,7 @@ async def async_forward_message_to_host(
|
|||||||
status_code = await _message_ingress_snapshot().async_post(
|
status_code = await _message_ingress_snapshot().async_post(
|
||||||
request.url,
|
request.url,
|
||||||
request.payload,
|
request.payload,
|
||||||
|
headers=request.headers,
|
||||||
timeout=request.timeout,
|
timeout=request.timeout,
|
||||||
)
|
)
|
||||||
return _message_ingress_confirmed(request, status_code)
|
return _message_ingress_confirmed(request, status_code)
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ class AuthUserRepository(Protocol):
|
|||||||
def get_by_id(self, user_id: int) -> Optional[AuthUser]:
|
def get_by_id(self, user_id: int) -> Optional[AuthUser]:
|
||||||
"""按 ID 查询用户。"""
|
"""按 ID 查询用户。"""
|
||||||
|
|
||||||
|
def get_active_superuser(self) -> Optional[AuthUser]:
|
||||||
|
"""返回按稳定顺序选出的启用超级管理员。"""
|
||||||
|
|
||||||
|
|
||||||
class AuthPasskeyRepository(Protocol):
|
class AuthPasskeyRepository(Protocol):
|
||||||
"""认证提供方查询端口。"""
|
"""认证提供方查询端口。"""
|
||||||
@@ -195,7 +198,9 @@ class AuthService:
|
|||||||
|
|
||||||
def build_superuser_token_payload(self) -> _SchemaTokenPayload:
|
def build_superuser_token_payload(self) -> _SchemaTokenPayload:
|
||||||
"""从持久化用户和站点认证状态构造超级用户令牌载荷。"""
|
"""从持久化用户和站点认证状态构造超级用户令牌载荷。"""
|
||||||
configured_name = get_chain_runtime_config_snapshot().superuser
|
configured_name = str(
|
||||||
|
get_chain_runtime_config_snapshot().superuser or ""
|
||||||
|
).strip()
|
||||||
if (
|
if (
|
||||||
self._superuser_binding_id is not None
|
self._superuser_binding_id is not None
|
||||||
and configured_name == self._superuser_binding_name
|
and configured_name == self._superuser_binding_name
|
||||||
@@ -203,12 +208,22 @@ class AuthService:
|
|||||||
# 配置保存用户名;持久化 ID 保证管理员改名不会让管理员级集成失效。
|
# 配置保存用户名;持久化 ID 保证管理员改名不会让管理员级集成失效。
|
||||||
user = self._users.get_by_id(self._superuser_binding_id)
|
user = self._users.get_by_id(self._superuser_binding_id)
|
||||||
else:
|
else:
|
||||||
user = self._users.get_by_name(configured_name)
|
user = (
|
||||||
|
self._users.get_by_name(configured_name)
|
||||||
|
if configured_name
|
||||||
|
else self._users.get_active_superuser()
|
||||||
|
)
|
||||||
if user:
|
if user:
|
||||||
self._superuser_binding_name = configured_name
|
self._superuser_binding_name = configured_name
|
||||||
self._superuser_binding_id = user.id
|
self._superuser_binding_id = user.id
|
||||||
if not user or not user.is_active or not user.is_superuser:
|
if not user or not user.is_active or not user.is_superuser:
|
||||||
raise PermissionError("用户权限不足")
|
if not configured_name:
|
||||||
|
raise PermissionError(
|
||||||
|
"未配置 SUPERUSER,且数据库中没有可用超级管理员"
|
||||||
|
)
|
||||||
|
raise PermissionError(
|
||||||
|
"SUPERUSER 对应用户不存在、未启用或非超级管理员"
|
||||||
|
)
|
||||||
return _SchemaTokenPayload(
|
return _SchemaTokenPayload(
|
||||||
sub=user.id,
|
sub=user.id,
|
||||||
username=user.name,
|
username=user.name,
|
||||||
|
|||||||
+14
-9
@@ -17,9 +17,12 @@ import click
|
|||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
from app.application.backup import BackupArtifact
|
from app.application.backup import BackupArtifact
|
||||||
from app.application.configuration import get_runtime_settings
|
|
||||||
from app.runtime.config import Settings
|
from app.runtime.config import Settings
|
||||||
from app.runtime.settings import get_runtime_setting
|
from app.runtime.settings import (
|
||||||
|
get_runtime_setting,
|
||||||
|
has_runtime_setting,
|
||||||
|
update_runtime_setting,
|
||||||
|
)
|
||||||
from app.runtime.state import SystemHelper
|
from app.runtime.state import SystemHelper
|
||||||
from app.runtime.version import get_app_version, get_frontend_version
|
from app.runtime.version import get_app_version, get_frontend_version
|
||||||
from app.startup.composition.database import build_database_governance
|
from app.startup.composition.database import build_database_governance
|
||||||
@@ -801,7 +804,9 @@ def _ensure_local_api_token() -> bool:
|
|||||||
if get_runtime_setting("API_TOKEN") and len(str(get_runtime_setting("API_TOKEN")).strip()) >= 16:
|
if get_runtime_setting("API_TOKEN") and len(str(get_runtime_setting("API_TOKEN")).strip()) >= 16:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
result, message = get_runtime_settings().update("API_TOKEN", get_runtime_setting("API_TOKEN") or "")
|
result, message = update_runtime_setting(
|
||||||
|
"API_TOKEN", get_runtime_setting("API_TOKEN") or ""
|
||||||
|
)
|
||||||
if result is False:
|
if result is False:
|
||||||
raise click.ClickException(message or "初始化 API_TOKEN 失败")
|
raise click.ClickException(message or "初始化 API_TOKEN 失败")
|
||||||
return result is True
|
return result is True
|
||||||
@@ -1324,7 +1329,7 @@ def config_path() -> None:
|
|||||||
@click.option("--show-secrets", is_flag=True, help="显示敏感配置原文")
|
@click.option("--show-secrets", is_flag=True, help="显示敏感配置原文")
|
||||||
def config_list(show_secrets: bool) -> None:
|
def config_list(show_secrets: bool) -> None:
|
||||||
"""列出当前配置"""
|
"""列出当前配置"""
|
||||||
values = get_runtime_settings().snapshot()
|
values = {key: get_runtime_setting(key) for key in Settings.model_fields}
|
||||||
for key in sorted(values):
|
for key in sorted(values):
|
||||||
click.echo(f"{key}={_format_value(_mask_value(key, values[key], show_secrets))}")
|
click.echo(f"{key}={_format_value(_mask_value(key, values[key], show_secrets))}")
|
||||||
|
|
||||||
@@ -1334,9 +1339,9 @@ def config_list(show_secrets: bool) -> None:
|
|||||||
def config_get(key: str) -> None:
|
def config_get(key: str) -> None:
|
||||||
"""读取单个配置项"""
|
"""读取单个配置项"""
|
||||||
setting_fields = Settings.model_fields.keys()
|
setting_fields = Settings.model_fields.keys()
|
||||||
if key not in setting_fields and not get_runtime_settings().contains(key):
|
if key not in setting_fields and not has_runtime_setting(key):
|
||||||
raise click.ClickException(f"配置项不存在:{key}")
|
raise click.ClickException(f"配置项不存在:{key}")
|
||||||
click.echo(_format_value(get_runtime_settings().get(key)))
|
click.echo(_format_value(get_runtime_setting(key)))
|
||||||
|
|
||||||
|
|
||||||
@config.command("set", context_settings=CONTEXT_SETTINGS)
|
@config.command("set", context_settings=CONTEXT_SETTINGS)
|
||||||
@@ -1344,7 +1349,7 @@ def config_get(key: str) -> None:
|
|||||||
@click.argument("value")
|
@click.argument("value")
|
||||||
def config_set(key: str, value: str) -> None:
|
def config_set(key: str, value: str) -> None:
|
||||||
"""写入单个配置项"""
|
"""写入单个配置项"""
|
||||||
result, message = get_runtime_settings().update(key, value)
|
result, message = update_runtime_setting(key, value)
|
||||||
if result is False:
|
if result is False:
|
||||||
raise click.ClickException(message or f"配置项更新失败:{key}")
|
raise click.ClickException(message or f"配置项更新失败:{key}")
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -1376,7 +1381,7 @@ def config_keys(pattern: Optional[str], show_current: bool, show_secrets: bool)
|
|||||||
if pattern and pattern.lower() not in key.lower():
|
if pattern and pattern.lower() not in key.lower():
|
||||||
continue
|
continue
|
||||||
default_value = _field_default(field)
|
default_value = _field_default(field)
|
||||||
current_value = get_runtime_settings().get(key, default_value)
|
current_value = get_runtime_setting(key, default_value)
|
||||||
rows.append(
|
rows.append(
|
||||||
(
|
(
|
||||||
key,
|
key,
|
||||||
@@ -1408,7 +1413,7 @@ def config_describe(key: str, show_secrets: bool) -> None:
|
|||||||
raise click.ClickException(f"配置项不存在:{key}")
|
raise click.ClickException(f"配置项不存在:{key}")
|
||||||
|
|
||||||
default_value = _field_default(field)
|
default_value = _field_default(field)
|
||||||
current_value = get_runtime_settings().get(key, default_value)
|
current_value = get_runtime_setting(key, default_value)
|
||||||
click.echo(f"Key: {key}")
|
click.echo(f"Key: {key}")
|
||||||
click.echo(f"Type: {_annotation_name(field.annotation)}")
|
click.echo(f"Type: {_annotation_name(field.annotation)}")
|
||||||
click.echo(f"Default: {_format_value(_mask_value(key, default_value, show_secrets))}")
|
click.echo(f"Default: {_format_value(_mask_value(key, default_value, show_secrets))}")
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ class SqlAlchemyUserRepository(UserRepository):
|
|||||||
model = self._oper.get_by_id(user_id)
|
model = self._oper.get_by_id(user_id)
|
||||||
return _to_snapshot(model) if model else None
|
return _to_snapshot(model) if model else None
|
||||||
|
|
||||||
|
def get_active_superuser(self) -> Optional[UserSnapshot]:
|
||||||
|
"""按主键顺序返回首个启用的超级管理员快照。"""
|
||||||
|
session = cast(Session, self._session)
|
||||||
|
model = session.execute(
|
||||||
|
select(User)
|
||||||
|
.where(User.is_active.is_(True), User.is_superuser.is_(True))
|
||||||
|
.order_by(User.id)
|
||||||
|
.limit(1)
|
||||||
|
).scalars().first()
|
||||||
|
return _to_snapshot(model) if model else None
|
||||||
|
|
||||||
async def async_has_users(self) -> bool:
|
async def async_has_users(self) -> bool:
|
||||||
"""使用最小列查询判断数据库中是否已有用户。"""
|
"""使用最小列查询判断数据库中是否已有用户。"""
|
||||||
session = self._require_async_session()
|
session = self._require_async_session()
|
||||||
@@ -225,6 +236,11 @@ class TransactionalUserRepository(ChainUserRepository):
|
|||||||
with self._sync_session() as session:
|
with self._sync_session() as session:
|
||||||
return SqlAlchemyUserRepository(session).get_by_id(user_id)
|
return SqlAlchemyUserRepository(session).get_by_id(user_id)
|
||||||
|
|
||||||
|
def get_active_superuser(self) -> Optional[UserSnapshot]:
|
||||||
|
"""在独立会话中返回首个启用的超级管理员快照。"""
|
||||||
|
with self._sync_session() as session:
|
||||||
|
return SqlAlchemyUserRepository(session).get_active_superuser()
|
||||||
|
|
||||||
def get_auth_by_name(self, name: str) -> Optional[UserAuthSnapshot]:
|
def get_auth_by_name(self, name: str) -> Optional[UserAuthSnapshot]:
|
||||||
"""按用户名读取认证凭据快照。"""
|
"""按用户名读取认证凭据快照。"""
|
||||||
with self._sync_session() as session:
|
with self._sync_session() as session:
|
||||||
|
|||||||
@@ -128,10 +128,14 @@ class _MessageIngressAdapter:
|
|||||||
url: str,
|
url: str,
|
||||||
payload: Mapping[str, Any],
|
payload: Mapping[str, Any],
|
||||||
*,
|
*,
|
||||||
|
headers: Mapping[str, str],
|
||||||
timeout: float,
|
timeout: float,
|
||||||
) -> Optional[int]:
|
) -> Optional[int]:
|
||||||
"""同步投递消息,关闭响应后返回状态码。"""
|
"""同步投递消息,关闭响应后返回状态码。"""
|
||||||
response = RequestUtils(timeout=timeout).post_res( # type: ignore[arg-type]
|
response = RequestUtils(
|
||||||
|
timeout=timeout, # type: ignore[arg-type]
|
||||||
|
headers=dict(headers),
|
||||||
|
).post_res(
|
||||||
url,
|
url,
|
||||||
json=dict(payload),
|
json=dict(payload),
|
||||||
)
|
)
|
||||||
@@ -150,11 +154,13 @@ class _MessageIngressAdapter:
|
|||||||
url: str,
|
url: str,
|
||||||
payload: Mapping[str, Any],
|
payload: Mapping[str, Any],
|
||||||
*,
|
*,
|
||||||
|
headers: Mapping[str, str],
|
||||||
timeout: float,
|
timeout: float,
|
||||||
) -> Optional[int]:
|
) -> Optional[int]:
|
||||||
"""异步投递消息,关闭响应后返回状态码。"""
|
"""异步投递消息,关闭响应后返回状态码。"""
|
||||||
response = await AsyncRequestUtils(
|
response = await AsyncRequestUtils(
|
||||||
timeout=timeout # type: ignore[arg-type]
|
timeout=timeout, # type: ignore[arg-type]
|
||||||
|
headers=dict(headers),
|
||||||
).post_res(
|
).post_res(
|
||||||
url,
|
url,
|
||||||
json=dict(payload),
|
json=dict(payload),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.adapters.web.security.access import (
|
|||||||
from app.application.configuration import get_configured_system_config
|
from app.application.configuration import get_configured_system_config
|
||||||
from app.application.security.auth import (
|
from app.application.security.auth import (
|
||||||
AuthService,
|
AuthService,
|
||||||
|
AuthUserRepository,
|
||||||
build_superuser_token_payload,
|
build_superuser_token_payload,
|
||||||
configure_auth_service,
|
configure_auth_service,
|
||||||
reset_auth_service,
|
reset_auth_service,
|
||||||
@@ -30,6 +31,8 @@ from app.db.adapters.user import SqlAlchemyUserRepository
|
|||||||
from app.db.oper.passkey import PassKeyOper
|
from app.db.oper.passkey import PassKeyOper
|
||||||
from app.db.oper.systemconfig import SystemConfigOper
|
from app.db.oper.systemconfig import SystemConfigOper
|
||||||
from app.runtime.cache import TTLCache
|
from app.runtime.cache import TTLCache
|
||||||
|
from app.runtime.log import logger
|
||||||
|
from app.runtime.settings import get_runtime_setting, update_runtime_setting
|
||||||
from app.startup.composition.context import (
|
from app.startup.composition.context import (
|
||||||
RepositoryFactory,
|
RepositoryFactory,
|
||||||
StandaloneRepositoryFactory,
|
StandaloneRepositoryFactory,
|
||||||
@@ -48,8 +51,27 @@ class SecurityComposition:
|
|||||||
passkey: StandaloneRepositoryFactory
|
passkey: StandaloneRepositoryFactory
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_superuser_setting(users: AuthUserRepository) -> None:
|
||||||
|
"""用现有数据库管理员补全 V2 升级后缺失的 SUPERUSER。"""
|
||||||
|
if str(get_runtime_setting("SUPERUSER") or "").strip():
|
||||||
|
return
|
||||||
|
user = users.get_active_superuser()
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
success, message = update_runtime_setting("SUPERUSER", user.name)
|
||||||
|
if success is False:
|
||||||
|
logger.warning(
|
||||||
|
f"检测到数据库超级管理员 {user.name},但自动补全 SUPERUSER 失败:"
|
||||||
|
f"{message or '未知错误'}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
logger.info(f"已根据数据库超级管理员自动补全 SUPERUSER:{user.name}")
|
||||||
|
|
||||||
|
|
||||||
def configure_security_services() -> SecurityComposition:
|
def configure_security_services() -> SecurityComposition:
|
||||||
"""构造并登记认证、用户查询和 PassKey 服务。"""
|
"""构造并登记认证、用户查询和 PassKey 服务。"""
|
||||||
|
users = build_transactional_user_repository()
|
||||||
|
_backfill_superuser_setting(users)
|
||||||
configure_user_lookups(
|
configure_user_lookups(
|
||||||
by_id=lambda user_id: build_transactional_user_repository().get_by_id(user_id),
|
by_id=lambda user_id: build_transactional_user_repository().get_by_id(user_id),
|
||||||
by_name=lambda username: build_transactional_user_repository().get_by_name(username),
|
by_name=lambda username: build_transactional_user_repository().get_by_name(username),
|
||||||
@@ -57,7 +79,7 @@ def configure_security_services() -> SecurityComposition:
|
|||||||
)
|
)
|
||||||
configure_auth_service(
|
configure_auth_service(
|
||||||
AuthService(
|
AuthService(
|
||||||
users=build_transactional_user_repository(),
|
users=users,
|
||||||
config=get_configured_system_config(),
|
config=get_configured_system_config(),
|
||||||
passkeys=PassKeyOper(),
|
passkeys=PassKeyOper(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -360,6 +360,12 @@ def test_standalone_api_credential_revalidates_current_identity(monkeypatch, dep
|
|||||||
assert exc_info.value.headers == {"WWW-Authenticate": "Bearer"}
|
assert exc_info.value.headers == {"WWW-Authenticate": "Bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_token_reader_prefers_header_and_preserves_query_compatibility():
|
||||||
|
"""兼容 Token 依赖应优先使用请求头,同时继续接受旧查询参数。"""
|
||||||
|
assert access._get_api_token("query-token", "header-token") == "header-token"
|
||||||
|
assert access._get_api_token("query-token", None) == "query-token"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("endpoint", "credential"),
|
("endpoint", "credential"),
|
||||||
@@ -475,6 +481,52 @@ def test_superuser_payload_provider_rejects_inactive_user(monkeypatch):
|
|||||||
service.build_superuser_token_payload()
|
service.build_superuser_token_payload()
|
||||||
|
|
||||||
|
|
||||||
|
def test_superuser_payload_provider_falls_back_to_database_admin(monkeypatch):
|
||||||
|
"""V2 升级后 SUPERUSER 为空时,API 凭据应绑定现有启用管理员。"""
|
||||||
|
user = _user(active=True, superuser=True)
|
||||||
|
users = SimpleNamespace(
|
||||||
|
get_by_name=Mock(),
|
||||||
|
get_active_superuser=Mock(return_value=user),
|
||||||
|
)
|
||||||
|
service = AuthService(
|
||||||
|
users=users,
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
passkeys=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
auth_service_module,
|
||||||
|
"get_chain_runtime_config_snapshot",
|
||||||
|
lambda: SimpleNamespace(superuser=""),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = service.build_superuser_token_payload()
|
||||||
|
|
||||||
|
assert payload.sub == user.id
|
||||||
|
assert payload.username == user.name
|
||||||
|
users.get_by_name.assert_not_called()
|
||||||
|
users.get_active_superuser.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_superuser_payload_provider_explains_missing_binding(monkeypatch):
|
||||||
|
"""配置和数据库都没有管理员时,应返回可操作的认证失败原因。"""
|
||||||
|
service = AuthService(
|
||||||
|
users=SimpleNamespace(get_active_superuser=Mock(return_value=None)),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
passkeys=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
auth_service_module,
|
||||||
|
"get_chain_runtime_config_snapshot",
|
||||||
|
lambda: SimpleNamespace(superuser=""),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
PermissionError,
|
||||||
|
match="未配置 SUPERUSER,且数据库中没有可用超级管理员",
|
||||||
|
):
|
||||||
|
service.build_superuser_token_payload()
|
||||||
|
|
||||||
|
|
||||||
def test_auth_service_reads_user_by_id_and_accepts_current_token_identity():
|
def test_auth_service_reads_user_by_id_and_accepts_current_token_identity():
|
||||||
"""认证服务按稳定用户 ID 查询,并接受与当前账号一致的令牌声明。"""
|
"""认证服务按稳定用户 ID 查询,并接受与当前账号一致的令牌声明。"""
|
||||||
user = _user()
|
user = _user()
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""离线配置 CLI 的运行时端口契约测试。"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from app import cli as cli_module
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_get_works_without_web_lifespan(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""配置读取必须使用启动前可用的 runtime 端口,不能依赖 Web 组合根。"""
|
||||||
|
monkeypatch.setattr(cli_module, "has_runtime_setting", lambda _key: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_module,
|
||||||
|
"get_runtime_setting",
|
||||||
|
lambda key, *_args: "admin" if key == "SUPERUSER" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli_module.cli,
|
||||||
|
["config", "get", "SUPERUSER"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.output == "admin\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_set_works_without_web_lifespan(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""配置写入必须走离线可用的 runtime 更新端口。"""
|
||||||
|
updates: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def update_setting(key: str, value: str) -> tuple[bool, str]:
|
||||||
|
"""记录离线配置写入。"""
|
||||||
|
updates.append((key, value))
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_module,
|
||||||
|
"update_runtime_setting",
|
||||||
|
update_setting,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_module,
|
||||||
|
"_managed_backend_status",
|
||||||
|
lambda: ("stopped", None, None, None),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_module,
|
||||||
|
"_managed_frontend_status",
|
||||||
|
lambda: ("stopped", None, None, None),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli_module.cli,
|
||||||
|
["config", "set", "SUPERUSER", "admin"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert updates == [("SUPERUSER", "admin")]
|
||||||
|
assert result.output == "SUPERUSER 已更新\n"
|
||||||
@@ -28,14 +28,14 @@ class _FakeMessageIngressPort:
|
|||||||
self.sync_calls = []
|
self.sync_calls = []
|
||||||
self.async_calls = []
|
self.async_calls = []
|
||||||
|
|
||||||
def post(self, url, payload, *, timeout):
|
def post(self, url, payload, *, headers, timeout):
|
||||||
"""记录同步投递并返回固定状态码。"""
|
"""记录同步投递并返回固定状态码。"""
|
||||||
self.sync_calls.append((url, payload, timeout))
|
self.sync_calls.append((url, payload, headers, timeout))
|
||||||
return self.status_code
|
return self.status_code
|
||||||
|
|
||||||
async def async_post(self, url, payload, *, timeout):
|
async def async_post(self, url, payload, *, headers, timeout):
|
||||||
"""记录异步投递并返回固定状态码。"""
|
"""记录异步投递并返回固定状态码。"""
|
||||||
self.async_calls.append((url, payload, timeout))
|
self.async_calls.append((url, payload, headers, timeout))
|
||||||
return self.status_code
|
return self.status_code
|
||||||
|
|
||||||
|
|
||||||
@@ -57,8 +57,8 @@ def _patch_ingress_settings(monkeypatch, **values):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_forward_message_to_host_encodes_source_and_copies_payload(monkeypatch):
|
def test_forward_message_to_host_keeps_token_out_of_url(monkeypatch):
|
||||||
"""统一入口必须安全编码查询参数,并向端口传递 payload 副本。"""
|
"""统一入口仅编码来源参数,并通过请求头传递凭据和 payload 副本。"""
|
||||||
port = _FakeMessageIngressPort()
|
port = _FakeMessageIngressPort()
|
||||||
ingress.configure_message_ingress_port(port)
|
ingress.configure_message_ingress_port(port)
|
||||||
_patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token value")
|
_patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token value")
|
||||||
@@ -70,12 +70,12 @@ def test_forward_message_to_host_encodes_source_and_copies_payload(monkeypatch):
|
|||||||
timeout=9,
|
timeout=9,
|
||||||
) is True
|
) is True
|
||||||
|
|
||||||
url, forwarded, timeout = port.sync_calls[0]
|
url, forwarded, headers, timeout = port.sync_calls[0]
|
||||||
assert urlparse(url).path == "/api/v1/message"
|
assert urlparse(url).path == "/api/v1/message"
|
||||||
assert parse_qs(urlparse(url).query) == {
|
assert parse_qs(urlparse(url).query) == {
|
||||||
"token": ["token value"],
|
|
||||||
"source": ["channel & one"],
|
"source": ["channel & one"],
|
||||||
}
|
}
|
||||||
|
assert headers == {"X-API-KEY": "token value"}
|
||||||
assert forwarded == {"text": "hello"}
|
assert forwarded == {"text": "hello"}
|
||||||
assert forwarded is not payload
|
assert forwarded is not payload
|
||||||
assert timeout == 9
|
assert timeout == 9
|
||||||
@@ -114,11 +114,11 @@ async def test_async_forward_message_to_host_uses_same_contract(monkeypatch):
|
|||||||
timeout=10,
|
timeout=10,
|
||||||
) is True
|
) is True
|
||||||
|
|
||||||
url, payload, timeout = port.async_calls[0]
|
url, payload, headers, timeout = port.async_calls[0]
|
||||||
assert parse_qs(urlparse(url).query) == {
|
assert parse_qs(urlparse(url).query) == {
|
||||||
"token": ["token value"],
|
|
||||||
"source": ["discord & one"],
|
"source": ["discord & one"],
|
||||||
}
|
}
|
||||||
|
assert headers == {"X-API-KEY": "token value"}
|
||||||
assert payload == {"text": "hello"}
|
assert payload == {"text": "hello"}
|
||||||
assert timeout == 10
|
assert timeout == 10
|
||||||
|
|
||||||
@@ -139,10 +139,15 @@ def test_startup_message_ingress_adapter_closes_sync_response(monkeypatch):
|
|||||||
status_code = network_composition._MessageIngressAdapter().post(
|
status_code = network_composition._MessageIngressAdapter().post(
|
||||||
"http://127.0.0.1/message",
|
"http://127.0.0.1/message",
|
||||||
{"text": "hello"},
|
{"text": "hello"},
|
||||||
|
headers={"X-API-KEY": "secret"},
|
||||||
timeout=9,
|
timeout=9,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert status_code == 202
|
assert status_code == 202
|
||||||
|
network_composition.RequestUtils.assert_called_once_with(
|
||||||
|
timeout=9,
|
||||||
|
headers={"X-API-KEY": "secret"},
|
||||||
|
)
|
||||||
response.close.assert_called_once_with()
|
response.close.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
@@ -163,10 +168,15 @@ async def test_startup_message_ingress_adapter_closes_async_response(monkeypatch
|
|||||||
status_code = await network_composition._MessageIngressAdapter().async_post(
|
status_code = await network_composition._MessageIngressAdapter().async_post(
|
||||||
"http://127.0.0.1/message",
|
"http://127.0.0.1/message",
|
||||||
{"text": "hello"},
|
{"text": "hello"},
|
||||||
|
headers={"X-API-KEY": "secret"},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert status_code == 202
|
assert status_code == 202
|
||||||
|
network_composition.AsyncRequestUtils.assert_called_once_with(
|
||||||
|
timeout=10,
|
||||||
|
headers={"X-API-KEY": "secret"},
|
||||||
|
)
|
||||||
response.aclose.assert_awaited_once_with()
|
response.aclose.assert_awaited_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""认证组合根的部署兼容修复测试。"""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.startup.composition import security as security_composition
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_superuser_setting_from_existing_database_admin(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""V2 数据库已有管理员而配置缺失时,应持久化稳定管理员用户名。"""
|
||||||
|
updates: list[tuple[str, str]] = []
|
||||||
|
users = SimpleNamespace(
|
||||||
|
get_active_superuser=Mock(
|
||||||
|
return_value=SimpleNamespace(name="legacy-admin")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
security_composition,
|
||||||
|
"get_runtime_setting",
|
||||||
|
lambda _key: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_setting(key: str, value: str) -> tuple[bool, str]:
|
||||||
|
"""记录启动兼容修复写入。"""
|
||||||
|
updates.append((key, value))
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
security_composition,
|
||||||
|
"update_runtime_setting",
|
||||||
|
update_setting,
|
||||||
|
)
|
||||||
|
|
||||||
|
security_composition._backfill_superuser_setting(users)
|
||||||
|
|
||||||
|
assert updates == [("SUPERUSER", "legacy-admin")]
|
||||||
|
users.get_active_superuser.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_superuser_setting_preserves_explicit_configuration(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""显式 SUPERUSER 必须保持原值,不得被数据库顺序覆盖。"""
|
||||||
|
users = SimpleNamespace(get_active_superuser=Mock())
|
||||||
|
update = Mock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
security_composition,
|
||||||
|
"get_runtime_setting",
|
||||||
|
lambda _key: "configured-admin",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(security_composition, "update_runtime_setting", update)
|
||||||
|
|
||||||
|
security_composition._backfill_superuser_setting(users)
|
||||||
|
|
||||||
|
users.get_active_superuser.assert_not_called()
|
||||||
|
update.assert_not_called()
|
||||||
@@ -160,6 +160,21 @@ async def test_user_snapshots_are_detached_and_deeply_frozen(user_repository) ->
|
|||||||
public.permissions["features"]["search"] = False # type: ignore[index]
|
public.permissions["features"]["search"] = False # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_repository_selects_first_active_superuser(user_repository) -> None:
|
||||||
|
"""升级绑定只能选择启用管理员,并按主键保持确定顺序。"""
|
||||||
|
repository, sync_factory = user_repository
|
||||||
|
_insert_user(sync_factory, name="disabled-admin", is_active=False)
|
||||||
|
expected_id = _insert_user(sync_factory, name="first-admin")
|
||||||
|
_insert_user(sync_factory, name="second-admin")
|
||||||
|
_insert_user(sync_factory, name="member", is_superuser=False)
|
||||||
|
|
||||||
|
selected = repository.get_active_superuser()
|
||||||
|
|
||||||
|
assert selected is not None
|
||||||
|
assert selected.id == expected_id
|
||||||
|
assert selected.name == "first-admin"
|
||||||
|
|
||||||
|
|
||||||
def test_auxiliary_create_commits_before_return(user_repository) -> None:
|
def test_auxiliary_create_commits_before_return(user_repository) -> None:
|
||||||
"""辅助认证创建成功返回时,新用户必须已对后续独立会话可见。"""
|
"""辅助认证创建成功返回时,新用户必须已对后续独立会话可见。"""
|
||||||
repository, sync_factory = user_repository
|
repository, sync_factory = user_repository
|
||||||
|
|||||||
Reference in New Issue
Block a user