202 lines
8.0 KiB
Python
202 lines
8.0 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 core.executor import run_cpu, run_io
|
|
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 = await run_io(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 = await run_cpu(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
|
|
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 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
|