Auth: nedokoncena registracia sa da dokoncit novym QR kodom

Ucet bez potvrdeneho kodu (auth_token is NULL a totp_last_step == 0)
uz neblokuje meno: opakovany register_account vyda novy secret (stary
QR prestane platit) a login vyhodi RegistrationIncomplete, na ktoru
server odpovie novym QR -- klient sa prepne na registracny tab.
Admin statistiky vykazuju nedokoncene registracie osobitne, Hraci
celkom pocita len potvrdene ucty. Novy event register v analytike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tim
2026-07-04 17:34:03 +02:00
co-authored by Claude Fable 5
parent 53274607b1
commit 5b9d6342ad
7 changed files with 124 additions and 20 deletions
+10 -1
View File
@@ -14,7 +14,7 @@ from bridzik import Bridzik, BridzikException, Card
from db.db import init_db
from api import auth as auth_module, history
from api import stats as stats_module
from api.auth import AuthError
from api.auth import AuthError, RegistrationIncomplete
def _env_bool(name: str, default: bool) -> bool:
@@ -415,6 +415,15 @@ async def confirm_account(sid, username, code):
async def login(sid, username, code):
try:
identity = await auth_module.login(username, code)
except RegistrationIncomplete as exc:
# Nedokoncena registracia -> vydame novy QR kod, klient sa prepne
# na registracny tab a pouzivatel ju moze dokoncit.
try:
data = await auth_module.register_account(username)
except AuthError as exc2:
return await send_error(sid, str(exc2))
await sio.emit("register_account", data, to=sid)
return await send_error(sid, str(exc))
except AuthError as exc:
return await send_error(sid, str(exc))
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
+35 -6
View File
@@ -30,6 +30,18 @@ class AuthError(Exception):
"""Chyba prihlasenia/registracie (slovenska sprava pre klienta)."""
class RegistrationIncomplete(AuthError):
"""Ucet existuje, ale registracia nebola nikdy potvrdena kodom.
Handler v api/__init__.py na nu reaguje novym QR kodom namiesto chyby.
"""
def _is_unconfirmed(player: Player) -> bool:
"""Ucet, ktory nikdy neoveril TOTP kod (confirm_account nastavuje oboje)."""
return player.auth_token is None and player.totp_last_step == 0
def _new_token() -> str:
return secrets.token_urlsafe(48)
@@ -74,7 +86,11 @@ def _verify_code(player: Player, code: str) -> None:
async def register_account(username: str) -> dict:
"""Zaregistruje meno a vygeneruje TOTP secret. Vrati otpauth URI pre QR."""
"""Zaregistruje meno a vygeneruje TOTP secret. Vrati otpauth URI pre QR.
Nedokoncenu registraciu (meno existuje, ale kod nebol nikdy potvrdeny)
prepise novym secretom -- povodny QR kod tym prestane platit.
"""
username = (username or "").strip()
if not username:
raise AuthError("Zadajte meno.")
@@ -83,9 +99,12 @@ async def register_account(username: str) -> dict:
existing = await session.scalar(
select(Player).where(Player.username == username)
)
if existing is not None:
if existing is not None and not _is_unconfirmed(existing):
raise AuthError("Toto meno je už obsadené.")
session.add(Player(username=username, totp_secret=crypto.encrypt(secret)))
if existing is not None:
existing.totp_secret = crypto.encrypt(secret)
else:
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}
@@ -97,11 +116,17 @@ async def confirm_account(username: str, code: str) -> dict:
async def login(username: str, code: str) -> dict:
"""Prihlasi existujuci ucet a vrati session token."""
return await _verify_and_issue_token(username, code)
"""Prihlasi existujuci ucet a vrati session token.
Pre nedokoncenu registraciu vyhodi RegistrationIncomplete namiesto
overovania kodu -- pouzivatel bez naskenovaneho QR ziadny kod nema.
"""
return await _verify_and_issue_token(username, code, unconfirmed_ok=False)
async def _verify_and_issue_token(username: str, code: str) -> dict:
async def _verify_and_issue_token(
username: str, code: str, *, unconfirmed_ok: bool = True
) -> dict:
username = (username or "").strip()
_check_lockout(username)
async with async_session() as session:
@@ -109,6 +134,10 @@ async def _verify_and_issue_token(username: str, code: str) -> dict:
if player is None:
_register_failure(username)
raise AuthError("Účet neexistuje.")
if not unconfirmed_ok and _is_unconfirmed(player):
raise RegistrationIncomplete(
"Registrácia nie je dokončená — naskenuj QR kód a potvrď prvým kódom."
)
try:
_verify_code(player, (code or "").strip())
except AuthError:
+13 -1
View File
@@ -228,7 +228,18 @@ async def get_daily_stats(logged_in_only: bool = False) -> dict:
)
).scalar()
total_players = (await session.execute(select(func.count(Player.id)))).scalar()
# Nedokoncena registracia = kod nebol nikdy potvrdeny (SQL obdoba
# api/auth._is_unconfirmed). "Hraci celkom" pocita len potvrdene ucty,
# nedokoncene sa vykazuju osobitne.
unconfirmed = Player.auth_token.is_(None) & (Player.totp_last_step == 0)
total_players, unconfirmed_players = (
await session.execute(
select(
func.count(Player.id).filter(~unconfirmed),
func.count(Player.id).filter(unconfirmed),
)
)
).one()
peak_hours = (
await session.execute(
@@ -320,6 +331,7 @@ async def get_daily_stats(logged_in_only: bool = False) -> dict:
"completion_rate": finished / total if total else None,
"avg_game_duration_minutes": (avg_duration / 60) if avg_duration else None,
"total_players": total_players,
"unconfirmed_players": unconfirmed_players,
"peak_hours": {int(r.h): r.n for r in peak_hours},
"rounds_per_day": {str(r.day): r.n for r in rounds_rows},
"pageviews_per_day": {str(r.day): r.n for r in pageview_rows},