535 lines
21 KiB
Python
535 lines
21 KiB
Python
import hashlib
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
|
|
import bcrypt
|
|
from sqlalchemy import delete, func, select, text, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from config import API_TOKEN_TTL_DAYS
|
|
from core.executor import run_cpu, run_io
|
|
from database.access.tg_mirror import refresh_tg_mirrors_for_user
|
|
from database.models import Admin, Identity, User
|
|
|
|
|
|
_BCRYPT_MAX_PASSWORD_BYTES = 72
|
|
_BCRYPT_ROUNDS = 12
|
|
|
|
|
|
def _password_bytes(password: str) -> bytes:
|
|
"""Пароль в байтах, не длиннее 72 байт (ограничение bcrypt)."""
|
|
raw = password.encode("utf-8")
|
|
if len(raw) > _BCRYPT_MAX_PASSWORD_BYTES:
|
|
return raw[:_BCRYPT_MAX_PASSWORD_BYTES]
|
|
return raw
|
|
|
|
|
|
def hash_token(token: str) -> str:
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
"""Хеш пароля через bcrypt (соль уникальна на каждый пароль)."""
|
|
salt = bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)
|
|
return bcrypt.hashpw(_password_bytes(password), salt).decode("ascii")
|
|
|
|
|
|
def check_password(password: str, password_hash: str | None) -> bool:
|
|
if not password_hash:
|
|
return False
|
|
try:
|
|
return bcrypt.checkpw(_password_bytes(password), password_hash.encode("ascii"))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def generate_token() -> str:
|
|
return secrets.token_urlsafe(32)
|
|
|
|
|
|
async def create_identity(
|
|
session: AsyncSession,
|
|
email: str | None = None,
|
|
tg_id: int | None = None,
|
|
) -> Identity:
|
|
"""Создаёт идентичность; можно задать email и/или tg_id."""
|
|
identity = Identity(email=email.strip().lower() if email else None, tg_id=tg_id)
|
|
session.add(identity)
|
|
await session.flush()
|
|
if tg_id:
|
|
await session.execute(User.__table__.update().where(User.tg_id == tg_id).values(identity_id=identity.id))
|
|
await session.refresh(identity)
|
|
return identity
|
|
|
|
|
|
async def get_identity_by_id(session: AsyncSession, identity_id: str) -> Identity | None:
|
|
"""Возвращает идентичность по id."""
|
|
result = await session.execute(select(Identity).where(Identity.id == identity_id))
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def get_identity_by_email(session: AsyncSession, email: str) -> Identity | None:
|
|
"""Возвращает идентичность по email."""
|
|
if not email or not email.strip():
|
|
return None
|
|
result = await session.execute(select(Identity).where(Identity.email == email.strip().lower()))
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def get_identity_by_tg_id(session: AsyncSession, tg_id: int) -> Identity | None:
|
|
"""Возвращает идентичность по tg_id."""
|
|
result = await session.execute(select(Identity).where(Identity.tg_id == tg_id))
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def get_identity_by_token_hash(session: AsyncSession, token_hash: str) -> Identity | None:
|
|
"""Возвращает идентичность по хешу API-токена."""
|
|
result = await session.execute(select(Identity).where(Identity.api_token_hash == token_hash))
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def issue_token_for_identity(session: AsyncSession, identity: Identity) -> str:
|
|
"""Генерирует токен, сохраняет хеш и token_issued_at в identity, возвращает токен (показать один раз)."""
|
|
token = generate_token()
|
|
identity.api_token_hash = await run_io(hash_token, token)
|
|
identity.token_issued_at = datetime.utcnow()
|
|
await session.flush()
|
|
return token
|
|
|
|
|
|
def _is_token_expired(identity: Identity) -> bool:
|
|
"""Проверяет, истёк ли срок действия токена (если задан API_TOKEN_TTL_DAYS)."""
|
|
if API_TOKEN_TTL_DAYS is None or identity.token_issued_at is None:
|
|
return False
|
|
expiry = identity.token_issued_at + timedelta(days=API_TOKEN_TTL_DAYS)
|
|
return datetime.utcnow() >= expiry
|
|
|
|
|
|
async def create_identity_with_token(
|
|
session: AsyncSession,
|
|
email: str | None = None,
|
|
password: str | None = None,
|
|
tg_id: int | None = None,
|
|
) -> tuple[Identity, str]:
|
|
"""Создаёт идентичность и выдаёт API-токен. При регистрации по почте передать email и password."""
|
|
identity = await create_identity(session, email=email, tg_id=tg_id)
|
|
if password:
|
|
identity.password_hash = await run_cpu(hash_password, password)
|
|
await session.refresh(identity)
|
|
token = await issue_token_for_identity(session, identity)
|
|
return identity, token
|
|
|
|
|
|
async def verify_identity_token(session: AsyncSession, identity_id: str, token: str) -> Identity | None:
|
|
"""Проверяет пару identity_id + token и срок действия токена; возвращает Identity или None."""
|
|
identity = await get_identity_by_id(session, identity_id)
|
|
if not identity or not identity.api_token_hash:
|
|
return None
|
|
token_hash = await run_io(hash_token, token)
|
|
if token_hash != identity.api_token_hash:
|
|
return None
|
|
if _is_token_expired(identity):
|
|
return None
|
|
return identity
|
|
|
|
|
|
async def login_by_email(session: AsyncSession, email: str, password: str) -> tuple[Identity, str] | None:
|
|
"""Вход по email и паролю: проверяет пароль, выдаёт новый токен; возвращает (identity, token) или None."""
|
|
identity = await get_identity_by_email(session, email)
|
|
if not identity:
|
|
return None
|
|
if not await run_cpu(check_password, password, identity.password_hash):
|
|
return None
|
|
token = await issue_token_for_identity(session, identity)
|
|
return identity, token
|
|
|
|
|
|
async def set_initial_password(
|
|
session: AsyncSession,
|
|
identity_id: str,
|
|
password: str,
|
|
) -> Identity | None:
|
|
identity = await get_identity_by_id(session, identity_id)
|
|
if not identity or identity.password_hash:
|
|
return None
|
|
identity.password_hash = await run_cpu(hash_password, password)
|
|
await session.refresh(identity)
|
|
return identity
|
|
|
|
|
|
async def set_password_for_identity(
|
|
session: AsyncSession,
|
|
identity_id: str,
|
|
new_password: str,
|
|
) -> Identity | None:
|
|
identity = await get_identity_by_id(session, identity_id)
|
|
if not identity:
|
|
return None
|
|
identity.password_hash = await run_cpu(hash_password, new_password)
|
|
await session.refresh(identity)
|
|
return identity
|
|
|
|
|
|
async def change_identity_password(
|
|
session: AsyncSession,
|
|
identity_id: str,
|
|
current_password: str,
|
|
new_password: str,
|
|
) -> str | None:
|
|
"""Возвращает None при успехе, иначе код: no_password | wrong_password."""
|
|
identity = await get_identity_by_id(session, identity_id)
|
|
if not identity:
|
|
return "wrong_password"
|
|
if not identity.password_hash:
|
|
return "no_password"
|
|
if not await run_cpu(check_password, current_password, identity.password_hash):
|
|
return "wrong_password"
|
|
identity.password_hash = await run_cpu(hash_password, new_password)
|
|
await session.refresh(identity)
|
|
return None
|
|
|
|
|
|
async def ensure_billing_user_for_identity(session: AsyncSession, identity: Identity) -> int:
|
|
from database.users import add_user, check_user_exists
|
|
|
|
if identity.tg_id is not None:
|
|
tid = int(identity.tg_id)
|
|
if not await check_user_exists(session, tid):
|
|
await add_user(session, tid)
|
|
ur = await session.execute(select(User).where(User.tg_id == tid).limit(1))
|
|
u = ur.scalar_one()
|
|
await session.execute(update(User).where(User.id == u.id).values(identity_id=identity.id))
|
|
return int(u.id)
|
|
res = await session.execute(select(User).where(User.identity_id == identity.id))
|
|
row = res.scalars().first()
|
|
if row is not None:
|
|
return int(row.id)
|
|
new_u = User(identity_id=identity.id, tg_id=None)
|
|
session.add(new_u)
|
|
await session.flush()
|
|
return int(new_u.id)
|
|
|
|
|
|
async def _transfer_user_data(
|
|
session: AsyncSession,
|
|
src_uid: int,
|
|
dst_uid: int,
|
|
dst_tg: int | None,
|
|
dst_identity_id: str,
|
|
) -> None:
|
|
"""Переносит все данные с src User на dst User. Dedup там, где есть уникальные ключи."""
|
|
from database.models import (
|
|
AuditEvent,
|
|
BlockedUser,
|
|
CouponUsage,
|
|
Gift,
|
|
GiftUsage,
|
|
Key,
|
|
ManualBan,
|
|
Notification,
|
|
Payment,
|
|
Referral,
|
|
ScheduledBroadcast,
|
|
TemporaryData,
|
|
WebNotification,
|
|
WebPushSubscription,
|
|
)
|
|
from database.users import invalidate_balance_cache, invalidate_profile_cache
|
|
|
|
await session.execute(update(Key).where(Key.user_id == src_uid).values(user_id=dst_uid))
|
|
await session.execute(update(Payment).where(Payment.user_id == src_uid).values(user_id=dst_uid))
|
|
|
|
await session.execute(
|
|
text(
|
|
"DELETE FROM notifications AS n1 USING notifications AS n2 "
|
|
"WHERE n1.user_id = :src AND n2.user_id = :dst AND n1.notification_type = n2.notification_type"
|
|
),
|
|
{"src": src_uid, "dst": dst_uid},
|
|
)
|
|
await session.execute(update(Notification).where(Notification.user_id == src_uid).values(user_id=dst_uid))
|
|
|
|
await session.execute(update(Gift).where(Gift.sender_user_id == src_uid).values(sender_user_id=dst_uid))
|
|
await session.execute(
|
|
update(Gift).where(Gift.recipient_user_id == src_uid).values(recipient_user_id=dst_uid)
|
|
)
|
|
|
|
await session.execute(
|
|
text(
|
|
"DELETE FROM gift_usages AS g1 USING gift_usages AS g2 "
|
|
"WHERE g1.user_id = :src AND g2.user_id = :dst AND g1.gift_id = g2.gift_id"
|
|
),
|
|
{"src": src_uid, "dst": dst_uid},
|
|
)
|
|
await session.execute(update(GiftUsage).where(GiftUsage.user_id == src_uid).values(user_id=dst_uid))
|
|
|
|
await session.execute(
|
|
text(
|
|
"DELETE FROM coupon_usages AS c1 USING coupon_usages AS c2 "
|
|
"WHERE c1.user_id = :src AND c2.user_id = :dst AND c1.coupon_id = c2.coupon_id"
|
|
),
|
|
{"src": src_uid, "dst": dst_uid},
|
|
)
|
|
await session.execute(update(CouponUsage).where(CouponUsage.user_id == src_uid).values(user_id=dst_uid))
|
|
|
|
await session.execute(update(TemporaryData).where(TemporaryData.user_id == src_uid).values(user_id=dst_uid))
|
|
|
|
await session.execute(
|
|
update(ScheduledBroadcast)
|
|
.where(ScheduledBroadcast.created_by_user_id == src_uid)
|
|
.values(created_by_user_id=dst_uid)
|
|
)
|
|
|
|
await session.execute(
|
|
text(
|
|
"DELETE FROM referrals AS r1 USING referrals AS r2 "
|
|
"WHERE r1.referred_user_id = :src AND r2.referred_user_id = :dst "
|
|
"AND r1.referrer_user_id = r2.referrer_user_id"
|
|
),
|
|
{"src": src_uid, "dst": dst_uid},
|
|
)
|
|
await session.execute(
|
|
text(
|
|
"DELETE FROM referrals AS r1 USING referrals AS r2 "
|
|
"WHERE r1.referrer_user_id = :src AND r2.referrer_user_id = :dst "
|
|
"AND r1.referred_user_id = r2.referred_user_id"
|
|
),
|
|
{"src": src_uid, "dst": dst_uid},
|
|
)
|
|
await session.execute(
|
|
update(Referral).where(Referral.referred_user_id == src_uid).values(referred_user_id=dst_uid)
|
|
)
|
|
await session.execute(
|
|
update(Referral).where(Referral.referrer_user_id == src_uid).values(referrer_user_id=dst_uid)
|
|
)
|
|
|
|
await session.execute(
|
|
update(WebPushSubscription).where(WebPushSubscription.user_id == src_uid).values(user_id=dst_uid)
|
|
)
|
|
await session.execute(
|
|
update(WebNotification).where(WebNotification.user_id == src_uid).values(user_id=dst_uid)
|
|
)
|
|
|
|
dst_ban = (await session.execute(select(ManualBan).where(ManualBan.user_id == dst_uid))).scalar_one_or_none()
|
|
src_ban = (await session.execute(select(ManualBan).where(ManualBan.user_id == src_uid))).scalar_one_or_none()
|
|
if src_ban is not None and dst_ban is None:
|
|
session.add(ManualBan(
|
|
user_id=dst_uid,
|
|
tg_id=dst_tg,
|
|
banned_at=src_ban.banned_at,
|
|
reason=src_ban.reason,
|
|
banned_by=src_ban.banned_by,
|
|
until=src_ban.until,
|
|
))
|
|
|
|
dst_block = (await session.execute(select(BlockedUser).where(BlockedUser.user_id == dst_uid))).scalar_one_or_none()
|
|
src_block = (await session.execute(select(BlockedUser).where(BlockedUser.user_id == src_uid))).scalar_one_or_none()
|
|
if src_block is not None and dst_block is None:
|
|
session.add(BlockedUser(user_id=dst_uid, tg_id=dst_tg))
|
|
|
|
if dst_tg is not None:
|
|
await session.execute(
|
|
update(AuditEvent)
|
|
.where(AuditEvent.actor_identity_id == dst_identity_id, AuditEvent.actor_tg_id.is_(None))
|
|
.values(actor_tg_id=dst_tg)
|
|
)
|
|
|
|
await refresh_tg_mirrors_for_user(session, dst_uid)
|
|
|
|
await session.execute(delete(User).where(User.id == src_uid))
|
|
await session.execute(update(User).where(User.id == dst_uid).values(identity_id=dst_identity_id))
|
|
|
|
await invalidate_balance_cache(src_uid)
|
|
await invalidate_profile_cache(src_uid)
|
|
await invalidate_balance_cache(dst_uid)
|
|
await invalidate_profile_cache(dst_uid)
|
|
|
|
|
|
async def merge_billing_user_into_telegram(session: AsyncSession, identity_id: str, telegram_tg_id: int) -> None:
|
|
from database.models import User as _User # noqa: F401
|
|
from database.access.resolution import resolve_user_optional
|
|
from database.users import update_balance
|
|
|
|
res = await session.execute(select(User).where(User.identity_id == identity_id))
|
|
rows = res.scalars().all()
|
|
if not rows:
|
|
return
|
|
billing = rows[0]
|
|
src_uid = int(billing.id)
|
|
dst_tg = int(telegram_tg_id)
|
|
if billing.tg_id is not None and int(billing.tg_id) > 0:
|
|
return
|
|
|
|
dst_u = await resolve_user_optional(session, dst_tg)
|
|
if dst_u is None:
|
|
new_u = User(
|
|
tg_id=dst_tg,
|
|
identity_id=identity_id,
|
|
username=billing.username,
|
|
first_name=billing.first_name,
|
|
last_name=billing.last_name,
|
|
language_code=billing.language_code,
|
|
is_bot=billing.is_bot or False,
|
|
balance=float(billing.balance or 0.0),
|
|
trial=int(billing.trial or 0),
|
|
preferred_currency=billing.preferred_currency or "RUB",
|
|
source_code=billing.source_code,
|
|
)
|
|
session.add(new_u)
|
|
await session.flush()
|
|
dst_uid = int(new_u.id)
|
|
else:
|
|
dst_uid = int(dst_u.id)
|
|
bal = float(billing.balance or 0.0)
|
|
if bal:
|
|
await update_balance(session, dst_uid, bal)
|
|
st = int(billing.trial or 0)
|
|
dt_r = await session.execute(select(User.trial).where(User.id == dst_uid))
|
|
dt_val = dt_r.scalar_one_or_none()
|
|
if dt_val is not None and st > int(dt_val or 0):
|
|
await session.execute(update(User).where(User.id == dst_uid).values(trial=st))
|
|
|
|
await _transfer_user_data(session, src_uid, dst_uid, dst_tg, identity_id)
|
|
|
|
|
|
async def resolve_tg_id(session: AsyncSession, identity_id: str) -> int | None:
|
|
"""По identity_id возвращает внутренний user id (users.id) для биллинга и ключей."""
|
|
identity = await get_identity_by_id(session, identity_id)
|
|
if not identity:
|
|
return None
|
|
return await ensure_billing_user_for_identity(session, identity)
|
|
|
|
|
|
async def attach_email(session: AsyncSession, identity_id: str, email: str) -> Identity | None:
|
|
"""Привязывает email к идентичности.
|
|
|
|
Если email уже занят другой identity — пытаемся смёржить.
|
|
Условия мёрджа: у занимающей identity нет tg_id ИЛИ tg_id совпадает с нашим.
|
|
Иначе — возврат None (email принадлежит другому человеку, не отдаём).
|
|
"""
|
|
identity = await get_identity_by_id(session, identity_id)
|
|
if not identity:
|
|
return None
|
|
email_clean = email.strip().lower() if email else None
|
|
if not email_clean:
|
|
return identity
|
|
existing = await get_identity_by_email(session, email_clean)
|
|
if existing and existing.id != identity_id:
|
|
our_tg = identity.tg_id
|
|
their_tg = existing.tg_id
|
|
can_merge = their_tg is None or (our_tg is not None and int(their_tg) == int(our_tg))
|
|
if not can_merge:
|
|
return None
|
|
|
|
src_user = (
|
|
await session.execute(select(User).where(User.identity_id == existing.id))
|
|
).scalars().first()
|
|
dst_uid = await ensure_billing_user_for_identity(session, identity)
|
|
dst_tg = int(identity.tg_id) if identity.tg_id is not None else None
|
|
|
|
if src_user is not None and int(src_user.id) != int(dst_uid):
|
|
from database.users import update_balance
|
|
|
|
src_uid = int(src_user.id)
|
|
bal = float(src_user.balance or 0.0)
|
|
if bal:
|
|
await update_balance(session, dst_uid, bal)
|
|
src_trial = int(src_user.trial or 0)
|
|
dst_trial_val = await session.scalar(select(User.trial).where(User.id == dst_uid))
|
|
if dst_trial_val is not None and src_trial > int(dst_trial_val or 0):
|
|
await session.execute(update(User).where(User.id == dst_uid).values(trial=src_trial))
|
|
await _transfer_user_data(session, src_uid, dst_uid, dst_tg, identity_id)
|
|
|
|
existing.email = None
|
|
await session.flush()
|
|
await session.execute(delete(Identity).where(Identity.id == existing.id))
|
|
|
|
identity.email = email_clean
|
|
await session.refresh(identity)
|
|
return identity
|
|
|
|
|
|
async def attach_telegram(session: AsyncSession, identity_id: str, tg_id: int) -> Identity | None:
|
|
"""Привязывает Telegram (tg_id) к идентичности и связывает User с identity.
|
|
|
|
Если tg_id уже висит на другой identity — пытаемся смёржить.
|
|
Условия мёрджа: у занимающей identity нет email ИЛИ email совпадает с нашим.
|
|
Иначе — возврат None (TG принадлежит другому человеку).
|
|
"""
|
|
identity = await get_identity_by_id(session, identity_id)
|
|
if not identity:
|
|
return None
|
|
existing = await get_identity_by_tg_id(session, tg_id)
|
|
if existing and existing.id != identity_id:
|
|
our_email = (str(identity.email).strip().lower() if identity.email else None)
|
|
their_email = (str(existing.email).strip().lower() if existing.email else None)
|
|
can_merge = their_email is None or (our_email is not None and their_email == our_email)
|
|
if not can_merge:
|
|
return None
|
|
existing.email = None
|
|
existing.tg_id = None
|
|
await session.flush()
|
|
await session.execute(delete(Identity).where(Identity.id == existing.id))
|
|
await session.flush()
|
|
await merge_billing_user_into_telegram(session, identity_id, tg_id)
|
|
identity = await get_identity_by_id(session, identity_id)
|
|
if not identity:
|
|
return None
|
|
identity.tg_id = tg_id
|
|
admin_row = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
|
if admin_row.scalar_one_or_none():
|
|
identity.is_admin = True
|
|
await session.execute(User.__table__.update().where(User.tg_id == tg_id).values(identity_id=identity_id))
|
|
await session.refresh(identity)
|
|
return identity
|
|
|
|
|
|
async def detach_email(session: AsyncSession, identity_id: str) -> Identity | None:
|
|
"""Отвязывает email от identity. Возвращает None если у identity не осталось
|
|
ни одного канала (email + tg_id оба пустые) — в этом случае отвязка запрещена,
|
|
иначе identity станет orphan.
|
|
"""
|
|
identity = await get_identity_by_id(session, identity_id)
|
|
if not identity:
|
|
return None
|
|
if identity.email is None:
|
|
return identity
|
|
if identity.tg_id is None:
|
|
return None
|
|
identity.email = None
|
|
identity.email_verified = False
|
|
identity.password_hash = None
|
|
await session.refresh(identity)
|
|
return identity
|
|
|
|
|
|
async def detach_telegram(session: AsyncSession, identity_id: str) -> Identity | None:
|
|
"""Отвязывает Telegram от identity. Запрещено если это единственный канал."""
|
|
identity = await get_identity_by_id(session, identity_id)
|
|
if not identity:
|
|
return None
|
|
if identity.tg_id is None:
|
|
return identity
|
|
if identity.email is None:
|
|
return None
|
|
old_tg = int(identity.tg_id)
|
|
identity.tg_id = None
|
|
identity.is_admin = False
|
|
await session.execute(
|
|
update(User).where(User.identity_id == identity_id, User.tg_id == old_tg).values(tg_id=None)
|
|
)
|
|
await session.refresh(identity)
|
|
return identity
|
|
|
|
|
|
async def get_or_create_identity_for_tg(session: AsyncSession, tg_id: int) -> Identity:
|
|
"""Для tg_id возвращает существующую идентичность или создаёт новую и привязывает User."""
|
|
identity = await get_identity_by_tg_id(session, tg_id)
|
|
if identity:
|
|
return identity
|
|
identity = Identity(tg_id=tg_id)
|
|
session.add(identity)
|
|
await session.flush()
|
|
await session.execute(User.__table__.update().where(User.tg_id == tg_id).values(identity_id=identity.id))
|
|
await session.refresh(identity)
|
|
return identity
|