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