- AccessService: одноразовые приглашения (128 бит, в БД только sha256, TTL 24 ч), атомарный захват приглашения (условный UPDATE — из двух сканов одного QR проходит один), антиперебор (> 5 неудач в час — молчание), отзыв (черновик и FSM удаляются), повторное приглашение - ADMIN_IDS заводятся в БД при старте; дальше — только по приглашению - AccessMiddleware: участники из БД (+ last_seen раз в 5 мин), новичкам — только /start <токен> - «👥 Участники» для админов: список, QR (segno) + ссылка, отзыв с подтверждением - один ответ на неверный/истёкший/использованный токен — без подсказок Refs #25 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
7.1 KiB
Python
175 lines
7.1 KiB
Python
"""Invites, members and revocation (SPEC §3.1, ADR-011)."""
|
|
|
|
import hashlib
|
|
import secrets
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from enum import StrEnum
|
|
from typing import Any, cast
|
|
|
|
from sqlalchemy import CursorResult, delete, func, select, update
|
|
|
|
from app.config import AccessSettings
|
|
from app.db.models import AccessAttempt, Draft, FsmState, Invite, Role, User, utcnow
|
|
from app.db.session import SessionMaker
|
|
|
|
LAST_SEEN_EVERY = timedelta(minutes=5)
|
|
ATTEMPT_WINDOW = timedelta(hours=1)
|
|
|
|
|
|
class RedeemStatus(StrEnum):
|
|
OK = "ok"
|
|
ALREADY_MEMBER = "already_member"
|
|
REJECTED = "rejected" # unknown, used or expired — one answer, no hints which tokens existed
|
|
THROTTLED = "throttled"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TgUser:
|
|
tg_id: int
|
|
full_name: str
|
|
username: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CreatedInvite:
|
|
token: str
|
|
expires_at: datetime
|
|
|
|
|
|
def hash_token(token: str) -> str:
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
|
|
class AccessService:
|
|
def __init__(self, sessions: SessionMaker, settings: AccessSettings, clock: Callable[[], datetime] = utcnow):
|
|
self._sessions = sessions
|
|
self._settings = settings
|
|
self._clock = clock
|
|
|
|
async def bootstrap_admins(self) -> None:
|
|
"""ADMIN_IDS become active admins in the DB; everybody else gets in only by invite (SPEC A-8)."""
|
|
async with self._sessions.begin() as s:
|
|
for tg_id in self._settings.admin_ids:
|
|
user = await s.get(User, tg_id)
|
|
if user is None:
|
|
s.add(User(tg_id=tg_id, full_name=f"admin {tg_id}", role=Role.ADMIN, joined_at=self._clock()))
|
|
else:
|
|
user.role, user.is_active = Role.ADMIN, True
|
|
|
|
async def get_member(self, tg_id: int) -> User | None:
|
|
async with self._sessions() as s:
|
|
user = await s.get(User, tg_id)
|
|
return user if user is not None and user.is_active else None
|
|
|
|
async def touch(self, member: User, seen: TgUser) -> None:
|
|
"""Refresh name and last-seen, at most every few minutes — not a write per update."""
|
|
now = self._clock()
|
|
last = member.last_seen_at
|
|
if (
|
|
last is not None
|
|
and now - _aware(last) < LAST_SEEN_EVERY
|
|
and (member.full_name, member.username) == (seen.full_name, seen.username)
|
|
):
|
|
return
|
|
async with self._sessions.begin() as s:
|
|
await s.execute(
|
|
update(User)
|
|
.where(User.tg_id == member.tg_id)
|
|
.values(last_seen_at=now, full_name=seen.full_name, username=seen.username)
|
|
)
|
|
|
|
async def create_invite(self, admin_id: int) -> CreatedInvite:
|
|
token = secrets.token_urlsafe(16) # 128 bits, 22 chars of [A-Za-z0-9_-]: fits the 64-char deep link payload
|
|
expires_at = self._clock() + timedelta(hours=self._settings.invite_ttl_hours)
|
|
async with self._sessions.begin() as s:
|
|
s.add(
|
|
Invite(
|
|
token_hash=hash_token(token), created_by=admin_id, created_at=self._clock(), expires_at=expires_at
|
|
)
|
|
)
|
|
return CreatedInvite(token=token, expires_at=expires_at)
|
|
|
|
async def redeem(self, token: str, who: TgUser) -> RedeemStatus:
|
|
now = self._clock()
|
|
try:
|
|
async with self._sessions.begin() as s:
|
|
existing = await s.get(User, who.tg_id)
|
|
if existing is not None and existing.is_active:
|
|
return RedeemStatus.ALREADY_MEMBER
|
|
failed = await s.scalar(
|
|
select(func.count())
|
|
.select_from(AccessAttempt)
|
|
.where(
|
|
AccessAttempt.tg_id == who.tg_id,
|
|
AccessAttempt.ok.is_(False),
|
|
AccessAttempt.at > now - ATTEMPT_WINDOW,
|
|
)
|
|
)
|
|
if (failed or 0) >= self._settings.max_failed_attempts:
|
|
return RedeemStatus.THROTTLED
|
|
|
|
invite = await s.scalar(select(Invite).where(Invite.token_hash == hash_token(token)))
|
|
if invite is None or invite.used_by is not None or _aware(invite.expires_at) <= now:
|
|
raise _Rejected
|
|
|
|
if existing is None:
|
|
s.add(
|
|
User(
|
|
tg_id=who.tg_id,
|
|
full_name=who.full_name,
|
|
username=who.username,
|
|
role=Role.MEMBER,
|
|
invited_by=invite.created_by,
|
|
joined_at=now,
|
|
last_seen_at=now,
|
|
)
|
|
)
|
|
else: # revoked earlier, invited again
|
|
existing.is_active, existing.role, existing.invited_by = True, Role.MEMBER, invite.created_by
|
|
existing.full_name, existing.username, existing.last_seen_at = who.full_name, who.username, now
|
|
await s.flush() # the user row must exist before invites.used_by points at it
|
|
|
|
# Claim atomically: of two people scanning the same QR only one UPDATE matches the row,
|
|
# the loser's transaction (with its freshly created user) is rolled back.
|
|
result = await s.execute(
|
|
update(Invite)
|
|
.where(Invite.id == invite.id, Invite.used_by.is_(None))
|
|
.values(used_by=who.tg_id, used_at=now)
|
|
)
|
|
if cast(CursorResult[Any], result).rowcount != 1:
|
|
raise _Rejected
|
|
s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=True))
|
|
except _Rejected:
|
|
async with self._sessions.begin() as s:
|
|
s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=False))
|
|
return RedeemStatus.REJECTED
|
|
return RedeemStatus.OK
|
|
|
|
async def list_members(self) -> list[User]:
|
|
async with self._sessions() as s:
|
|
rows = await s.scalars(select(User).where(User.is_active.is_(True)).order_by(User.role, User.joined_at))
|
|
return list(rows)
|
|
|
|
async def revoke(self, tg_id: int) -> bool:
|
|
"""Cut a member off at once: their updates are ignored from now on, draft and dialog state are dropped."""
|
|
async with self._sessions.begin() as s:
|
|
user = await s.get(User, tg_id)
|
|
if user is None or not user.is_active or user.role is Role.ADMIN:
|
|
return False
|
|
user.is_active = False
|
|
await s.execute(delete(Draft).where(Draft.user_id == tg_id))
|
|
# FSM keys are "bot:chat:user:…"; in a private chat chat == user
|
|
await s.execute(delete(FsmState).where(FsmState.key.like(f"%:{tg_id}:{tg_id}:%")))
|
|
return True
|
|
|
|
|
|
class _Rejected(Exception):
|
|
pass
|
|
|
|
|
|
def _aware(value: datetime) -> datetime:
|
|
# SQLite hands back naive datetimes even for timezone=True columns; everything we store is UTC.
|
|
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|