fix: harden account merge security and correctness
- Clear ALL unique constraint fields on secondary user after merge (telegram_id, OAuth IDs, email, referral_code, remnawave_uuid) - Add Literal type + runtime validation for keep_subscription_from - Reject merge when primary user is deleted - Validate OAuth state user_id matches authenticated user in link callback - Replace leaked ValueError messages with generic error detail - Fix exc_info usage for idiomatic structlog - Fix _get_remnawave_api return type to AsyncIterator - Remove unnecessary from __future__ import annotations - Add 3 new tests (42 total, all passing)
This commit is contained in:
@@ -18,7 +18,7 @@ from app.database.crud.user import (
|
|||||||
set_user_oauth_provider_id,
|
set_user_oauth_provider_id,
|
||||||
)
|
)
|
||||||
from app.database.models import User
|
from app.database.models import User
|
||||||
from app.services.account_merge_service import execute_merge, get_merge_preview
|
from app.services.account_merge_service import _compute_auth_methods, execute_merge, get_merge_preview
|
||||||
|
|
||||||
from ..auth.merge_service import (
|
from ..auth.merge_service import (
|
||||||
MERGE_TOKEN_TTL_SECONDS,
|
MERGE_TOKEN_TTL_SECONDS,
|
||||||
@@ -137,15 +137,7 @@ def _get_provider_identifier(user: User, provider: str) -> str | None:
|
|||||||
|
|
||||||
def _count_auth_methods(user: User) -> int:
|
def _count_auth_methods(user: User) -> int:
|
||||||
"""Count how many auth methods the user has linked."""
|
"""Count how many auth methods the user has linked."""
|
||||||
count = 0
|
return len(_compute_auth_methods(user))
|
||||||
if user.telegram_id:
|
|
||||||
count += 1
|
|
||||||
if user.email and user.password_hash:
|
|
||||||
count += 1
|
|
||||||
for column in _OAUTH_PROVIDER_COLUMNS.values():
|
|
||||||
if getattr(user, column, None):
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -237,6 +229,20 @@ async def link_provider_callback(
|
|||||||
detail='Invalid or expired OAuth state',
|
detail='Invalid or expired OAuth state',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 1b. Validate that the user who initiated the link flow is the same user completing it
|
||||||
|
state_user_id = state_data.get('user_id')
|
||||||
|
if state_user_id and str(user.id) != state_user_id:
|
||||||
|
logger.warning(
|
||||||
|
'OAuth state user_id mismatch in link callback',
|
||||||
|
state_user_id=state_user_id,
|
||||||
|
current_user_id=user.id,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail='OAuth state was initiated by a different user',
|
||||||
|
)
|
||||||
|
|
||||||
# 2. Get provider instance
|
# 2. Get provider instance
|
||||||
oauth_provider = get_provider(provider)
|
oauth_provider = get_provider(provider)
|
||||||
if not oauth_provider:
|
if not oauth_provider:
|
||||||
@@ -256,7 +262,7 @@ async def link_provider_callback(
|
|||||||
try:
|
try:
|
||||||
token_data = await oauth_provider.exchange_code(request.code, **exchange_kwargs)
|
token_data = await oauth_provider.exchange_code(request.code, **exchange_kwargs)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error('OAuth code exchange failed during linking', provider=provider, exc_info=exc)
|
logger.error('OAuth code exchange failed during linking', provider=provider, exc_info=True)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail='Failed to exchange authorization code',
|
detail='Failed to exchange authorization code',
|
||||||
@@ -266,7 +272,7 @@ async def link_provider_callback(
|
|||||||
try:
|
try:
|
||||||
user_info = await oauth_provider.get_user_info(token_data)
|
user_info = await oauth_provider.get_user_info(token_data)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error('OAuth user info fetch failed during linking', provider=provider, exc_info=exc)
|
logger.error('OAuth user info fetch failed during linking', provider=provider, exc_info=True)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail='Failed to fetch user information from provider',
|
detail='Failed to fetch user information from provider',
|
||||||
@@ -445,11 +451,11 @@ async def execute_merge_endpoint(
|
|||||||
logger.error('Merge execution failed (ValueError)', error=str(exc))
|
logger.error('Merge execution failed (ValueError)', error=str(exc))
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=str(exc),
|
detail='Account merge cannot be completed. The accounts may have already been merged or deleted.',
|
||||||
) from exc
|
) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
logger.error('Merge execution failed', exc_info=exc)
|
logger.error('Merge execution failed', exc_info=True)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail='Account merge failed due to an internal error',
|
detail='Account merge failed due to an internal error',
|
||||||
@@ -468,7 +474,7 @@ async def execute_merge_endpoint(
|
|||||||
auth_response = await _create_auth_response(merged_user, db)
|
auth_response = await _create_auth_response(merged_user, db)
|
||||||
await _store_refresh_token(db, merged_user.id, auth_response.refresh_token, device_info='merge')
|
await _store_refresh_token(db, merged_user.id, auth_response.refresh_token, device_info='merge')
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error('Failed to create auth tokens after merge', exc_info=exc)
|
logger.error('Failed to create auth tokens after merge', exc_info=True)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail='Merge succeeded but failed to create new session',
|
detail='Merge succeeded but failed to create new session',
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from __future__ import annotations
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from sqlalchemy import update
|
from sqlalchemy import update
|
||||||
@@ -148,7 +147,7 @@ async def get_merge_preview(
|
|||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _get_remnawave_api() -> RemnaWaveAPI:
|
async def _get_remnawave_api() -> AsyncIterator[RemnaWaveAPI]:
|
||||||
"""Создаёт экземпляр RemnaWave API клиента (паттерн из RemnaWaveService)."""
|
"""Создаёт экземпляр RemnaWave API клиента (паттерн из RemnaWaveService)."""
|
||||||
auth_params = settings.get_remnawave_auth_params()
|
auth_params = settings.get_remnawave_auth_params()
|
||||||
base_url = (auth_params.get('base_url') or '').strip()
|
base_url = (auth_params.get('base_url') or '').strip()
|
||||||
@@ -308,7 +307,7 @@ async def execute_merge(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
primary_user_id: int,
|
primary_user_id: int,
|
||||||
secondary_user_id: int,
|
secondary_user_id: int,
|
||||||
keep_subscription_from: str = 'primary',
|
keep_subscription_from: Literal['primary', 'secondary'] = 'primary',
|
||||||
provider: str | None = None,
|
provider: str | None = None,
|
||||||
provider_id: str | None = None,
|
provider_id: str | None = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
@@ -330,6 +329,9 @@ async def execute_merge(
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: Если пользователь не найден, совпадают ID, или secondary уже удалён.
|
ValueError: Если пользователь не найден, совпадают ID, или secondary уже удалён.
|
||||||
"""
|
"""
|
||||||
|
if keep_subscription_from not in ('primary', 'secondary'):
|
||||||
|
raise ValueError("keep_subscription_from must be 'primary' or 'secondary'")
|
||||||
|
|
||||||
if primary_user_id == secondary_user_id:
|
if primary_user_id == secondary_user_id:
|
||||||
raise ValueError('primary_user_id и secondary_user_id не могут совпадать')
|
raise ValueError('primary_user_id и secondary_user_id не могут совпадать')
|
||||||
|
|
||||||
@@ -338,6 +340,8 @@ async def execute_merge(
|
|||||||
|
|
||||||
if not primary:
|
if not primary:
|
||||||
raise ValueError(f'Основной пользователь (id={primary_user_id}) не найден')
|
raise ValueError(f'Основной пользователь (id={primary_user_id}) не найден')
|
||||||
|
if primary.status == UserStatus.DELETED.value:
|
||||||
|
raise ValueError(f'Основной пользователь (id={primary_user_id}) удалён')
|
||||||
if not secondary:
|
if not secondary:
|
||||||
raise ValueError(f'Вторичный пользователь (id={secondary_user_id}) не найден')
|
raise ValueError(f'Вторичный пользователь (id={secondary_user_id}) не найден')
|
||||||
if secondary.status == UserStatus.DELETED.value:
|
if secondary.status == UserStatus.DELETED.value:
|
||||||
@@ -461,13 +465,17 @@ async def execute_merge(
|
|||||||
value=primary.referral_commission_percent,
|
value=primary.referral_commission_percent,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 14. Помечаем secondary как удалённый
|
# 14. Помечаем secondary как удалённый и очищаем ВСЕ unique constraint поля
|
||||||
secondary.status = UserStatus.DELETED.value
|
secondary.status = UserStatus.DELETED.value
|
||||||
secondary.referral_code = None
|
secondary.referral_code = None
|
||||||
secondary.remnawave_uuid = None
|
secondary.remnawave_uuid = None
|
||||||
# email уже очищен выше если был перенесён, иначе очищаем для unique constraint
|
|
||||||
if secondary.email:
|
if secondary.email:
|
||||||
secondary.email = None
|
secondary.email = None
|
||||||
|
if secondary.telegram_id:
|
||||||
|
secondary.telegram_id = None
|
||||||
|
for field in _OAUTH_FIELDS:
|
||||||
|
if getattr(secondary, field) is not None:
|
||||||
|
setattr(secondary, field, None)
|
||||||
secondary.updated_at = now
|
secondary.updated_at = now
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -273,6 +273,23 @@ class TestExecuteMergeValidation:
|
|||||||
with pytest.raises(ValueError, match='уже удалён'):
|
with pytest.raises(ValueError, match='уже удалён'):
|
||||||
await execute_merge(db, 1, 2)
|
await execute_merge(db, 1, 2)
|
||||||
|
|
||||||
|
async def test_deleted_primary_raises(self, monkeypatch):
|
||||||
|
db = _make_db()
|
||||||
|
primary = _make_user(id=1, status='deleted')
|
||||||
|
secondary = _make_user(id=2)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
account_merge_service,
|
||||||
|
'get_user_by_id',
|
||||||
|
AsyncMock(side_effect=[primary, secondary]),
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match='удалён'):
|
||||||
|
await execute_merge(db, 1, 2)
|
||||||
|
|
||||||
|
async def test_invalid_keep_subscription_from_raises(self):
|
||||||
|
db = _make_db()
|
||||||
|
with pytest.raises(ValueError, match=r'primary.*secondary'):
|
||||||
|
await execute_merge(db, 1, 2, keep_subscription_from='invalid')
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# execute_merge — data transfer
|
# execute_merge — data transfer
|
||||||
@@ -325,6 +342,8 @@ class TestExecuteMergeOAuthTransfer:
|
|||||||
|
|
||||||
# Primary keeps its own google_id
|
# Primary keeps its own google_id
|
||||||
assert result.google_id == 'g_primary'
|
assert result.google_id == 'g_primary'
|
||||||
|
# Secondary's conflicting google_id is cleared (unique constraint cleanup)
|
||||||
|
assert secondary.google_id is None
|
||||||
|
|
||||||
|
|
||||||
class TestExecuteMergeTelegramTransfer:
|
class TestExecuteMergeTelegramTransfer:
|
||||||
@@ -513,6 +532,40 @@ class TestExecuteMergeSecondaryDeleted:
|
|||||||
assert secondary.remnawave_uuid is None
|
assert secondary.remnawave_uuid is None
|
||||||
assert secondary.email is None
|
assert secondary.email is None
|
||||||
|
|
||||||
|
async def test_all_unique_fields_cleared_on_secondary(self, monkeypatch):
|
||||||
|
"""All unique constraint fields must be cleared on secondary after merge."""
|
||||||
|
db = _make_db()
|
||||||
|
# Primary has its own OAuth + telegram, so secondary's won't transfer
|
||||||
|
primary = _make_user(id=1, telegram_id=111, google_id='g1', yandex_id='y1')
|
||||||
|
secondary = _make_user(
|
||||||
|
id=2,
|
||||||
|
telegram_id=222,
|
||||||
|
google_id='g2',
|
||||||
|
yandex_id='y2',
|
||||||
|
discord_id='d2',
|
||||||
|
vk_id=999,
|
||||||
|
email='sec@e.com',
|
||||||
|
referral_code='REF',
|
||||||
|
remnawave_uuid='rw-sec',
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
account_merge_service,
|
||||||
|
'get_user_by_id',
|
||||||
|
AsyncMock(side_effect=[primary, secondary]),
|
||||||
|
)
|
||||||
|
with _patch_remnawave_delete():
|
||||||
|
await execute_merge(db, 1, 2)
|
||||||
|
|
||||||
|
# ALL unique fields cleared on secondary
|
||||||
|
assert secondary.telegram_id is None
|
||||||
|
assert secondary.google_id is None
|
||||||
|
assert secondary.yandex_id is None
|
||||||
|
assert secondary.discord_id is None
|
||||||
|
assert secondary.vk_id is None
|
||||||
|
assert secondary.email is None
|
||||||
|
assert secondary.referral_code is None
|
||||||
|
assert secondary.remnawave_uuid is None
|
||||||
|
|
||||||
async def test_db_flush_called(self, monkeypatch):
|
async def test_db_flush_called(self, monkeypatch):
|
||||||
db = _make_db()
|
db = _make_db()
|
||||||
primary = _make_user(id=1)
|
primary = _make_user(id=1)
|
||||||
|
|||||||
Reference in New Issue
Block a user