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
+2 -1
View File
@@ -51,8 +51,9 @@ Serialization: `Card.JSONEncoder` flattens a `Card` to `{color, value}` name str
Async SQLAlchemy 2.0, independent of Socket.IO (mirrors how the engine is kept clean).
- **`db/db.py`** — async `engine` + `async_sessionmaker`, declarative `Base`, and `init_db()` (`create_all`). Connection string from env **`DATABASE_URL`** (default `sqlite+aiosqlite:///bridzik.db`; Docker sets PostgreSQL via `asyncpg`). There are **no migrations**`create_all` only adds new tables, so a changed column needs a fresh DB.
- **`db/crypto.py`** — encryption/hashing for sensitive `Player` columns. `encrypt`/`decrypt` (Fernet, key from env **`ENCRYPTION_KEY`** — must stay stable, losing it locks out every account) for `totp_secret`; `hash_token` (SHA-256, one-way) for `auth_token`.
- **`db/models.py`** — 3 tables:
- **`Player`** — account + auth: `username` (unique login), `totp_secret`, `totp_last_step` (TOTP replay guard), `auth_token` (session token for reconnect).
- **`Player`** — account + auth: `username` (unique login), `totp_secret` (Fernet-encrypted at rest via `db/crypto.py`), `totp_last_step` (TOTP replay guard), `auth_token` (session token for reconnect, stored as a SHA-256 hash, not plaintext).
- **`Game`** — one match: `id` (gid), 4 `playerN_id` seats, `name`, `series`/`round` (current position, used for restore), `created_at`, `ended_at`.
- **`Guess`** — one player's bid+result in a round: `series_number`, `round_number`, `guess`, `points`. `won` is derived (`points > 0`). Unique on (game, series, round, player) → idempotent writes.
+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
+42
View File
@@ -0,0 +1,42 @@
"""Sifrovanie/hashovanie citlivych stlpcov (Player.totp_secret, Player.auth_token).
totp_secret sa musi dat spatne desifrovat (treba ho na vygenerovanie/overenie
TOTP kodu), preto Fernet -- symetricke sifrovanie s klucom z env ENCRYPTION_KEY.
auth_token sa iba porovnava, nikdy nepotrebujeme povodnu hodnotu spat, preto
staci jednosmerny SHA-256 hash (token ma 384 bitov entropie z
secrets.token_urlsafe(48) v api/auth.py, takze netreba salt/pepper).
"""
import hashlib
import os
from cryptography.fernet import Fernet, InvalidToken
_ENV_VAR = "ENCRYPTION_KEY"
def _fernet() -> Fernet:
key = os.environ.get(_ENV_VAR)
if not key:
raise RuntimeError(
f"{_ENV_VAR} nie je nastaveny. Vygeneruj ho pomocou:\n"
' python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"\n'
"a nastav ako env premennu (drz ho stabilny -- zmena znamena, "
"ze existujuce totp_secret sa uz nedaju desifrovat)."
)
return Fernet(key.encode())
def encrypt(plaintext: str) -> str:
return _fernet().encrypt(plaintext.encode()).decode()
def decrypt(ciphertext: str) -> str:
try:
return _fernet().decrypt(ciphertext.encode()).decode()
except InvalidToken as exc:
raise ValueError("Neplatny alebo poskodeny sifrovany udaj.") from exc
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
+3 -1
View File
@@ -20,10 +20,12 @@ class Player(Base):
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(40), unique=True, index=True)
totp_secret: Mapped[str] = mapped_column(String(32))
# Sifrovany cez db/crypto.py (Fernet) -- nikdy neuklada plaintext secret.
totp_secret: Mapped[str] = mapped_column(String(255))
# Posledny pouzity TOTP casovy krok -- ochrana proti replay v ramci okna.
totp_last_step: Mapped[int] = mapped_column(Integer, default=0)
# Session token pre auto-reconnect (poslany v Socket.IO `auth`).
# Uklada sa SHA-256 hash (db/crypto.hash_token), nie surovy token.
auth_token: Mapped[str | None] = mapped_column(
String(64), unique=True, nullable=True
)
+3
View File
@@ -22,6 +22,9 @@ services:
DATABASE_URL: postgresql+asyncpg://bridzik:bridzik@db:5432/bridzik
# Shared secret for the self-hosted /api/admin/* stats endpoints.
ADMIN_TOKEN: tajneheslo
# Dev-only Fernet key encrypting Player.totp_secret -- fine to hardcode
# here since the dev DB is disposable (docker-compose down -v).
ENCRYPTION_KEY: FAMD5i_Pc-Ursu_Bi49ZYMN2ehhfBkjjehxTOFvNrBU=
# Optional: IP -> country for /api/track. Drop a .mmdb file (GeoLite2 or
# a DB-IP/IP2Location Lite equivalent) at ./geoip/ -- it's already inside
# the ./:/app bind mount below, no extra volume entry needed. Missing
+1
View File
@@ -8,6 +8,7 @@ SQLAlchemy[asyncio]>=2.0
aiosqlite>=0.20 # dev / default DATABASE_URL
asyncpg>=0.29 # production (PostgreSQL)
pyotp>=2.9 # TOTP login
cryptography>=42 # Fernet encryption for Player.totp_secret at rest
user-agents>=2.2 # parse User-Agent for /track (self-hosted analytics)
geoip2>=4.8 # resolve IP -> country from a local .mmdb file (no external calls)
+84 -2
View File
@@ -12,14 +12,21 @@ import unittest
import uuid
from types import SimpleNamespace
# Nastav DB PRED importom db/api modulov -- engine sa vytvara pri importe.
# Nastav DB/ENCRYPTION_KEY PRED importom db/api modulov -- engine sa vytvara pri importe.
_DB_FILE = os.path.join(tempfile.gettempdir(), f"bridzik_test_{uuid.uuid4().hex}.db")
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///" + _DB_FILE.replace("\\", "/")
from cryptography.fernet import Fernet # noqa: E402
os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode())
import pyotp # noqa: E402
from sqlalchemy import select # noqa: E402
from api import auth, history # noqa: E402
from db.db import init_db # noqa: E402
from db import crypto # noqa: E402
from db.db import async_session, init_db # noqa: E402
from db.models import Player # noqa: E402
def run(coro):
@@ -76,6 +83,32 @@ class HistoryCase(unittest.TestCase):
with self.assertRaises(auth.AuthError):
run(auth.login(username, "000000"))
def test_login_lockout_after_repeated_failures(self):
username = "bob_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username))
for _ in range(auth._LOGIN_ATTEMPT_LIMIT):
with self.assertRaises(auth.AuthError):
run(auth.login(username, "000000"))
# Lockout odmietne aj spravny kod, kym neubehne okno
code = pyotp.TOTP(data["secret"]).now()
with self.assertRaises(auth.AuthError):
run(auth.login(username, code))
def test_successful_login_clears_failed_attempts(self):
username = "carol_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username))
for _ in range(auth._LOGIN_ATTEMPT_LIMIT - 1):
with self.assertRaises(auth.AuthError):
run(auth.login(username, "000000"))
code = pyotp.TOTP(data["secret"]).now()
ident = run(auth.login(username, code))
self.assertEqual(ident["username"], username)
self.assertNotIn(username, auth._failed_attempts)
def test_record_rounds_and_idempotency(self):
ids = self._make_players()
gid = str(uuid.uuid4())
@@ -191,6 +224,55 @@ class HistoryCase(unittest.TestCase):
self.assertEqual(mine["my_points"], 12)
self.assertEqual(len(mine["players"]), 4)
def test_totp_secret_stored_encrypted_not_plaintext(self):
username = "dave_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username))
async def _raw_secret():
async with async_session() as session:
return await session.scalar(
select(Player.totp_secret).where(Player.username == username)
)
stored = run(_raw_secret())
self.assertNotEqual(stored, data["secret"])
self.assertEqual(crypto.decrypt(stored), data["secret"])
def test_auth_token_stored_hashed_not_plaintext(self):
username = "erin_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username))
ident = run(auth.login(username, pyotp.TOTP(data["secret"]).now()))
async def _raw_token():
async with async_session() as session:
return await session.scalar(
select(Player.auth_token).where(Player.username == username)
)
stored = run(_raw_token())
self.assertNotEqual(stored, ident["token"])
self.assertEqual(stored, crypto.hash_token(ident["token"]))
class CryptoCase(unittest.TestCase):
def test_encrypt_decrypt_roundtrip(self):
secret = "JBSWY3DPEHPK3PXP"
ciphertext = crypto.encrypt(secret)
self.assertNotEqual(ciphertext, secret)
self.assertEqual(crypto.decrypt(ciphertext), secret)
def test_hash_token_is_deterministic_and_distinct(self):
self.assertEqual(crypto.hash_token("abc"), crypto.hash_token("abc"))
self.assertNotEqual(crypto.hash_token("abc"), crypto.hash_token("abd"))
def test_missing_key_raises(self):
saved = os.environ.pop("ENCRYPTION_KEY")
try:
with self.assertRaises(RuntimeError):
crypto.encrypt("x")
finally:
os.environ["ENCRYPTION_KEY"] = saved
def tearDownModule():
from db.db import engine