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>
107 lines
4.1 KiB
Python
107 lines
4.1 KiB
Python
"""ORM modely: Player, Game, Guess, PageView.
|
|
|
|
`won` sa neuklada -- vyplyva z `points > 0`
|
|
(trafeny tip = 10 + tip, inak 0; pozri Round.get_points_summary v bridzik.py).
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from db.db import Base
|
|
|
|
|
|
class Player(Base):
|
|
"""Trvaly ucet hraca + autentifikacia (TOTP)."""
|
|
|
|
__tablename__ = "players"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
username: Mapped[str] = mapped_column(String(40), unique=True, index=True)
|
|
# 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
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime, server_default=func.now()
|
|
)
|
|
|
|
|
|
class Game(Base):
|
|
"""Jedna partia. Drzi priamo 4 ID hracov podla sedadla (0-3)."""
|
|
|
|
__tablename__ = "games"
|
|
|
|
id: Mapped[str] = mapped_column(
|
|
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
|
)
|
|
name: Mapped[str] = mapped_column(String(40), default="")
|
|
player0_id: Mapped[int] = mapped_column(ForeignKey("players.id"))
|
|
player1_id: Mapped[int] = mapped_column(ForeignKey("players.id"))
|
|
player2_id: Mapped[int] = mapped_column(ForeignKey("players.id"))
|
|
player3_id: Mapped[int] = mapped_column(ForeignKey("players.id"))
|
|
# Aktualna pozicia hry. Spolu s rozdanim kariet nanovo plne urcuju stav hry
|
|
# po zaciatok kola -> staci na restore (pozri api/history.rebuild_core).
|
|
series: Mapped[int] = mapped_column(Integer, default=0)
|
|
round: Mapped[int] = mapped_column(Integer, default=0)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime, server_default=func.now()
|
|
)
|
|
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
|
|
def player_id_for_seat(self, seat: int) -> int:
|
|
return (self.player0_id, self.player1_id, self.player2_id, self.player3_id)[seat]
|
|
|
|
|
|
class Guess(Base):
|
|
"""Tip a vysledok jedneho hraca v jednom kole.
|
|
|
|
Unikat (game_id, series_number, round_number, player_id) robi zapis
|
|
idempotentnym -- opakovany `record_completed_rounds` nezaklada duplikaty.
|
|
"""
|
|
|
|
__tablename__ = "guesses"
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"game_id",
|
|
"series_number",
|
|
"round_number",
|
|
"player_id",
|
|
name="uq_guess_round_player",
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
game_id: Mapped[str] = mapped_column(ForeignKey("games.id"), index=True)
|
|
player_id: Mapped[int] = mapped_column(ForeignKey("players.id"), index=True)
|
|
series_number: Mapped[int] = mapped_column(Integer)
|
|
round_number: Mapped[int] = mapped_column(Integer)
|
|
guess: Mapped[int] = mapped_column(Integer)
|
|
points: Mapped[int] = mapped_column(Integer)
|
|
|
|
|
|
class PageView(Base):
|
|
"""Jedna navsteva stranky (pre /track). Bez schema migracii -- create_all only."""
|
|
|
|
__tablename__ = "page_views"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
path: Mapped[str] = mapped_column(String(200))
|
|
referrer: Mapped[str] = mapped_column(String(300), default="")
|
|
user_agent: Mapped[str] = mapped_column(String(300), default="")
|
|
browser: Mapped[str] = mapped_column(String(40), default="")
|
|
os: Mapped[str] = mapped_column(String(40), default="")
|
|
device_type: Mapped[str] = mapped_column(String(20), default="") # mobile/tablet/pc/bot
|
|
ip: Mapped[str] = mapped_column(String(45), default="") # surova IP (IPv4/IPv6), "" ak nezname
|
|
country: Mapped[str] = mapped_column(String(2), default="") # ISO kod z GeoIP, "" ak nerozlusene
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime, server_default=func.now()
|
|
)
|