fix(auth): fix passkey deletion password verification

This commit is contained in:
jxxghp
2026-09-06 10:05:40 +08:00
parent a4cb2870a1
commit 82652add91
5 changed files with 92 additions and 7 deletions
+3 -5
View File
@@ -34,7 +34,6 @@ from app.application.security.passkey import (
PassKeyRegistrationVerificationError, PassKeyRegistrationVerificationError,
PasskeyService, PasskeyService,
) )
from app.application.security.token import verify_password
from app.application.security.user import ( from app.application.security.user import (
UserService, UserService,
get_configured_user_id_lookup, get_configured_user_id_lookup,
@@ -185,7 +184,7 @@ async def otp_disable(
) -> Any: ) -> Any:
"""关闭当前用户的 OTP 验证功能""" """关闭当前用户的 OTP 验证功能"""
# 验证密码 # 验证密码
if not verify_password(data.password, str(current_user.hashed_password)): if not await service.verify_password(current_user.id, data.password):
return _SchemaResponse(success=False, message="密码错误") return _SchemaResponse(success=False, message="密码错误")
await service.update_otp(current_user.name, False, "") await service.update_otp(current_user.name, False, "")
return _SchemaResponse(success=True) return _SchemaResponse(success=True)
@@ -508,13 +507,12 @@ async def passkey_delete(
data: PassKeyDeleteRequest, data: PassKeyDeleteRequest,
current_user: ApiPrincipal = Depends(get_current_active_user_async), current_user: ApiPrincipal = Depends(get_current_active_user_async),
service: PasskeyService = Depends(get_passkey_service), service: PasskeyService = Depends(get_passkey_service),
user_service: UserService = Depends(get_user_service),
) -> Any: ) -> Any:
"""删除指定的 PassKey""" """删除指定的 PassKey"""
try: try:
# 验证密码 # 验证密码
if not verify_password( if not await user_service.verify_password(current_user.id, data.password):
data.password, str(current_user.hashed_password)
):
return _SchemaResponse(success=False, message="密码错误") return _SchemaResponse(success=False, message="密码错误")
success = service.delete_by_id(data.passkey_id, current_user.id) success = service.delete_by_id(data.passkey_id, current_user.id)
+15
View File
@@ -9,6 +9,8 @@ from dataclasses import dataclass
from types import MappingProxyType from types import MappingProxyType
from typing import Any, Optional, Protocol, TypeAlias, TypeVar, Union, cast from typing import Any, Optional, Protocol, TypeAlias, TypeVar, Union, cast
from app.application.security.token import verify_password as _verify_password
FrozenJson: TypeAlias = Union[ FrozenJson: TypeAlias = Union[
str, str,
int, int,
@@ -195,6 +197,9 @@ class UserRepository(Protocol):
async def async_get_by_id(self, user_id: int) -> Optional[UserSnapshot]: async def async_get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
"""按用户 ID 返回用户。""" """按用户 ID 返回用户。"""
async def async_get_auth_by_id(self, user_id: int) -> Optional[UserAuthSnapshot]:
"""按用户 ID 返回密码校验所需的认证快照。"""
async def async_create( async def async_create(
self, self,
payload: dict[str, Any], payload: dict[str, Any],
@@ -283,6 +288,16 @@ class UserService:
"""按用户 ID 查询用户。""" """按用户 ID 查询用户。"""
return await self._repository.async_get_by_id(user_id) return await self._repository.async_get_by_id(user_id)
async def verify_password(self, user_id: int, password: str) -> bool:
"""按用户 ID 读取认证快照并校验密码。"""
user = await self._repository.async_get_auth_by_id(user_id)
return bool(
user
and user.user.is_active
and user.hashed_password
and _verify_password(password, user.hashed_password)
)
async def create(self, payload: dict[str, Any]) -> Optional[UserSnapshot]: async def create(self, payload: dict[str, Any]) -> Optional[UserSnapshot]:
"""创建用户。""" """创建用户。"""
return await self._write(lambda: self._repository.async_create(payload)) return await self._write(lambda: self._repository.async_create(payload))
+8
View File
@@ -115,6 +115,14 @@ class SqlAlchemyUserRepository(UserRepository):
model = await self._oper.async_get_by_id(user_id) model = await self._oper.async_get_by_id(user_id)
return _to_snapshot(model) if model else None return _to_snapshot(model) if model else None
async def async_get_auth_by_id(
self,
user_id: int,
) -> Optional[UserAuthSnapshot]:
"""在异步请求会话中按 ID 读取密码校验所需的认证快照。"""
model = await self._oper.async_get_by_id(user_id)
return _to_auth_snapshot(model) if model else None
async def async_create( async def async_create(
self, self,
payload: dict[str, Any], payload: dict[str, Any],
+40 -1
View File
@@ -1,5 +1,5 @@
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
@@ -309,3 +309,42 @@ def test_authentication_finish_does_not_issue_token_when_sign_count_write_fails(
) )
auth_service.build_token_response.assert_not_called() auth_service.build_token_response.assert_not_called()
set_cookie.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()
+26 -1
View File
@@ -1,9 +1,11 @@
"""用户应用服务的请求级事务边界测试。""" """用户应用服务的请求级事务边界测试。"""
from unittest.mock import AsyncMock, MagicMock from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from app.application.security import user as user_service_module
from app.application.security.user import UserService, UserSnapshot, UserUpdateResult from app.application.security.user import UserService, UserSnapshot, UserUpdateResult
@@ -139,3 +141,26 @@ async def test_user_service_does_not_rollback_committed_publish_failure() -> Non
unit_of_work.commit.assert_awaited_once_with() unit_of_work.commit.assert_awaited_once_with()
unit_of_work.rollback.assert_not_awaited() unit_of_work.rollback.assert_not_awaited()
@pytest.mark.asyncio
async def test_user_service_verifies_password_from_auth_snapshot() -> None:
"""密码校验应读取认证快照,而不是依赖公开用户资料。"""
repository = MagicMock()
repository.async_get_auth_by_id = AsyncMock(
return_value=SimpleNamespace(
user=SimpleNamespace(is_active=True),
hashed_password="hashed",
)
)
service = UserService(repository, MagicMock(), MagicMock())
with patch.object(
user_service_module,
"_verify_password",
return_value=True,
) as verify_password:
assert await service.verify_password(7, "password") is True
repository.async_get_auth_by_id.assert_awaited_once_with(7)
verify_password.assert_called_once_with("password", "hashed")