Add login lockout and encrypt Player.totp_secret/auth_token at rest

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>
This commit is contained in:
tim
2026-07-01 19:49:56 +02:00
co-authored by Claude Sonnet 5
parent 0845562a21
commit fbe0c3aa18
7 changed files with 177 additions and 12 deletions
+42 -8
View File
@@ -6,16 +6,25 @@ 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)."""
@@ -25,6 +34,25 @@ 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
@@ -34,7 +62,7 @@ def _verify_code(player: Player, code: str) -> None:
Akceptuje +-1 casovy krok (tolerancia hodin) a odmietne uz pouzity krok.
"""
totp = pyotp.TOTP(player.totp_secret)
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:
@@ -57,7 +85,7 @@ async def register_account(username: str) -> dict:
)
if existing is not None:
raise AuthError("Toto meno je už obsadené.")
session.add(Player(username=username, totp_secret=secret))
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}
@@ -74,15 +102,21 @@ async def login(username: str, code: str) -> dict:
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 or "").strip())
)
player = await session.scalar(select(Player).where(Player.username == username))
if player is None:
_register_failure(username)
raise AuthError("Účet neexistuje.")
_verify_code(player, (code or "").strip())
try:
_verify_code(player, (code or "").strip())
except AuthError:
_register_failure(username)
raise
_clear_failures(username)
token = _new_token()
player.auth_token = token
player.auth_token = crypto.hash_token(token)
await session.commit()
return {"player_id": player.id, "username": player.username, "token": token}
@@ -93,7 +127,7 @@ async def player_by_token(token: str) -> dict | None:
return None
async with async_session() as session:
player = await session.scalar(
select(Player).where(Player.auth_token == token)
select(Player).where(Player.auth_token == crypto.hash_token(token))
)
if player is None:
return None