feat(bot): доступ по QR-приглашениям, раздел «Участники», отзыв доступа
- 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>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user