Files
Solo_bot/database/identities.py
T

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