Per-username lockout (5 failed TOTP attempts / 5 min) stops account-targeted brute force regardless of source IP. Player.totp_secret is now Fernet- encrypted (ENCRYPTION_KEY env, db/crypto.py) instead of stored in plaintext, and auth_token is stored as a SHA-256 hash rather than the raw session token. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
135 lines
4.6 KiB
Python
135 lines
4.6 KiB
Python
"""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)."""
|
|
|
|
|
|
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."""
|
|
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:
|
|
raise AuthError("Toto meno je už obsadené.")
|
|
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."""
|
|
return await _verify_and_issue_token(username, code)
|
|
|
|
|
|
async def _verify_and_issue_token(username: str, code: str) -> 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.")
|
|
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}
|