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). 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/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: - **`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`. - **`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. - **`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 secrets
import time import time
from collections import defaultdict
import pyotp import pyotp
from sqlalchemy import select from sqlalchemy import select
from db import crypto
from db.db import async_session from db.db import async_session
from db.models import Player from db.models import Player
ISSUER = "Bridžik" ISSUER = "Bridžik"
TOTP_PERIOD = 30 # sekund -- default pyotp 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): class AuthError(Exception):
"""Chyba prihlasenia/registracie (slovenska sprava pre klienta).""" """Chyba prihlasenia/registracie (slovenska sprava pre klienta)."""
@@ -25,6 +34,25 @@ def _new_token() -> str:
return secrets.token_urlsafe(48) 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: def _current_step() -> int:
return int(time.time()) // TOTP_PERIOD 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. 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() current = _current_step()
for step in (current - 1, current, current + 1): for step in (current - 1, current, current + 1):
if step <= player.totp_last_step: if step <= player.totp_last_step:
@@ -57,7 +85,7 @@ async def register_account(username: str) -> dict:
) )
if existing is not None: if existing is not None:
raise AuthError("Toto meno je už obsadené.") 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() await session.commit()
otpauth_uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=ISSUER) otpauth_uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=ISSUER)
return {"username": username, "secret": secret, "otpauth_uri": otpauth_uri} 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: async def _verify_and_issue_token(username: str, code: str) -> dict:
username = (username or "").strip()
_check_lockout(username)
async with async_session() as session: async with async_session() as session:
player = await session.scalar( player = await session.scalar(select(Player).where(Player.username == username))
select(Player).where(Player.username == (username or "").strip())
)
if player is None: if player is None:
_register_failure(username)
raise AuthError("Účet neexistuje.") 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() token = _new_token()
player.auth_token = token player.auth_token = crypto.hash_token(token)
await session.commit() await session.commit()
return {"player_id": player.id, "username": player.username, "token": token} 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 return None
async with async_session() as session: async with async_session() as session:
player = await session.scalar( 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: if player is None:
return 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) id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(40), unique=True, index=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. # Posledny pouzity TOTP casovy krok -- ochrana proti replay v ramci okna.
totp_last_step: Mapped[int] = mapped_column(Integer, default=0) totp_last_step: Mapped[int] = mapped_column(Integer, default=0)
# Session token pre auto-reconnect (poslany v Socket.IO `auth`). # 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( auth_token: Mapped[str | None] = mapped_column(
String(64), unique=True, nullable=True String(64), unique=True, nullable=True
) )
+3
View File
@@ -22,6 +22,9 @@ services:
DATABASE_URL: postgresql+asyncpg://bridzik:bridzik@db:5432/bridzik DATABASE_URL: postgresql+asyncpg://bridzik:bridzik@db:5432/bridzik
# Shared secret for the self-hosted /api/admin/* stats endpoints. # Shared secret for the self-hosted /api/admin/* stats endpoints.
ADMIN_TOKEN: tajneheslo 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 # Optional: IP -> country for /api/track. Drop a .mmdb file (GeoLite2 or
# a DB-IP/IP2Location Lite equivalent) at ./geoip/ -- it's already inside # a DB-IP/IP2Location Lite equivalent) at ./geoip/ -- it's already inside
# the ./:/app bind mount below, no extra volume entry needed. Missing # 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 aiosqlite>=0.20 # dev / default DATABASE_URL
asyncpg>=0.29 # production (PostgreSQL) asyncpg>=0.29 # production (PostgreSQL)
pyotp>=2.9 # TOTP login 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) 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) 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 import uuid
from types import SimpleNamespace 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") _DB_FILE = os.path.join(tempfile.gettempdir(), f"bridzik_test_{uuid.uuid4().hex}.db")
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///" + _DB_FILE.replace("\\", "/") 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 import pyotp # noqa: E402
from sqlalchemy import select # noqa: E402
from api import auth, history # 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): def run(coro):
@@ -76,6 +83,32 @@ class HistoryCase(unittest.TestCase):
with self.assertRaises(auth.AuthError): with self.assertRaises(auth.AuthError):
run(auth.login(username, "000000")) 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): def test_record_rounds_and_idempotency(self):
ids = self._make_players() ids = self._make_players()
gid = str(uuid.uuid4()) gid = str(uuid.uuid4())
@@ -191,6 +224,55 @@ class HistoryCase(unittest.TestCase):
self.assertEqual(mine["my_points"], 12) self.assertEqual(mine["my_points"], 12)
self.assertEqual(len(mine["players"]), 4) 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(): def tearDownModule():
from db.db import engine from db.db import engine