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:
+10
-1
@@ -14,7 +14,7 @@ from bridzik import Bridzik, BridzikException, Card
|
|||||||
from db.db import init_db
|
from db.db import init_db
|
||||||
from api import auth as auth_module, history
|
from api import auth as auth_module, history
|
||||||
from api import stats as stats_module
|
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:
|
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):
|
async def login(sid, username, code):
|
||||||
try:
|
try:
|
||||||
identity = await auth_module.login(username, code)
|
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:
|
except AuthError as exc:
|
||||||
return await send_error(sid, str(exc))
|
return await send_error(sid, str(exc))
|
||||||
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
||||||
|
|||||||
+34
-5
@@ -30,6 +30,18 @@ class AuthError(Exception):
|
|||||||
"""Chyba prihlasenia/registracie (slovenska sprava pre klienta)."""
|
"""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:
|
def _new_token() -> str:
|
||||||
return secrets.token_urlsafe(48)
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
@@ -74,7 +86,11 @@ def _verify_code(player: Player, code: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def register_account(username: str) -> dict:
|
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()
|
username = (username or "").strip()
|
||||||
if not username:
|
if not username:
|
||||||
raise AuthError("Zadajte meno.")
|
raise AuthError("Zadajte meno.")
|
||||||
@@ -83,8 +99,11 @@ async def register_account(username: str) -> dict:
|
|||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(Player).where(Player.username == username)
|
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é.")
|
raise AuthError("Toto meno je už obsadené.")
|
||||||
|
if existing is not None:
|
||||||
|
existing.totp_secret = crypto.encrypt(secret)
|
||||||
|
else:
|
||||||
session.add(Player(username=username, totp_secret=crypto.encrypt(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)
|
||||||
@@ -97,11 +116,17 @@ async def confirm_account(username: str, code: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def login(username: str, code: str) -> dict:
|
async def login(username: str, code: str) -> dict:
|
||||||
"""Prihlasi existujuci ucet a vrati session token."""
|
"""Prihlasi existujuci ucet a vrati session token.
|
||||||
return await _verify_and_issue_token(username, code)
|
|
||||||
|
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()
|
username = (username or "").strip()
|
||||||
_check_lockout(username)
|
_check_lockout(username)
|
||||||
async with async_session() as session:
|
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:
|
if player is None:
|
||||||
_register_failure(username)
|
_register_failure(username)
|
||||||
raise AuthError("Účet neexistuje.")
|
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:
|
try:
|
||||||
_verify_code(player, (code or "").strip())
|
_verify_code(player, (code or "").strip())
|
||||||
except AuthError:
|
except AuthError:
|
||||||
|
|||||||
+13
-1
@@ -228,7 +228,18 @@ async def get_daily_stats(logged_in_only: bool = False) -> dict:
|
|||||||
)
|
)
|
||||||
).scalar()
|
).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 = (
|
peak_hours = (
|
||||||
await session.execute(
|
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,
|
"completion_rate": finished / total if total else None,
|
||||||
"avg_game_duration_minutes": (avg_duration / 60) if avg_duration else None,
|
"avg_game_duration_minutes": (avg_duration / 60) if avg_duration else None,
|
||||||
"total_players": total_players,
|
"total_players": total_players,
|
||||||
|
"unconfirmed_players": unconfirmed_players,
|
||||||
"peak_hours": {int(r.h): r.n for r in peak_hours},
|
"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},
|
"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},
|
"pageviews_per_day": {str(r.day): r.n for r in pageview_rows},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { useGameStore } from '../store/gameStore';
|
import { useGameStore } from '../store/gameStore';
|
||||||
import { emit } from '../lib/socket';
|
import { emit } from '../lib/socket';
|
||||||
@@ -20,6 +20,15 @@ export default function Auth() {
|
|||||||
|
|
||||||
const remember = (name: string) => localStorage.setItem('bridzik_name', name.trim());
|
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) => {
|
const handleLogin = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!username.trim() || code.trim().length < 6) return;
|
if (!username.trim() || code.trim().length < 6) return;
|
||||||
@@ -31,6 +40,7 @@ export default function Auth() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!username.trim()) return;
|
if (!username.trim()) return;
|
||||||
remember(username);
|
remember(username);
|
||||||
|
trackEvent('register');
|
||||||
emit.registerAccount(username.trim());
|
emit.registerAccount(username.trim());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface DailyStats {
|
|||||||
completion_rate: number | null;
|
completion_rate: number | null;
|
||||||
avg_game_duration_minutes: number | null;
|
avg_game_duration_minutes: number | null;
|
||||||
total_players: number;
|
total_players: number;
|
||||||
|
unconfirmed_players: number;
|
||||||
peak_hours: Record<string, number>;
|
peak_hours: Record<string, number>;
|
||||||
rounds_per_day: Record<string, number>;
|
rounds_per_day: Record<string, number>;
|
||||||
pageviews_per_day: Record<string, number>;
|
pageviews_per_day: Record<string, number>;
|
||||||
@@ -155,6 +156,7 @@ export default function AdminStats() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className={`flex gap-3 ${desktop ? '' : 'flex-wrap'}`}>
|
<div className={`flex gap-3 ${desktop ? '' : 'flex-wrap'}`}>
|
||||||
<SummaryCard label="Hráči celkom" value={String(data.total_players)} />
|
<SummaryCard label="Hráči celkom" value={String(data.total_players)} />
|
||||||
|
<SummaryCard label="Nedokončené registrácie" value={String(data.unconfirmed_players)} />
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
label="Dokončené hry"
|
label="Dokončené hry"
|
||||||
value={data.completion_rate != null ? `${Math.round(data.completion_rate * 100)}%` : '—'}
|
value={data.completion_rate != null ? `${Math.round(data.completion_rate * 100)}%` : '—'}
|
||||||
|
|||||||
+35
-10
@@ -57,24 +57,37 @@ class HistoryCase(unittest.TestCase):
|
|||||||
for _ in range(n):
|
for _ in range(n):
|
||||||
username = "u_" + uuid.uuid4().hex[:8]
|
username = "u_" + uuid.uuid4().hex[:8]
|
||||||
data = run(auth.register_account(username))
|
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"])
|
ids.append(ident["player_id"])
|
||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _next_step_code(secret):
|
||||||
|
"""Kod pre NASLEDUJUCI casovy krok -- confirm uz spotreboval aktualny."""
|
||||||
|
return pyotp.TOTP(secret).at((auth._current_step() + 1) * auth.TOTP_PERIOD)
|
||||||
|
|
||||||
def test_register_login_token(self):
|
def test_register_login_token(self):
|
||||||
username = "alice_" + uuid.uuid4().hex[:6]
|
username = "alice_" + uuid.uuid4().hex[:6]
|
||||||
data = run(auth.register_account(username))
|
data = run(auth.register_account(username))
|
||||||
self.assertIn("otpauth_uri", data)
|
self.assertIn("otpauth_uri", data)
|
||||||
|
|
||||||
# Zle meno je obsadene
|
# Nedokoncena registracia: opakovany register vyda NOVY secret
|
||||||
with self.assertRaises(auth.AuthError):
|
data = run(auth.register_account(username))
|
||||||
run(auth.register_account(username))
|
self.assertIn("otpauth_uri", data)
|
||||||
|
|
||||||
|
# Login pred potvrdenim -> RegistrationIncomplete (novy QR namiesto chyby)
|
||||||
|
with self.assertRaises(auth.RegistrationIncomplete):
|
||||||
|
run(auth.login(username, "000000"))
|
||||||
|
|
||||||
code = pyotp.TOTP(data["secret"]).now()
|
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.assertEqual(ident["username"], username)
|
||||||
self.assertTrue(ident["token"])
|
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
|
# Token sa da spatne rozlustit na identitu
|
||||||
resolved = run(auth.player_by_token(ident["token"]))
|
resolved = run(auth.player_by_token(ident["token"]))
|
||||||
self.assertEqual(resolved["player_id"], ident["player_id"])
|
self.assertEqual(resolved["player_id"], ident["player_id"])
|
||||||
@@ -83,29 +96,41 @@ 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_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):
|
def test_login_lockout_after_repeated_failures(self):
|
||||||
username = "bob_" + uuid.uuid4().hex[:6]
|
username = "bob_" + uuid.uuid4().hex[:6]
|
||||||
data = run(auth.register_account(username))
|
data = run(auth.register_account(username))
|
||||||
|
run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
|
||||||
|
|
||||||
for _ in range(auth._LOGIN_ATTEMPT_LIMIT):
|
for _ in range(auth._LOGIN_ATTEMPT_LIMIT):
|
||||||
with self.assertRaises(auth.AuthError):
|
with self.assertRaises(auth.AuthError):
|
||||||
run(auth.login(username, "000000"))
|
run(auth.login(username, "000000"))
|
||||||
|
|
||||||
# Lockout odmietne aj spravny kod, kym neubehne okno
|
# Lockout odmietne aj spravny kod, kym neubehne okno
|
||||||
code = pyotp.TOTP(data["secret"]).now()
|
|
||||||
with self.assertRaises(auth.AuthError):
|
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):
|
def test_successful_login_clears_failed_attempts(self):
|
||||||
username = "carol_" + uuid.uuid4().hex[:6]
|
username = "carol_" + uuid.uuid4().hex[:6]
|
||||||
data = run(auth.register_account(username))
|
data = run(auth.register_account(username))
|
||||||
|
run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
|
||||||
|
|
||||||
for _ in range(auth._LOGIN_ATTEMPT_LIMIT - 1):
|
for _ in range(auth._LOGIN_ATTEMPT_LIMIT - 1):
|
||||||
with self.assertRaises(auth.AuthError):
|
with self.assertRaises(auth.AuthError):
|
||||||
run(auth.login(username, "000000"))
|
run(auth.login(username, "000000"))
|
||||||
|
|
||||||
code = pyotp.TOTP(data["secret"]).now()
|
ident = run(auth.login(username, self._next_step_code(data["secret"])))
|
||||||
ident = run(auth.login(username, code))
|
|
||||||
self.assertEqual(ident["username"], username)
|
self.assertEqual(ident["username"], username)
|
||||||
self.assertNotIn(username, auth._failed_attempts)
|
self.assertNotIn(username, auth._failed_attempts)
|
||||||
|
|
||||||
@@ -241,7 +266,7 @@ class HistoryCase(unittest.TestCase):
|
|||||||
def test_auth_token_stored_hashed_not_plaintext(self):
|
def test_auth_token_stored_hashed_not_plaintext(self):
|
||||||
username = "erin_" + uuid.uuid4().hex[:6]
|
username = "erin_" + uuid.uuid4().hex[:6]
|
||||||
data = run(auth.register_account(username))
|
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 def _raw_token():
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
|
|||||||
+18
-1
@@ -58,7 +58,7 @@ class StatsCase(unittest.TestCase):
|
|||||||
for _ in range(n):
|
for _ in range(n):
|
||||||
username = "u_" + uuid.uuid4().hex[:8]
|
username = "u_" + uuid.uuid4().hex[:8]
|
||||||
data = run(auth.register_account(username))
|
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"])
|
ids.append(ident["player_id"])
|
||||||
return ids
|
return ids
|
||||||
|
|
||||||
@@ -319,6 +319,23 @@ class StatsCase(unittest.TestCase):
|
|||||||
self.assertGreaterEqual(sum(data["games_per_day"].values()), 1)
|
self.assertGreaterEqual(sum(data["games_per_day"].values()), 1)
|
||||||
self.assertGreaterEqual(sum(data["rounds_per_day"].values()), 4)
|
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):
|
def test_empty_referrer_excluded_from_top_referrers(self):
|
||||||
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA))
|
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA))
|
||||||
data = run(stats.get_daily_stats())
|
data = run(stats.get_daily_stats())
|
||||||
|
|||||||
Reference in New Issue
Block a user