Files
Solo_bot/database/identities.py
T

710 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_google_sub(session: AsyncSession, google_sub: str) -> Identity | None:
"""Возвращает идентичность по Google `sub` (стабильный ID пользователя от Google)."""
if not google_sub:
return None
result = await session.execute(select(Identity).where(Identity.google_sub == google_sub))
return result.scalar_one_or_none()
async def get_or_create_identity_for_google(
session: AsyncSession,
google_sub: str,
email: str | None = None,
) -> Identity:
"""Для Google `sub` возвращает существующую идентичность или создаёт новую.
Логика мёрджа:
1. Если identity с этим google_sub уже есть — возвращаем её.
2. Если нет, но есть identity с таким же email (верифицированный или нет) — подхватываем её, пишем google_sub, ставим email_verified=True.
3. Иначе создаём новую identity с google_sub + email.
"""
identity = await get_identity_by_google_sub(session, google_sub)
if identity:
if email and not identity.email:
email_clean = email.strip().lower()
if email_clean and not await get_identity_by_email(session, email_clean):
identity.email = email_clean
identity.email_verified = True
await session.flush()
return identity
if email:
email_clean = email.strip().lower()
existing_by_email = await get_identity_by_email(session, email_clean)
if existing_by_email and existing_by_email.google_sub is None:
existing_by_email.google_sub = google_sub
existing_by_email.email_verified = True
await session.flush()
await session.refresh(existing_by_email)
return existing_by_email
identity = Identity(
google_sub=google_sub,
email=(email.strip().lower() if email else None),
email_verified=bool(email),
)
session.add(identity)
await session.flush()
await session.refresh(identity)
return identity
async def attach_google(
session: AsyncSession,
identity_id: str,
google_sub: str,
) -> Identity | None:
"""Привязывает Google-аккаунт к существующей identity.
Возвращает None, если этот google_sub уже привязан к другой identity.
"""
identity = await get_identity_by_id(session, identity_id)
if not identity:
return None
if identity.google_sub == google_sub:
return identity
existing = await get_identity_by_google_sub(session, google_sub)
if existing and existing.id != identity_id:
return None
identity.google_sub = google_sub
await session.flush()
await session.refresh(identity)
return identity
async def detach_google(session: AsyncSession, identity_id: str) -> Identity | None:
"""Отвязывает Google от identity. Запрещено если это единственный канал."""
identity = await get_identity_by_id(session, identity_id)
if not identity:
return None
if identity.google_sub is None:
return identity
if identity.email is None and identity.tg_id is None:
return None
identity.google_sub = None
await session.flush()
await session.refresh(identity)
return identity
async def get_identity_by_yandex_sub(session: AsyncSession, yandex_sub: str) -> Identity | None:
"""Возвращает идентичность по Яндекс ID (поле `id` из https://login.yandex.ru/info)."""
if not yandex_sub:
return None
result = await session.execute(select(Identity).where(Identity.yandex_sub == yandex_sub))
return result.scalar_one_or_none()
async def get_or_create_identity_for_yandex(
session: AsyncSession,
yandex_sub: str,
email: str | None = None,
) -> Identity:
"""Для Яндекс `id` возвращает существующую идентичность или создаёт новую.
Мёрджится с existing identity по email, если тот свободен. Email от Яндекса
считается верифицированным (Яндекс не отдаёт default_email без подтверждения).
"""
identity = await get_identity_by_yandex_sub(session, yandex_sub)
if identity:
if email and not identity.email:
email_clean = email.strip().lower()
if email_clean and not await get_identity_by_email(session, email_clean):
identity.email = email_clean
identity.email_verified = True
await session.flush()
return identity
if email:
email_clean = email.strip().lower()
existing_by_email = await get_identity_by_email(session, email_clean)
if existing_by_email and existing_by_email.yandex_sub is None:
existing_by_email.yandex_sub = yandex_sub
existing_by_email.email_verified = True
await session.flush()
await session.refresh(existing_by_email)
return existing_by_email
identity = Identity(
yandex_sub=yandex_sub,
email=(email.strip().lower() if email else None),
email_verified=bool(email),
)
session.add(identity)
await session.flush()
await session.refresh(identity)
return identity
async def attach_yandex(
session: AsyncSession,
identity_id: str,
yandex_sub: str,
) -> Identity | None:
"""Привязывает Яндекс-аккаунт к existing identity. None если yandex_sub занят чужой identity."""
identity = await get_identity_by_id(session, identity_id)
if not identity:
return None
if identity.yandex_sub == yandex_sub:
return identity
existing = await get_identity_by_yandex_sub(session, yandex_sub)
if existing and existing.id != identity_id:
return None
identity.yandex_sub = yandex_sub
await session.flush()
await session.refresh(identity)
return identity
async def detach_yandex(session: AsyncSession, identity_id: str) -> Identity | None:
"""Отвязывает Яндекс от identity. Запрещено если это единственный канал."""
identity = await get_identity_by_id(session, identity_id)
if not identity:
return None
if identity.yandex_sub is None:
return identity
if identity.email is None and identity.tg_id is None and identity.google_sub is None:
return None
identity.yandex_sub = None
await session.flush()
await session.refresh(identity)
return identity
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:
if row.tg_id is None:
synthetic = -int(row.id)
await session.execute(update(User).where(User.id == row.id).values(tg_id=synthetic))
return int(row.id)
new_u = User(identity_id=identity.id, tg_id=None)
session.add(new_u)
await session.flush()
synthetic = -int(new_u.id)
await session.execute(update(User).where(User.id == new_u.id).values(tg_id=synthetic))
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.access.resolution import resolve_user_optional
from database.models import User as _User # noqa: F401
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."""
is_admin = (await session.execute(select(Admin).where(Admin.tg_id == tg_id))).scalar_one_or_none() is not None
identity = await get_identity_by_tg_id(session, tg_id)
if identity:
if is_admin and not identity.is_admin:
identity.is_admin = True
await session.flush()
await session.refresh(identity)
return identity
identity = Identity(tg_id=tg_id, is_admin=is_admin)
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