From fbe0c3aa1847b4f120c50494452ea11a04564247 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 1 Jul 2026 19:49:56 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 3 +- api/auth.py | 50 +++++++++++++++++++++---- db/crypto.py | 42 +++++++++++++++++++++ db/models.py | 4 +- docker-compose.yaml | 3 ++ requirements.txt | 1 + tests/test_history.py | 86 ++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 db/crypto.py diff --git a/CLAUDE.md b/CLAUDE.md index 86d1dbe..f1a413c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/api/auth.py b/api/auth.py index 1853673..66d4dab 100644 --- a/api/auth.py +++ b/api/auth.py @@ -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 diff --git a/db/crypto.py b/db/crypto.py new file mode 100644 index 0000000..d198b5a --- /dev/null +++ b/db/crypto.py @@ -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() diff --git a/db/models.py b/db/models.py index fb96a57..47370fd 100644 --- a/db/models.py +++ b/db/models.py @@ -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 ) diff --git a/docker-compose.yaml b/docker-compose.yaml index d8780ff..751ef10 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 diff --git a/requirements.txt b/requirements.txt index cc4d18b..70ab042 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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) diff --git a/tests/test_history.py b/tests/test_history.py index 20f55b5..dbe2fdb 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -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