fix: prevent self-referral loops, invalidate all sessions on merge

- Add User.id != primary.id filter to referred_by_id reassignment to
  prevent self-referral loops when primary was referred by secondary
- Clear primary.referred_by_id if it pointed to secondary
- Add exclusion filter to ReferralEarning.referral_id reassignment to
  prevent user_id == referral_id rows
- Invalidate refresh tokens for BOTH primary and secondary during merge
  (primary gets a fresh session after merge)
- Fix duplicate step 4 comment numbering in execute_merge_endpoint
- Add referred_by_id field to test fixture _make_user
This commit is contained in:
Fringg
2026-03-04 15:01:51 +03:00
parent bc1e6fb22c
commit db61365e11
3 changed files with 19 additions and 8 deletions

View File

@@ -469,7 +469,7 @@ async def execute_merge_endpoint(
detail='Account merge failed due to an internal error',
) from exc
# 4. Re-fetch merged user with full relationships for auth response
# 5. Re-fetch merged user with full relationships for auth response
merged_user = await get_user_by_id(db, primary_user_id)
if not merged_user:
raise HTTPException(
@@ -477,7 +477,7 @@ async def execute_merge_endpoint(
detail='Failed to load merged user',
)
# 5. Create auth tokens for the merged user
# 6. Create auth tokens for the merged user
try:
auth_response = await _create_auth_response(merged_user, db)
await _store_refresh_token(db, merged_user.id, auth_response.refresh_token, device_info='merge')

View File

@@ -420,26 +420,35 @@ async def execute_merge(
for payment_model in _PAYMENT_MODELS:
await db.execute(update(payment_model).where(payment_model.user_id == secondary.id).values(user_id=primary.id))
# 8. Переназначение referral_earnings (обе колонки)
# 8. Переназначение referral_earnings (обе колонки, исключая self-referral)
await db.execute(update(ReferralEarning).where(ReferralEarning.user_id == secondary.id).values(user_id=primary.id))
await db.execute(
update(ReferralEarning).where(ReferralEarning.referral_id == secondary.id).values(referral_id=primary.id)
update(ReferralEarning)
.where(ReferralEarning.referral_id == secondary.id, ReferralEarning.user_id != primary.id)
.values(referral_id=primary.id)
)
# 9. Переназначение реферальной цепочки
await db.execute(update(User).where(User.referred_by_id == secondary.id).values(referred_by_id=primary.id))
# 9. Переназначение реферальной цепочки (исключая self-referral)
await db.execute(
update(User)
.where(User.referred_by_id == secondary.id, User.id != primary.id)
.values(referred_by_id=primary.id)
)
# Если primary был приглашён secondary — очищаем (нельзя ссылаться на самого себя)
if primary.referred_by_id == secondary.id:
primary.referred_by_id = None
# 10. Переназначение withdrawal_requests
await db.execute(
update(WithdrawalRequest).where(WithdrawalRequest.user_id == secondary.id).values(user_id=primary.id)
)
# 11. Инвалидация refresh-токенов secondary
# 11. Инвалидация refresh-токенов обоих пользователей (после мержа будет создан новый)
now = datetime.now(UTC)
await db.execute(
update(CabinetRefreshToken)
.where(
CabinetRefreshToken.user_id == secondary.id,
CabinetRefreshToken.user_id.in_([primary.id, secondary.id]),
CabinetRefreshToken.revoked_at.is_(None),
)
.values(revoked_at=now)

View File

@@ -40,6 +40,7 @@ def _make_user(
partner_status: str = 'none',
referral_code: str | None = None,
referral_commission_percent: int | None = None,
referred_by_id: int | None = None,
remnawave_uuid: str | None = None,
subscription: object | None = None,
created_at: datetime | None = None,
@@ -63,6 +64,7 @@ def _make_user(
partner_status=partner_status,
referral_code=referral_code,
referral_commission_percent=referral_commission_percent,
referred_by_id=referred_by_id,
remnawave_uuid=remnawave_uuid,
subscription=subscription,
created_at=created_at or datetime(2024, 1, 1, tzinfo=UTC),