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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user