From 5b9d6342ad4989b607ba684cb0648d5f0b0fe3cb Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 4 Jul 2026 17:34:03 +0200 Subject: [PATCH] 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 --- api/__init__.py | 11 +++++- api/auth.py | 41 ++++++++++++++++++---- api/stats.py | 14 +++++++- frontend/src/pages/Auth.tsx | 12 ++++++- frontend/src/pages/admin/AdminStats.tsx | 2 ++ tests/test_history.py | 45 +++++++++++++++++++------ tests/test_stats.py | 19 ++++++++++- 7 files changed, 124 insertions(+), 20 deletions(-) diff --git a/api/__init__.py b/api/__init__.py index a6e2662..2f95570 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -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"]} diff --git a/api/auth.py b/api/auth.py index 66d4dab..9148ae1 100644 --- a/api/auth.py +++ b/api/auth.py @@ -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: diff --git a/api/stats.py b/api/stats.py index 2afd276..f35c2e7 100644 --- a/api/stats.py +++ b/api/stats.py @@ -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}, diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx index e830ba8..e87601f 100644 --- a/frontend/src/pages/Auth.tsx +++ b/frontend/src/pages/Auth.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { QRCodeSVG } from 'qrcode.react'; import { useGameStore } from '../store/gameStore'; import { emit } from '../lib/socket'; @@ -20,6 +20,15 @@ export default function Auth() { const remember = (name: string) => localStorage.setItem('bridzik_name', name.trim()); + // Server can push a registration payload even from the login tab (unfinished + // registration → re-issued QR) — always land on the tab that shows it. + useEffect(() => { + if (registration) { + setMode('register'); + setCode(''); + } + }, [registration]); + const handleLogin = (e: React.FormEvent) => { e.preventDefault(); if (!username.trim() || code.trim().length < 6) return; @@ -31,6 +40,7 @@ export default function Auth() { e.preventDefault(); if (!username.trim()) return; remember(username); + trackEvent('register'); emit.registerAccount(username.trim()); }; diff --git a/frontend/src/pages/admin/AdminStats.tsx b/frontend/src/pages/admin/AdminStats.tsx index 4a6ae2a..06cb546 100644 --- a/frontend/src/pages/admin/AdminStats.tsx +++ b/frontend/src/pages/admin/AdminStats.tsx @@ -20,6 +20,7 @@ interface DailyStats { completion_rate: number | null; avg_game_duration_minutes: number | null; total_players: number; + unconfirmed_players: number; peak_hours: Record; rounds_per_day: Record; pageviews_per_day: Record; @@ -155,6 +156,7 @@ export default function AdminStats() {
+ RegistrationIncomplete (novy QR namiesto chyby) + with self.assertRaises(auth.RegistrationIncomplete): + run(auth.login(username, "000000")) code = pyotp.TOTP(data["secret"]).now() - ident = run(auth.login(username, code)) + ident = run(auth.confirm_account(username, code)) self.assertEqual(ident["username"], username) self.assertTrue(ident["token"]) + # Po potvrdeni je uz meno obsadene + with self.assertRaises(auth.AuthError): + run(auth.register_account(username)) + # Token sa da spatne rozlustit na identitu resolved = run(auth.player_by_token(ident["token"])) self.assertEqual(resolved["player_id"], ident["player_id"]) @@ -83,29 +96,41 @@ class HistoryCase(unittest.TestCase): with self.assertRaises(auth.AuthError): run(auth.login(username, "000000")) + def test_reissued_secret_invalidates_old_qr(self): + username = "fred_" + uuid.uuid4().hex[:6] + old = run(auth.register_account(username)) + new = run(auth.register_account(username)) + self.assertNotEqual(old["secret"], new["secret"]) + + # Kod zo stareho QR uz neplati, z noveho ano + with self.assertRaises(auth.AuthError): + run(auth.confirm_account(username, pyotp.TOTP(old["secret"]).now())) + ident = run(auth.confirm_account(username, pyotp.TOTP(new["secret"]).now())) + self.assertEqual(ident["username"], username) + def test_login_lockout_after_repeated_failures(self): username = "bob_" + uuid.uuid4().hex[:6] data = run(auth.register_account(username)) + run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now())) 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)) + run(auth.login(username, self._next_step_code(data["secret"]))) def test_successful_login_clears_failed_attempts(self): username = "carol_" + uuid.uuid4().hex[:6] data = run(auth.register_account(username)) + run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now())) 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)) + ident = run(auth.login(username, self._next_step_code(data["secret"]))) self.assertEqual(ident["username"], username) self.assertNotIn(username, auth._failed_attempts) @@ -241,7 +266,7 @@ class HistoryCase(unittest.TestCase): 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())) + ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now())) async def _raw_token(): async with async_session() as session: diff --git a/tests/test_stats.py b/tests/test_stats.py index e5e326e..06011c6 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -58,7 +58,7 @@ class StatsCase(unittest.TestCase): for _ in range(n): username = "u_" + uuid.uuid4().hex[:8] data = run(auth.register_account(username)) - ident = run(auth.login(username, pyotp.TOTP(data["secret"]).now())) + ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now())) ids.append(ident["player_id"]) return ids @@ -319,6 +319,23 @@ class StatsCase(unittest.TestCase): self.assertGreaterEqual(sum(data["games_per_day"].values()), 1) self.assertGreaterEqual(sum(data["rounds_per_day"].values()), 4) + def test_unconfirmed_players_counted_separately(self): + before = run(stats.get_daily_stats()) + + # Registracia bez potvrdenia kodu -> nedokonceny ucet + username = "ghost_" + uuid.uuid4().hex[:8] + data = run(auth.register_account(username)) + + after = run(stats.get_daily_stats()) + self.assertEqual(after["total_players"], before["total_players"]) + self.assertEqual(after["unconfirmed_players"], before["unconfirmed_players"] + 1) + + # Po potvrdeni sa presunie medzi potvrdenych hracov + run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now())) + confirmed = run(stats.get_daily_stats()) + self.assertEqual(confirmed["total_players"], before["total_players"] + 1) + self.assertEqual(confirmed["unconfirmed_players"], before["unconfirmed_players"]) + def test_empty_referrer_excluded_from_top_referrers(self): run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA)) data = run(stats.get_daily_stats())