F3: beskyt lie-submit med player session token #36
2
TODO.md
2
TODO.md
@@ -104,7 +104,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
|
||||
|
||||
### Backlog — Need-to-have / Nice-to-have
|
||||
- [ ] (Need-to-have) Persistér mixed svarrækkefølge pr. round question, så alle spillere ser samme rækkefølge ved reconnect/refresh
|
||||
- [ ] (Need-to-have) Tilføj spiller-auth/session-token for submit_lie (pt. baseret på player_id i payload)
|
||||
- [x] (Need-to-have) Tilføj spiller-auth/session-token for submit_lie (pt. baseret på player_id i payload)
|
||||
- [ ] (Nice-to-have) Endpoint til status/progress i løgnfasen (antal indsendt ud af total)
|
||||
- [ ] (Need-to-have) [Fejltype: CI/lint F401] [Fil/område: core_admin/*, fupogfakta/tests.py+views.py, lobby/admin.py+models.py, realtime/*, voice/*] [Branch/PR: feature/f3-lobby-create-join, feature/fase0-mvp-fup-og-fakta, feature/lobby-mvp (ingen åbne PRs fundet)] Fjern ubrugte scaffold-imports (eller kør ruff check --fix) så quality gate kan blive grøn før merge.
|
||||
- [ ] (Need-to-have) Rate limiting på join/submit endpoints
|
||||
|
||||
19
fupogfakta/migrations/0004_player_session_token.py
Normal file
19
fupogfakta/migrations/0004_player_session_token.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-27 22:08
|
||||
|
||||
import fupogfakta.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fupogfakta', '0003_roundquestion_mixed_answers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='player',
|
||||
name='session_token',
|
||||
field=models.CharField(db_index=True, default=fupogfakta.models._generate_player_session_token, max_length=64),
|
||||
),
|
||||
]
|
||||
@@ -1,9 +1,15 @@
|
||||
import secrets
|
||||
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
def _generate_player_session_token() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
name = models.CharField(max_length=120, unique=True)
|
||||
@@ -54,6 +60,7 @@ class GameSession(models.Model):
|
||||
class Player(models.Model):
|
||||
session = models.ForeignKey(GameSession, on_delete=models.CASCADE, related_name="players")
|
||||
nickname = models.CharField(max_length=40)
|
||||
session_token = models.CharField(max_length=64, db_index=True, default=_generate_player_session_token)
|
||||
score = models.IntegerField(default=0)
|
||||
is_connected = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -55,6 +55,8 @@ class LobbyFlowTests(TestCase):
|
||||
body = response.json()
|
||||
self.assertEqual(body["session"]["code"], "ABCD23")
|
||||
self.assertEqual(body["player"]["nickname"], "Luna")
|
||||
self.assertIn("session_token", body["player"])
|
||||
self.assertTrue(body["player"]["session_token"])
|
||||
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
|
||||
|
||||
def test_join_rejects_duplicate_nickname_case_insensitive(self):
|
||||
@@ -217,7 +219,7 @@ class LieSubmissionTests(TestCase):
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "text": "Sydney"},
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Sydney"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
@@ -239,7 +241,7 @@ class LieSubmissionTests(TestCase):
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "text": "Melbourne"},
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Melbourne"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
@@ -260,13 +262,53 @@ class LieSubmissionTests(TestCase):
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "text": "Brisbane"},
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Brisbane"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"], "Lie already submitted for this player")
|
||||
|
||||
def test_submit_lie_requires_session_token(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "text": "Sydney"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "session_token is required")
|
||||
|
||||
def test_submit_lie_rejects_invalid_session_token(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": "invalid-token", "text": "Sydney"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Invalid player session token")
|
||||
|
||||
class MixAnswersTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host", password="secret123")
|
||||
|
||||
@@ -104,6 +104,7 @@ def join_session(request: HttpRequest) -> JsonResponse:
|
||||
"player": {
|
||||
"id": player.id,
|
||||
"nickname": player.nickname,
|
||||
"session_token": player.session_token,
|
||||
"score": player.score,
|
||||
},
|
||||
"session": {
|
||||
@@ -296,11 +297,15 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
|
||||
session_code = code.strip().upper()
|
||||
|
||||
player_id = payload.get("player_id")
|
||||
session_token = str(payload.get("session_token", "")).strip()
|
||||
lie_text = str(payload.get("text", "")).strip()
|
||||
|
||||
if not player_id:
|
||||
return JsonResponse({"error": "player_id is required"}, status=400)
|
||||
|
||||
if not session_token:
|
||||
return JsonResponse({"error": "session_token is required"}, status=400)
|
||||
|
||||
if not lie_text or len(lie_text) > 255:
|
||||
return JsonResponse({"error": "text must be between 1 and 255 characters"}, status=400)
|
||||
|
||||
@@ -317,6 +322,9 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
|
||||
except Player.DoesNotExist:
|
||||
return JsonResponse({"error": "Player not found in session"}, status=404)
|
||||
|
||||
if player.session_token != session_token:
|
||||
return JsonResponse({"error": "Invalid player session token"}, status=403)
|
||||
|
||||
try:
|
||||
round_question = RoundQuestion.objects.get(
|
||||
pk=round_question_id,
|
||||
|
||||
Reference in New Issue
Block a user