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