Files
Solo_bot/database/identities.py
T

198 lines
7.9 KiB
Python

import hashlib
import secrets
from datetime import datetime, timedelta
import bcrypt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import API_TOKEN_TTL_DAYS
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.commit()
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 = hash_token(token)
identity.token_issued_at = datetime.utcnow()
await session.commit()
await session.refresh(identity)
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 = hash_password(password)
await session.commit()
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
if hash_token(token) != 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 or not check_password(password, identity.password_hash):
return None
token = await issue_token_for_identity(session, identity)
return identity, token
async def resolve_tg_id(session: AsyncSession, identity_id: str) -> int | None:
"""По identity_id возвращает tg_id, если привязан."""
identity = await get_identity_by_id(session, identity_id)
return identity.tg_id if identity else None
async def attach_email(session: AsyncSession, identity_id: str, email: str) -> Identity | 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:
return None
identity.email = email_clean
await session.commit()
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 в admins — выставляет is_admin."""
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:
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.commit()
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.commit()
await session.refresh(identity)
return identity