"""Bezheslova autentifikacia cez TOTP (Google Authenticator a pod.). Domenova logika nad `db/` -- tenke socket handlery v api/__init__.py ju volaju. Hodnoty sa overuju cez pyotp; replay sa bloku pomocou Player.totp_last_step. """ import secrets import time from collections import defaultdict import pyotp from sqlalchemy import select from db import crypto from db.db import async_session from db.models import Player ISSUER = "Bridžik" TOTP_PERIOD = 30 # sekund -- default pyotp # Lockout na neuspesne prihlasovacie pokusy, per username, len v pamati # procesu (rovnaky pattern ako games/sessions/accounts v api/__init__.py -- # proces je jediny, ziadny Redis). _LOGIN_ATTEMPT_LIMIT = 5 _LOGIN_ATTEMPT_WINDOW = 300 # sekund _failed_attempts: dict[str, list[float]] = defaultdict(list) class AuthError(Exception): """Chyba prihlasenia/registracie (slovenska sprava pre klienta).""" class RegistrationIncomplete(AuthError): """Ucet existuje, ale registracia nebola nikdy potvrdena kodom. Handler v api/__init__.py na nu reaguje novym QR kodom namiesto chyby. """ def _is_unconfirmed(player: Player) -> bool: """Ucet, ktory nikdy neoveril TOTP kod (confirm_account nastavuje oboje).""" return player.auth_token is None and player.totp_last_step == 0 def _new_token() -> str: return secrets.token_urlsafe(48) def _check_lockout(username: str) -> None: cutoff = time.monotonic() - _LOGIN_ATTEMPT_WINDOW attempts = [t for t in _failed_attempts.get(username, []) if t > cutoff] if attempts: _failed_attempts[username] = attempts else: _failed_attempts.pop(username, None) if len(attempts) >= _LOGIN_ATTEMPT_LIMIT: raise AuthError("Príliš veľa neúspešných pokusov. Skúste to znova o pár minút.") def _register_failure(username: str) -> None: _failed_attempts[username].append(time.monotonic()) def _clear_failures(username: str) -> None: _failed_attempts.pop(username, None) def _current_step() -> int: return int(time.time()) // TOTP_PERIOD def _verify_code(player: Player, code: str) -> None: """Overi TOTP kod a posunie totp_last_step. Pri neuspechu vyhodi AuthError. Akceptuje +-1 casovy krok (tolerancia hodin) a odmietne uz pouzity krok. """ totp = pyotp.TOTP(crypto.decrypt(player.totp_secret)) current = _current_step() for step in (current - 1, current, current + 1): if step <= player.totp_last_step: continue if totp.verify(code, for_time=step * TOTP_PERIOD): player.totp_last_step = step return raise AuthError("Nesprávny alebo už použitý kód.") async def register_account(username: str) -> dict: """Zaregistruje meno a vygeneruje TOTP secret. Vrati otpauth URI pre QR. Nedokoncenu registraciu (meno existuje, ale kod nebol nikdy potvrdeny) prepise novym secretom -- povodny QR kod tym prestane platit. """ username = (username or "").strip() if not username: raise AuthError("Zadajte meno.") secret = pyotp.random_base32() async with async_session() as session: existing = await session.scalar( select(Player).where(Player.username == username) ) if existing is not None and not _is_unconfirmed(existing): raise AuthError("Toto meno je už obsadené.") if existing is not None: existing.totp_secret = crypto.encrypt(secret) else: session.add(Player(username=username, totp_secret=crypto.encrypt(secret))) await session.commit() otpauth_uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=ISSUER) return {"username": username, "secret": secret, "otpauth_uri": otpauth_uri} async def confirm_account(username: str, code: str) -> dict: """Potvrdi registraciu prvym kodom z aplikacie a vrati session token.""" return await _verify_and_issue_token(username, code) async def login(username: str, code: str) -> dict: """Prihlasi existujuci ucet a vrati session token. Pre nedokoncenu registraciu vyhodi RegistrationIncomplete namiesto overovania kodu -- pouzivatel bez naskenovaneho QR ziadny kod nema. """ return await _verify_and_issue_token(username, code, unconfirmed_ok=False) async def _verify_and_issue_token( username: str, code: str, *, unconfirmed_ok: bool = True ) -> dict: username = (username or "").strip() _check_lockout(username) async with async_session() as session: player = await session.scalar(select(Player).where(Player.username == username)) if player is None: _register_failure(username) raise AuthError("Účet neexistuje.") if not unconfirmed_ok and _is_unconfirmed(player): raise RegistrationIncomplete( "Registrácia nie je dokončená — naskenuj QR kód a potvrď prvým kódom." ) try: _verify_code(player, (code or "").strip()) except AuthError: _register_failure(username) raise _clear_failures(username) token = _new_token() player.auth_token = crypto.hash_token(token) await session.commit() return {"player_id": player.id, "username": player.username, "token": token} async def player_by_token(token: str) -> dict | None: """Overi session token (z Socket.IO `auth`). Vrati identitu alebo None.""" if not token: return None async with async_session() as session: player = await session.scalar( select(Player).where(Player.auth_token == crypto.hash_token(token)) ) if player is None: return None return {"player_id": player.id, "username": player.username}