Files
MoviePilot/tests/test_mfa_passkey_transactions.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

382 lines
12 KiB
Python

"""
验证通行密钥挑战事务与用户身份绑定。
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from starlette.responses import Response
from app.api.endpoints import mfa as mfa_endpoint
from app.application.security.passkey import (
PASSKEY_CHALLENGE_TTL_SECONDS,
PasskeyChallengeStore,
configure_passkey_challenge_cache,
)
from app.runtime.cache import TTLCache
def _request() -> Request:
return Request(
{
"type": "http",
"method": "POST",
"path": "/api/v1/mfa/passkey/authenticate/finish",
"headers": [(b"host", b"testserver")],
"scheme": "http",
"server": ("testserver", 80),
"client": ("testclient", 123),
}
)
def setup_function():
"""
为每个用例重建独立的通行密钥挑战缓存。
"""
configure_passkey_challenge_cache(TTLCache(
region="passkey_challenge",
maxsize=4096,
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
))
def test_registration_transaction_is_bound_to_current_user():
"""
注册事务绑定发起用户,其他用户不能使用该事务完成凭据注册。
"""
token = PasskeyChallengeStore.issue(
challenge="server-challenge",
purpose="registration",
user_id=1,
)
request = mfa_endpoint.PassKeyRegistrationFinish(
credential={"id": "credential-id"},
transaction_token=token,
name="test",
)
with patch.object(
mfa_endpoint.PassKeyHelper,
"verify_registration_response",
) as verify:
result = mfa_endpoint.passkey_register_finish(
passkey_req=request,
current_user=SimpleNamespace(id=2, name="other"),
)
assert not result.success
assert result.message == "注册请求已失效,请重新发起注册"
verify.assert_not_called()
def test_registration_uses_server_challenge():
"""
注册校验采用服务端保存的挑战值,忽略客户端自行提交的挑战。
"""
token = PasskeyChallengeStore.issue(
challenge="server-challenge",
purpose="registration",
user_id=1,
)
request = mfa_endpoint.PassKeyRegistrationFinish(
credential={"id": "credential-id", "challenge": "client-challenge"},
transaction_token=token,
name="test",
)
passkey = Mock()
service = SimpleNamespace(create=Mock(return_value=passkey))
with patch.object(
mfa_endpoint.PassKeyHelper,
"verify_registration_response",
return_value=("credential-id", b"public-key", 0, "aaguid"),
) as verify:
result = mfa_endpoint.passkey_register_finish(
passkey_req=request,
current_user=SimpleNamespace(id=1, name="user"),
service=service,
)
assert result.success
verify.assert_called_once_with(
credential=request.credential,
expected_challenge="server-challenge",
)
service.create.assert_called_once()
def test_authentication_transaction_rejects_other_user_credential():
"""
认证事务拒绝使用其他用户持有的凭据,避免跨账户完成认证。
"""
token = PasskeyChallengeStore.issue(
challenge="server-challenge",
purpose="authentication",
user_id=1,
)
request = mfa_endpoint.PassKeyAuthenticationFinish(
credential={"id": "credential-id"},
transaction_token=token,
)
passkey = SimpleNamespace(user_id=2)
user = SimpleNamespace(id=2, is_active=True)
service = SimpleNamespace(get_by_credential_id=Mock(return_value=passkey))
lookup = Mock(return_value=user)
with patch.object(
mfa_endpoint,
"_extract_and_standardize_credential_id",
return_value="credential-id",
), patch.object(
mfa_endpoint,
"get_configured_user_name_lookup",
return_value=lookup,
), patch.object(
mfa_endpoint,
"_verify_passkey_and_update",
) as verify:
with pytest.raises(HTTPException) as exc_info:
mfa_endpoint.passkey_authenticate_finish(
request=_request(),
response=Response(),
passkey_req=request,
service=service,
)
assert exc_info.value.status_code == 401
verify.assert_not_called()
def test_authentication_finish_token_cannot_be_replayed():
"""
认证完成后消费事务令牌,再次提交同一令牌必须失败。
"""
token = PasskeyChallengeStore.issue(
challenge="server-challenge",
purpose="authentication",
user_id=1,
)
request = mfa_endpoint.PassKeyAuthenticationFinish(
credential={"id": "credential-id"},
transaction_token=token,
)
passkey = SimpleNamespace(user_id=1)
user = SimpleNamespace(
id=1,
name="user",
is_active=True,
is_superuser=False,
avatar="",
permissions={},
)
service = SimpleNamespace(get_by_credential_id=Mock(return_value=passkey))
lookup = Mock(return_value=user)
token_response = SimpleNamespace(access_token="access-token", level=1)
with patch.object(
mfa_endpoint,
"_extract_and_standardize_credential_id",
return_value="credential-id",
), patch.object(
mfa_endpoint,
"get_configured_user_id_lookup",
return_value=lookup,
), patch.object(
mfa_endpoint,
"_verify_passkey_and_update",
return_value=(True, 0),
), patch.object(
mfa_endpoint,
"get_configured_auth_service",
return_value=SimpleNamespace(build_token_response=Mock(return_value=token_response)),
), patch.object(
mfa_endpoint,
"set_or_refresh_resource_token_cookie",
):
result = mfa_endpoint.passkey_authenticate_finish(
request=_request(),
response=Response(),
passkey_req=request,
service=service,
)
with pytest.raises(HTTPException) as replay_error:
mfa_endpoint.passkey_authenticate_finish(
request=_request(),
response=Response(),
passkey_req=request,
service=service,
)
assert result.access_token == "access-token"
assert replay_error.value.status_code == 401
assert replay_error.value.detail == "认证请求已失效"
def test_authentication_finish_fails_closed_when_challenge_backend_fails(
monkeypatch,
):
"""
challenge 后端故障不得继续查询凭证或签发 Token。
"""
class FailingCache:
"""
模拟 challenge 原子领取期间缓存不可用。
"""
def consume(self, _key):
"""
报告后端不可用。
"""
raise RuntimeError("cache unavailable")
monkeypatch.setattr(PasskeyChallengeStore, "_cache", FailingCache())
service = SimpleNamespace(get_by_credential_id=Mock())
auth_service = SimpleNamespace(build_token_response=Mock())
with patch.object(
mfa_endpoint,
"get_configured_auth_service",
return_value=auth_service,
), patch.object(
mfa_endpoint,
"set_or_refresh_resource_token_cookie",
) as set_cookie, pytest.raises(HTTPException) as exc_info:
mfa_endpoint.passkey_authenticate_finish(
request=_request(),
response=Response(),
passkey_req=mfa_endpoint.PassKeyAuthenticationFinish(
credential={"id": "credential-id"},
transaction_token="transaction-token",
),
service=service,
)
assert exc_info.value.status_code == 401
service.get_by_credential_id.assert_not_called()
auth_service.build_token_response.assert_not_called()
set_cookie.assert_not_called()
@pytest.mark.parametrize("cas_failure", [False, RuntimeError("write failed")])
def test_authentication_finish_does_not_issue_token_when_sign_count_write_fails(
cas_failure,
):
"""
签名计数 CAS 冲突或事务失败时,认证不得越过持久化门禁。
"""
token = PasskeyChallengeStore.issue(
challenge="server-challenge",
purpose="authentication",
user_id=1,
)
passkey_req = mfa_endpoint.PassKeyAuthenticationFinish(
credential={"id": "credential-id"},
transaction_token=token,
)
passkey = SimpleNamespace(
id=10,
user_id=1,
public_key="public-key",
sign_count=5,
)
user = SimpleNamespace(
id=1,
name="user",
is_active=True,
is_superuser=False,
)
compare_and_update = Mock()
if isinstance(cas_failure, Exception):
compare_and_update.side_effect = cas_failure
else:
compare_and_update.return_value = cas_failure
service = SimpleNamespace(
get_by_credential_id=Mock(return_value=passkey),
compare_and_update_sign_count=compare_and_update,
)
auth_service = SimpleNamespace(build_token_response=Mock())
with patch.object(
mfa_endpoint,
"_extract_and_standardize_credential_id",
return_value="credential-id",
), patch.object(
mfa_endpoint,
"get_configured_user_id_lookup",
return_value=Mock(return_value=user),
), patch.object(
mfa_endpoint.PassKeyHelper,
"verify_authentication_response",
return_value=(True, 6),
), patch.object(
mfa_endpoint,
"get_configured_auth_service",
return_value=auth_service,
), patch.object(
mfa_endpoint,
"set_or_refresh_resource_token_cookie",
) as set_cookie:
with pytest.raises(HTTPException) as exc_info:
mfa_endpoint.passkey_authenticate_finish(
request=_request(),
response=Response(),
passkey_req=passkey_req,
service=service,
)
assert exc_info.value.status_code == 401
compare_and_update.assert_called_once_with(
passkey_id=10,
expected_sign_count=5,
sign_count=6,
)
auth_service.build_token_response.assert_not_called()
set_cookie.assert_not_called()
@pytest.mark.asyncio
async def test_passkey_delete_verifies_password_without_public_password_field():
"""
删除 PassKey 应通过认证服务校验密码,而不是读取公开用户快照字段。
"""
data = mfa_endpoint.PassKeyDeleteRequest(passkey_id=10, password="password")
current_user = SimpleNamespace(id=7, name="user")
passkey_service = SimpleNamespace(delete_by_id=Mock(return_value=True))
user_service = SimpleNamespace(verify_password=AsyncMock(return_value=True))
result = await mfa_endpoint.passkey_delete(
data=data,
current_user=current_user,
service=passkey_service,
user_service=user_service,
)
assert result.success is True
user_service.verify_password.assert_awaited_once_with(7, "password")
passkey_service.delete_by_id.assert_called_once_with(10, 7)
@pytest.mark.asyncio
async def test_passkey_delete_rejects_invalid_password_before_deletion():
"""
密码错误时不得删除 PassKey。
"""
data = mfa_endpoint.PassKeyDeleteRequest(passkey_id=10, password="wrong")
passkey_service = SimpleNamespace(delete_by_id=Mock())
user_service = SimpleNamespace(verify_password=AsyncMock(return_value=False))
result = await mfa_endpoint.passkey_delete(
data=data,
current_user=SimpleNamespace(id=7, name="user"),
service=passkey_service,
user_service=user_service,
)
assert result.success is False
assert result.message == "密码错误"
passkey_service.delete_by_id.assert_not_called()