From 37e1d3267581ca0f70ec305dc8fc57213d84f8b7 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Fri, 27 Feb 2026 23:11:59 +0100 Subject: [PATCH] feat(f3): require player session token for lie submission --- TODO.md | 2 +- .../migrations/0004_player_session_token.py | 19 ++++++++ fupogfakta/models.py | 7 +++ lobby/tests.py | 48 +++++++++++++++++-- lobby/views.py | 8 ++++ 5 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 fupogfakta/migrations/0004_player_session_token.py diff --git a/TODO.md b/TODO.md index 55f595d..7e8a466 100644 --- a/TODO.md +++ b/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 diff --git a/fupogfakta/migrations/0004_player_session_token.py b/fupogfakta/migrations/0004_player_session_token.py new file mode 100644 index 0000000..eb23044 --- /dev/null +++ b/fupogfakta/migrations/0004_player_session_token.py @@ -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), + ), + ] diff --git a/fupogfakta/models.py b/fupogfakta/models.py index 55e98dd..6a5a39d 100644 --- a/fupogfakta/models.py +++ b/fupogfakta/models.py @@ -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) diff --git a/lobby/tests.py b/lobby/tests.py index 905d9a0..bf464a7 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -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") diff --git a/lobby/views.py b/lobby/views.py index b232015..f9fd0da 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -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,