diff --git a/.github/workflows/integrate.yml b/.github/workflows/integrate.yml index ec4f25a8..6c967501 100644 --- a/.github/workflows/integrate.yml +++ b/.github/workflows/integrate.yml @@ -46,9 +46,6 @@ jobs: - name: Confirm Current is Healthy run: curl -v --fail localhost:8000/api/v2/stats/stats/ - - name: Clean Up Docker Compose - run: make clean-dev-server - - name: Get Core Logs run: docker-compose logs backend if: always() @@ -56,3 +53,6 @@ jobs: - name: Get Sockets Logs run: docker-compose logs sockets if: always() + + - name: Clean Up Docker Compose + run: make clean-dev-server diff --git a/.gitignore b/.gitignore index 503d5947..cbacb676 100644 --- a/.gitignore +++ b/.gitignore @@ -255,3 +255,4 @@ $RECYCLE.BIN/ *.sqlite3 *.db .vim +src/publicmedia diff --git a/Makefile b/Makefile index 3fe042ec..a5f5fd5d 100644 --- a/Makefile +++ b/Makefile @@ -51,3 +51,15 @@ clean-test: clean-dev-server: docker-compose rm -sfv docker volume rm -f core_postgres + +dev-logs: + docker-compose logs -f backend + +dev-sql: + docker-compose exec database psql -U postgres postgres + +dev-fake-bulk-data: + docker-compose exec backend python -m scripts.fake generate --teams 10000 --users 2 --categories 10 --challenges 100 --solves 10000 + +dev-shell: + docker-compose exec backend ./src/manage.py shell diff --git a/poetry.lock b/poetry.lock index ccbe3e50..95c7a570 100644 --- a/poetry.lock +++ b/poetry.lock @@ -399,7 +399,7 @@ python-versions = "*" [[package]] name = "django" -version = "4.0.2" +version = "4.0.4" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." category = "main" optional = false @@ -1442,7 +1442,7 @@ test = ["pytest"] [[package]] name = "twisted" -version = "22.1.0" +version = "22.4.0" description = "An asynchronous networking framework written in Python" category = "main" optional = false @@ -1462,19 +1462,20 @@ typing-extensions = ">=3.6.5" "zope.interface" = ">=4.4.2" [package.extras] -all_non_platform = ["cython-test-exception-raiser (>=1.0.2,<2)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "contextvars (>=2.4,<3)"] +all_non_platform = ["cython-test-exception-raiser (>=1.0.2,<2)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<5.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "contextvars (>=2.4,<3)"] conch = ["pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)"] +conch_nacl = ["pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pynacl"] contextvars = ["contextvars (>=2.4,<3)"] dev = ["towncrier (>=19.2,<20.0)", "sphinx-rtd-theme (>=0.5,<1.0)", "readthedocs-sphinx-ext (>=2.1,<3.0)", "sphinx (>=4.1.2,<6)", "pyflakes (>=2.2,<3.0)", "twistedchecker (>=0.7,<1.0)", "coverage (>=6b1,<7)", "python-subunit (>=1.4,<2.0)", "pydoctor (>=21.9.0,<21.10.0)"] dev_release = ["towncrier (>=19.2,<20.0)", "sphinx-rtd-theme (>=0.5,<1.0)", "readthedocs-sphinx-ext (>=2.1,<3.0)", "sphinx (>=4.1.2,<6)", "pydoctor (>=21.9.0,<21.10.0)"] -http2 = ["h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)"] -macos_platform = ["pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "cython-test-exception-raiser (>=1.0.2,<2)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "contextvars (>=2.4,<3)"] -mypy = ["mypy (==0.930)", "mypy-zope (==0.3.4)", "types-setuptools", "types-pyopenssl", "towncrier (>=19.2,<20.0)", "sphinx-rtd-theme (>=0.5,<1.0)", "readthedocs-sphinx-ext (>=2.1,<3.0)", "sphinx (>=4.1.2,<6)", "pyflakes (>=2.2,<3.0)", "twistedchecker (>=0.7,<1.0)", "coverage (>=6b1,<7)", "cython-test-exception-raiser (>=1.0.2,<2)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "python-subunit (>=1.4,<2.0)", "contextvars (>=2.4,<3)", "pydoctor (>=21.9.0,<21.10.0)"] -osx_platform = ["pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "cython-test-exception-raiser (>=1.0.2,<2)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "contextvars (>=2.4,<3)"] +http2 = ["h2 (>=3.0,<5.0)", "priority (>=1.1.0,<2.0)"] +macos_platform = ["pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "cython-test-exception-raiser (>=1.0.2,<2)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<5.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "contextvars (>=2.4,<3)"] +mypy = ["mypy (==0.930)", "mypy-zope (==0.3.4)", "types-setuptools", "types-pyopenssl", "towncrier (>=19.2,<20.0)", "sphinx-rtd-theme (>=0.5,<1.0)", "readthedocs-sphinx-ext (>=2.1,<3.0)", "sphinx (>=4.1.2,<6)", "pyflakes (>=2.2,<3.0)", "twistedchecker (>=0.7,<1.0)", "coverage (>=6b1,<7)", "cython-test-exception-raiser (>=1.0.2,<2)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<5.0)", "priority (>=1.1.0,<2.0)", "pynacl", "pywin32 (!=226)", "python-subunit (>=1.4,<2.0)", "contextvars (>=2.4,<3)", "pydoctor (>=21.9.0,<21.10.0)"] +osx_platform = ["pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "cython-test-exception-raiser (>=1.0.2,<2)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<5.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "contextvars (>=2.4,<3)"] serial = ["pyserial (>=3.0)", "pywin32 (!=226)"] test = ["cython-test-exception-raiser (>=1.0.2,<2)", "PyHamcrest (>=1.9.0)"] tls = ["pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)"] -windows_platform = ["pywin32 (!=226)", "cython-test-exception-raiser (>=1.0.2,<2)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "contextvars (>=2.4,<3)"] +windows_platform = ["pywin32 (!=226)", "cython-test-exception-raiser (>=1.0.2,<2)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<5.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "contextvars (>=2.4,<3)"] [[package]] name = "twisted-iocpsupport" @@ -1659,7 +1660,7 @@ python-versions = "*" [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "3fe7393c33613effbe9249ed99652c52ff47dc24503b8af1de745585f3a765ea" +content-hash = "8ad935cc8daa9b9c362bed755363b4254019c5861b18b1688fc93d5e2e9f2537" [metadata.files] aioredis = [ @@ -1908,8 +1909,8 @@ distlib = [ {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, ] django = [ - {file = "Django-4.0.2-py3-none-any.whl", hash = "sha256:996495c58bff749232426c88726d8cd38d24c94d7c1d80835aafffa9bc52985a"}, - {file = "Django-4.0.2.tar.gz", hash = "sha256:110fb58fb12eca59e072ad59fc42d771cd642dd7a2f2416582aa9da7a8ef954a"}, + {file = "Django-4.0.4-py3-none-any.whl", hash = "sha256:07c8638e7a7f548dc0acaaa7825d84b7bd42b10e8d22268b3d572946f1e9b687"}, + {file = "Django-4.0.4.tar.gz", hash = "sha256:4e8177858524417563cc0430f29ea249946d831eacb0068a1455686587df40b5"}, ] django-anymail = [ {file = "django-anymail-8.5.tar.gz", hash = "sha256:677e937dc9e2671ca7631abb1d94ddc6b840beb3d53c0fbf699e866a6a9ba92f"}, @@ -2614,8 +2615,8 @@ traitlets = [ {file = "traitlets-5.1.1.tar.gz", hash = "sha256:059f456c5a7c1c82b98c2e8c799f39c9b8128f6d0d46941ee118daace9eb70c7"}, ] twisted = [ - {file = "Twisted-22.1.0-py3-none-any.whl", hash = "sha256:ccd638110d9ccfdc003042aa3e1b6d6af272eaca45d36e083359560549e3e848"}, - {file = "Twisted-22.1.0.tar.gz", hash = "sha256:b7971ec9805b0f80e1dcb1a3721d7bfad636d5f909de687430ce373979d67b61"}, + {file = "Twisted-22.4.0-py3-none-any.whl", hash = "sha256:f9f7a91f94932477a9fc3b169d57f54f96c6e74a23d78d9ce54039a7f48928a2"}, + {file = "Twisted-22.4.0.tar.gz", hash = "sha256:a047990f57dfae1e0bd2b7df2526d4f16dcdc843774dc108b78c52f2a5f13680"}, ] twisted-iocpsupport = [ {file = "twisted-iocpsupport-1.0.2.tar.gz", hash = "sha256:72068b206ee809c9c596b57b5287259ea41ddb4774d86725b19f35bf56aa32a9"}, diff --git a/pyproject.toml b/pyproject.toml index c354eb79..7b4f9623 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ django-zxcvbn-password-validator = "^1.4" uvicorn = {extras = ["standard"], version = "^0.13.4"} sentry-sdk = "^1.0.0" coverage = {extras = ["toml"], version = "^5.5"} -Twisted = "22.1.0" +Twisted = "22.4.0" channels-redis = "^3.2.0" requests = "^2.26.0" django-anymail = {extras = ["amazon_ses", "mailgun", "sendgrid", "console", "mailjet", "mandrill", "postal", "postmark", "sendinblue", "sparkpost"], version = "^8.5"} diff --git a/src/admin/tests.py b/src/admin/tests.py index ccbf6ea1..f527f6b6 100644 --- a/src/admin/tests.py +++ b/src/admin/tests.py @@ -88,7 +88,7 @@ def test_length(self): self.client.force_authenticate(user=self.user) response = self.client.get(reverse("self-check")) - self.assertEqual(len(response.data["d"]), 14) + self.assertEqual(len(response.data["d"]), 17) class DevMailEndpointTestCase(TestCase): def test_endpoint_absent(self): diff --git a/src/andromeda/tests.py b/src/andromeda/tests.py index a39b155a..5a0c682d 100644 --- a/src/andromeda/tests.py +++ b/src/andromeda/tests.py @@ -1 +1,46 @@ -# Create your tests here. +from django.urls import reverse +from rest_framework.test import APITestCase +from config import config + +from team.tests import TeamSetupMixin +from requests.exceptions import ConnectionError + + +class AndromedaTestCase(TeamSetupMixin, APITestCase): + def test_get_instance_disabled_challenge_server(self): + with self.settings( + CHALLENGE_SERVER_ENABLED=False, + ): + self.client.force_authenticate(user=self.user) + response = self.client.get(reverse("get-instance", args=["1"])) + self.assertContains(response, "challenge_server_disabled", status_code=403) + + def test_request_new_instance_disabled_challenge_server(self): + with self.settings( + CHALLENGE_SERVER_ENABLED=False, + ): + self.client.force_authenticate(user=self.user) + response = self.client.get(reverse("request-new-instance", args=["1"])) + self.assertContains(response, "challenge_server_disabled", status_code=403) + + def test_get_instance_no_team(self): + with self.settings( + CHALLENGE_SERVER_ENABLED=True, + ): + self.client.force_authenticate(user=self.user) + config.set("enable_team_leave", True) + self.client.post(reverse("team-leave")) + response = self.client.get(reverse("get-instance", args=["1"])) + self.assertContains(response, "challenge_server_team_required", status_code=403) + + def test_get_instance(self): + # This is a bad test but we need a stub service + with self.settings( + CHALLENGE_SERVER_ENABLED=True, + ANDROMEDA_URL="http://andromeda", + ANDROMEDA_API_KEY="andromeda", + ANDROMEDA_TIMEOUT=0.1, + ): + with self.assertRaises(ConnectionError): + self.client.force_authenticate(user=self.user) + self.client.get(reverse("get-instance", args=["1"])) diff --git a/src/andromeda/views.py b/src/andromeda/views.py index 6443c625..520fa8aa 100644 --- a/src/andromeda/views.py +++ b/src/andromeda/views.py @@ -1,5 +1,6 @@ """API endpoints for the andromeda integration.""" +from django.conf import settings from rest_framework.generics import get_object_or_404 from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.status import HTTP_403_FORBIDDEN @@ -10,7 +11,6 @@ from backend.response import FormattedResponse from challenge.models import Challenge from challenge.permissions import CompetitionOpen -from config import config class GetInstanceView(APIView): diff --git a/src/backend/settings/__init__.py b/src/backend/settings/__init__.py index d3f6d394..cbf43b46 100644 --- a/src/backend/settings/__init__.py +++ b/src/backend/settings/__init__.py @@ -57,7 +57,6 @@ "token_provider": "basic_auth", "enable_bot_users": True, "enable_caching": True, - "enable_challenge_server": True, "enable_ctftime": True, "enable_flag_submission": True, "enable_flag_submission_after_competition": True, @@ -283,15 +282,19 @@ "NUM_PROXIES": int(os.getenv("NUM_PROXIES", 0)), } -if os.getenv("CHALLENGE_SERVER_TYPE") == "POLARIS": +if os.getenv("CHALLENGE_SERVER_TYPE") == "POLARIS": # pragma: no cover + CHALLENGE_SERVER_ENABLED = True POLARIS_URL = os.getenv("POLARIS_URL") POLARIS_USERNAME = os.getenv("POLARIS_USERNAME") POLARIS_PASSWORD = os.getenv("POLARIS_PASSWORD") -else: +elif os.getenv("ANDROMEDA_URL") or os.getenv("CHALLENGE_SERVER_TYPE") == "ANDROMEDA": # Backwards compatability with old Docker Compose files + CHALLENGE_SERVER_ENABLED = True ANDROMEDA_URL = os.getenv("ANDROMEDA_URL") ANDROMEDA_API_KEY = os.getenv("ANDROMEDA_API_KEY") ANDROMEDA_SERVER_IP = os.getenv("ANDROMEDA_IP") # shown to participants ANDROMEDA_TIMEOUT = float(os.getenv("ANDROMEDA_TIMEOUT", 5)) +else: + CHALLENGE_SERVER_ENABLED = False INSTALLED_PLUGINS = [ "plugins.flag.hashed", diff --git a/src/challenge/migrations/0022_challenge_current_score.py b/src/challenge/migrations/0022_challenge_current_score.py new file mode 100644 index 00000000..cc83885b --- /dev/null +++ b/src/challenge/migrations/0022_challenge_current_score.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.2 on 2022-02-26 20:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('challenge', '0021_challenge_maintenance'), + ] + + operations = [ + migrations.AddField( + model_name='challenge', + name='current_score', + field=models.IntegerField(help_text='The dynamically updated score for this challenge, null if the challenge is statically scored.', null=True), + ), + ] diff --git a/src/challenge/models.py b/src/challenge/models.py index d57d8b29..fe8c5353 100644 --- a/src/challenge/models.py +++ b/src/challenge/models.py @@ -62,24 +62,31 @@ class Challenge(ExportModelOperationsMixin("challenge"), models.Model): points_type = models.CharField(max_length=64, default="basic") release_time = models.DateTimeField(default=timezone.now) tiebreaker = models.BooleanField(default=True, help_text="Should the challenge be able to break ties?") + current_score = models.IntegerField( + null=True, + help_text="The dynamically updated score for this challenge, null if the challenge is statically scored." + ) def self_check(self): """Check the challenge doesn't have any configuration issues.""" issues = [] if not self.score: - issues.append({"issue": "missing_points", "challenge": self.id}) + issues.append({"issue": "missing_points", "challenge": self.pk}) if not self.flag_type: - issues.append({"issue": "missing_flag_type", "challenge": self.id}) + issues.append({"issue": "missing_flag_type", "challenge": self.pk}) elif type(self.flag_metadata) != dict: - issues.append({"issue": "invalid_flag_data_type", "challenge": self.id}) + issues.append({"issue": "invalid_flag_data_type", "challenge": self.pk}) else: issues += [ - {"issue": "invalid_flag_data", "extra": issue, "challenge": self.id} + {"issue": "invalid_flag_data", "extra": issue, "challenge": self.pk} for issue in self.flag_plugin.self_check() ] + if (self.flag_type == "lenient") ^ (self.challenge_type == "freeform"): + issues.append({"issue": "lenient_freeform_mismatch", "challenge": self.pk}) + return issues @cached_property @@ -185,6 +192,21 @@ def get_unlocked_annotated_queryset(cls, user): ) return x + @property + def needs_recalculate(self): + return self.points_plugin.recalculate_type != "none" + + def recalculate_score(self, solve_set): + from team.models import Team + + new_score = self.points_plugin.recalculate( + teams=Team.objects.filter(solves__challenge=self), + users=get_user_model().objects.filter(solves__challenge=self), + solves=solve_set.filter(correct=True), + ) + self.current_score = new_score + self.save(update_fields=["current_score"]) + class ChallengeVote(ExportModelOperationsMixin("challenge_vote"), models.Model): challenge = models.ForeignKey(Challenge, on_delete=CASCADE, related_name="votes") diff --git a/src/challenge/serializers.py b/src/challenge/serializers.py index 54891033..5c389f7c 100644 --- a/src/challenge/serializers.py +++ b/src/challenge/serializers.py @@ -131,6 +131,7 @@ class FastChallengeSerializer(ChallengeSerializerMixin, serpy.Serializer): challenge_type = serpy.StrField() challenge_metadata = serpy.Field() flag_type = serpy.StrField() + points_type = serpy.StrField() author = serpy.StrField() score = serpy.IntField() unlock_requirements = serpy.StrField() @@ -147,6 +148,7 @@ class FastChallengeSerializer(ChallengeSerializerMixin, serpy.Serializer): unlock_time_surpassed = serpy.MethodField() post_score_explanation = serpy.MethodField() tiebreaker = serpy.BoolField() + current_score = serpy.IntField(required=False) def __init__(self, *args, **kwargs) -> None: """Add the 'context' attribute to the serializer.""" @@ -213,6 +215,7 @@ class FastAdminChallengeSerializer(ChallengeSerializerMixin, serpy.Serializer): challenge_metadata = serpy.Field() flag_type = serpy.StrField() flag_metadata = serpy.Field() + points_type = serpy.StrField() author = serpy.StrField() score = serpy.IntField() unlock_requirements = serpy.StrField() @@ -229,6 +232,7 @@ class FastAdminChallengeSerializer(ChallengeSerializerMixin, serpy.Serializer): unlock_time_surpassed = serpy.MethodField() post_score_explanation = serpy.StrField() tiebreaker = serpy.BoolField() + current_score = serpy.IntField(required=False) def __init__(self, *args, **kwargs): super(FastAdminChallengeSerializer, self).__init__(*args, **kwargs) @@ -249,7 +253,7 @@ def to_value(self, instance): class CreateChallengeSerializer(serializers.ModelSerializer): - tags = serializers.ListField(write_only=True) + tags = serializers.ListField(child=serializers.DictField(), write_only=True) class Meta: model = Challenge @@ -261,6 +265,7 @@ class Meta: "challenge_type", "challenge_metadata", "flag_type", + "points_type", "author", "score", "unlock_requirements", @@ -283,7 +288,6 @@ def create(self, validated_data): def update(self, instance, validated_data): tags = validated_data.pop("tags", None) - # FIXME: this is a list of strings somehow if tags: Tag.objects.filter(challenge=instance).delete() for tag_data in tags: diff --git a/src/challenge/signals.py b/src/challenge/signals.py index 591027d9..6aed8ae8 100644 --- a/src/challenge/signals.py +++ b/src/challenge/signals.py @@ -23,9 +23,15 @@ def challenge_cache_invalidate(sender, instance, **kwargs): def challenge_recalculate(sender, instance, **kwargs): with transaction.atomic(): correct_solves = instance.solves.filter(correct=True) - Score.objects.filter(id__in=correct_solves.values_list("score", flat=True)).update(points=instance.score) - for solve in correct_solves: - recalculate_team(solve.team) + if instance.current_score is None: + Score.objects.filter(id__in=correct_solves.values_list("score", flat=True)).update(points=instance.score) + for solve in correct_solves: + recalculate_team(solve.team) + else: + Score.objects.filter(id__in=correct_solves.values_list("score", flat=True))\ + .update(points=instance.current_score) + for solve in correct_solves: + recalculate_team(solve.team) @receiver([post_save, post_delete], sender=HintUse) diff --git a/src/challenge/tests/test_models.py b/src/challenge/tests/test_models.py index 526a4415..9251de7c 100644 --- a/src/challenge/tests/test_models.py +++ b/src/challenge/tests/test_models.py @@ -21,8 +21,8 @@ def test_missing_flag_type(self): score=1000, ) challenge.save() - check = challenge.self_check() - self.assertEqual(check[0]["issue"], "missing_flag_type") + checks = challenge.self_check() + self.assertIn("missing_flag_type", [check["issue"] for check in checks]) def test_invalid_flag_data_type(self): challenge = Challenge( @@ -37,8 +37,72 @@ def test_invalid_flag_data_type(self): score=1000, ) challenge.save() - check = challenge.self_check() - self.assertEqual(check[0]["issue"], "invalid_flag_data_type") + checks = challenge.self_check() + self.assertIn("invalid_flag_data_type", [check["issue"] for check in checks]) + + def test_not_lenient_or_freeform(self): + challenge = Challenge( + name="test5", + category=self.category, + description="a", + challenge_type="basic", + challenge_metadata={}, + flag_type="plaintext", + flag_metadata={"flag": "ractf{a}", "exclude_passes": []}, + author="", + score=1000, + ) + challenge.save() + checks = challenge.self_check() + self.assertNotIn("lenient_freeform_mismatch", [check["issue"] for check in checks]) + + def test_lenient_without_freeform(self): + challenge = Challenge( + name="test5", + category=self.category, + description="a", + challenge_type="basic", + challenge_metadata={}, + flag_type="lenient", + flag_metadata={"flag": "ractf{a}", "exclude_passes": []}, + author="", + score=1000, + ) + challenge.save() + checks = challenge.self_check() + self.assertIn("lenient_freeform_mismatch", [check["issue"] for check in checks]) + + def test_freeform_without_lenient(self): + challenge = Challenge( + name="test5", + category=self.category, + description="a", + challenge_type="freeform", + challenge_metadata={}, + flag_type="plaintext", + flag_metadata={"flag": "ractf{a}", "exclude_passes": []}, + author="", + score=1000, + ) + challenge.save() + checks = challenge.self_check() + self.assertIn("lenient_freeform_mismatch", [check["issue"] for check in checks]) + + def test_correct_freeform(self): + challenge = Challenge( + name="test5", + category=self.category, + description="a", + challenge_type="freeform", + challenge_metadata={}, + flag_type="lenient", + flag_metadata={"flag": "ractf{a}", "exclude_passes": []}, + author="", + score=1000, + ) + challenge.save() + checks = challenge.self_check() + self.assertNotIn("lenient_freeform_mismatch", [check["issue"] for check in checks]) def test_is_unlocked_null_user(self): self.assertEqual(self.challenge2.is_unlocked(None), False) diff --git a/src/challenge/tests/test_views.py b/src/challenge/tests/test_views.py index 703e7271..da0234b6 100644 --- a/src/challenge/tests/test_views.py +++ b/src/challenge/tests/test_views.py @@ -199,6 +199,47 @@ def test_challenge_solve_non_tiebreak(self): self.solve_challenge() self.assertEqual(last_score_before, self.user.last_score) + def test_challenge_solve_decay(self): + self.challenge2.points_type = "decay" + self.challenge2.flag_metadata["decay_constant"] = 0.5 + self.challenge2.flag_metadata["min_points"] = 50 + self.challenge2.save() + + self.client.force_authenticate(self.user) + data = { + "flag": "ractf{a}", + "challenge": self.challenge2.id, + } + self.client.post(reverse("submit-flag"), data) + + self.user.refresh_from_db() + points = self.user.points + + self.client.force_authenticate(self.user3) + self.client.post(reverse("submit-flag"), data).json() + + self.user.refresh_from_db() + self.assertLess(self.user.points, points) + + def test_challenge_solve_decay_current_score(self): + self.challenge2.points_type = "decay" + self.challenge2.flag_metadata["decay_constant"] = 0.5 + self.challenge2.flag_metadata["min_points"] = 50 + self.challenge2.save() + + self.client.force_authenticate(self.user) + data = { + "flag": "ractf{a}", + "challenge": self.challenge2.id, + } + self.client.post(reverse("submit-flag"), data) + + self.client.force_authenticate(self.user3) + self.client.post(reverse("submit-flag"), data).json() + + self.challenge2.refresh_from_db() + self.assertLess(self.challenge2.current_score, self.challenge2.score) + class CategoryViewsetTestCase(ChallengeSetupMixin, APITestCase): def test_category_list_unauthenticated_permission(self): @@ -474,6 +515,47 @@ def test_create_challenge_with_tags(self): self.assertEqual(len(Tag.objects.filter(challenge__name="test5")), 2) + def test_update_challenge_with_tags(self): + self.user.is_staff = True + self.user.save() + self.client.force_authenticate(self.user) + response = self.client.post( + reverse("challenges-list"), + data={ + "name": "test5", + "category": self.category.id, + "description": "abc", + "challenge_type": "test", + "challenge_metadata": {}, + "flag_type": "plaintext", + "author": "ractf", + "score": 1000, + "unlock_requirements": "", + "flag_metadata": {}, + "tags": [], + }, + format="json", + ) + self.client.patch( + reverse("challenges-detail", kwargs={"pk": response.json()["d"]["id"]}), + data={ + "name": "test5", + "category": self.category.id, + "description": "abc", + "challenge_type": "test", + "challenge_metadata": {}, + "flag_type": "plaintext", + "author": "ractf", + "score": 1000, + "unlock_requirements": "", + "flag_metadata": {}, + "tags": [{"text": "abc", "type": "abc"}, {"text": "123", "type": "123"}], + }, + format="json", + ) + + self.assertEqual(len(Tag.objects.filter(challenge__name="test5")), 2) + class FlagCheckViewTestCase(ChallengeSetupMixin, APITestCase): def test_disable_flag_submission(self): diff --git a/src/challenge/views.py b/src/challenge/views.py index ffd65d84..3d7d7627 100644 --- a/src/challenge/views.py +++ b/src/challenge/views.py @@ -52,6 +52,7 @@ ) from config import config from hint.models import Hint, HintUse +from sockets.signals import broadcast from team.models import Team from team.permissions import HasTeam @@ -305,7 +306,17 @@ def post(self, request): challenge.points_plugin.register_incorrect_attempt(user, team, flag, solve_set) return FormattedResponse(d={"correct": False}, m="incorrect_flag") - solve = challenge.points_plugin.score(user, team, flag, solve_set) + solve = challenge.points_plugin.score(user, team, flag, solve_set.filter(correct=True)) + + if challenge.needs_recalculate: + challenge.recalculate_score(solve_set) + broadcast({ + "type": "send_json", + "event_code": 7, + "challenge_id": challenge.id, + "challenge_score": solve.score.points, + }) + if challenge.first_blood is None: challenge.first_blood = user challenge.save(update_fields=["first_blood"]) @@ -366,6 +377,7 @@ def post(self, request): return FormattedResponse(d={"correct": False}, m="incorrect_flag") ret = {"correct": True} + if challenge.post_score_explanation: ret["explanation"] = challenge.post_score_explanation return FormattedResponse(d=ret, m="correct_flag") diff --git a/src/member/migrations/0010_fix_client_ip_addresses.py b/src/member/migrations/0010_fix_client_ip_addresses.py new file mode 100644 index 00000000..9a8e8d43 --- /dev/null +++ b/src/member/migrations/0010_fix_client_ip_addresses.py @@ -0,0 +1,36 @@ +# Generated by Django 4.0.2 on 2022-05-01 19:51 + +from django.conf import settings +from django.db import migrations, models + + +def fix_ip_addresses(apps, schema_editor): + UserIP = apps.get_model('member', 'userip') + db_alias = schema_editor.connection.alias + for userip in UserIP.objects.using(db_alias).all(): + userip.ip = userip.ip.split(",")[0] + userip.save() + +class Migration(migrations.Migration): + dependencies = [ + ('member', '0009_alter_member_options_alter_member_username_and_more'), + ] + + operations = [ + migrations.RunPython( + fix_ip_addresses, + # Marked as elidable as all new IP addresses will be in the correct format + elidable=True, + ), + migrations.AlterField( + model_name='userip', + name='ip', + field=models.GenericIPAddressField(), + ), + migrations.AlterField( + model_name='userip', + name='user', + field=models.ForeignKey(null=True, on_delete=models.deletion.SET_NULL, + related_name='ips', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/member/models.py b/src/member/models.py index 07ed9e7f..408ac652 100644 --- a/src/member/models.py +++ b/src/member/models.py @@ -80,8 +80,8 @@ def should_deny_admin(self): class UserIP(ExportModelOperationsMixin("user_ip"), models.Model): - user = models.ForeignKey(get_user_model(), on_delete=SET_NULL, null=True) - ip = models.CharField(max_length=255) + user = models.ForeignKey(get_user_model(), on_delete=SET_NULL, null=True, related_name="ips") + ip = models.GenericIPAddressField() seen = models.IntegerField(default=1) last_seen = models.DateTimeField(default=timezone.now) user_agent = models.CharField(max_length=255) @@ -90,7 +90,7 @@ class UserIP(ExportModelOperationsMixin("user_ip"), models.Model): def hook(request): if not request.user.is_authenticated: return - ip = request.headers.get("x-forwarded-for", "0.0.0.0") + ip = request.headers.get("x-forwarded-for", request.META.get("REMOTE_ADDR", "0.0.0.0")).split(",")[0] user_agent = request.headers.get("user-agent", "???")[:255] qs = UserIP.objects.filter(user=request.user, ip=ip) if qs.exists(): diff --git a/src/plugins/points/base.py b/src/plugins/points/base.py index b638ed25..5338d6aa 100644 --- a/src/plugins/points/base.py +++ b/src/plugins/points/base.py @@ -21,8 +21,8 @@ def __init__(self, challenge): def get_points(self, team, flag, solves, *args, **kwargs): pass - def recalculate(self, teams, users, solves, *args, **kwargs): - pass + def recalculate(self, teams, users, solves, *args, **kwargs) -> int: + return self.challenge.score def score(self, user, team, flag, solves, *args, **kwargs): challenge = self.challenge diff --git a/src/plugins/points/decay.py b/src/plugins/points/decay.py index 48eeee78..73e94a98 100644 --- a/src/plugins/points/decay.py +++ b/src/plugins/points/decay.py @@ -12,11 +12,11 @@ class DecayPointsPlugin(PointsPlugin): def get_points(self, team, flag, solves, *args, **kwargs): challenge = self.challenge - decay_constant = challenge.challenge_metadata["decay_constant"] - min_points = challenge.challenge_metadata["min_points"] + decay_constant = challenge.flag_metadata.get("decay_constant", 0.99) + min_points = challenge.flag_metadata.get("min_points", 100) return int(round(min_points + ((challenge.score - min_points) * (decay_constant ** max(solves - 1, 0))))) - def recalculate(self, teams, users, solves, *args, **kwargs): + def recalculate(self, teams, users, solves, *args, **kwargs) -> int: challenge = self.challenge points = self.get_points(None, None, solves.count()) delta = self.get_points(None, None, solves.count() - 1) - points @@ -24,3 +24,4 @@ def recalculate(self, teams, users, solves, *args, **kwargs): scores.update(points=points) team.models.Team.objects.filter(solves__challenge=challenge).update(points=F("points") - delta) get_user_model().objects.filter(solves__challenge=challenge).update(points=F("points") - delta) + return points diff --git a/src/ractf/management/commands/create_user.py b/src/ractf/management/commands/create_user.py index 3094aabc..eaa0c386 100644 --- a/src/ractf/management/commands/create_user.py +++ b/src/ractf/management/commands/create_user.py @@ -11,8 +11,8 @@ class Command(BaseCommand): def add_arguments(self, parser: CommandParser) -> None: parser.add_argument("username", type=str) - parser.add_argument("--email", nargs=1, const="", type=str) - parser.add_argument("--password", nargs=1, const="", type=str) + parser.add_argument("--email", type=str) + parser.add_argument("--password", type=str) parser.add_argument("--bot", action="store_true", help="Mark the user as a bot") parser.add_argument("--visible", action="store_true", help="Make the user visible") diff --git a/src/ractf/management/commands/group_ips.py b/src/ractf/management/commands/group_ips.py new file mode 100644 index 00000000..ec495639 --- /dev/null +++ b/src/ractf/management/commands/group_ips.py @@ -0,0 +1,51 @@ +from json import dumps + +from django.core.management import BaseCommand + +from member.models import UserIP + + +class Command(BaseCommand): + help = "Group users by source IP address to try and spot cheating." + + def add_arguments(self, parser): + parser.add_argument( + "--multiple", + action="store_true", + help="Show only IP addresses with multiple users", + ) + + parser.add_argument( + "--json", + action="store_true", + help="Output JSON", + ) + + def handle(self, *args, **options) -> None: + self.stderr.write( + self.style.WARNING("Due to use of CGNAT, source IP addresses may be unreliable. Proceed with caution.") + ) + + ips = UserIP.objects.all() + + grouped = {} + + for ip in ips: + if ip.ip not in grouped: + grouped[ip.ip] = [] + if ip.user.username not in grouped[ip.ip]: + grouped[ip.ip].append(ip.user.username) + + if options["multiple"]: + multiple_grouped = {} + + for group in grouped.items(): + if len(group[1]) > 1: + multiple_grouped |= {group[0]: group[1]} + + grouped = multiple_grouped + + if options["json"]: + self.stdout.write(dumps(grouped)) + else: + self.stdout.write(str(grouped)) diff --git a/src/ractf/tests.py b/src/ractf/tests.py index a39b155a..b8b27b65 100644 --- a/src/ractf/tests.py +++ b/src/ractf/tests.py @@ -1 +1,45 @@ -# Create your tests here. +from io import StringIO + +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.test import TestCase + +from member.models import UserIP + + +class GroupIpsTest(TestCase): + def setUp(self): + one = get_user_model()(username="one", email="one@one.one") + one.save() + + two = get_user_model()(username="two", email="two@two.two") + two.save() + + three = get_user_model()(username="three", email="three@three.three") + three.save() + + UserIP.objects.create(user=one, ip="1.1.1.1", user_agent="Django Tests") + UserIP.objects.create(user=two, ip="1.1.1.1", user_agent="Django Tests") + UserIP.objects.create(user=three, ip="2.2.2.2", user_agent="Django Tests") + + def test_group_ips(self): + out = StringIO() + call_command("group_ips", stdout=out) + self.assertIn("1.1.1.1", out.getvalue()) + + def test_group_ips_multiple(self): + out = StringIO() + call_command("group_ips", "--multiple", stdout=out) + self.assertIn("1.1.1.1", out.getvalue()) + self.assertNotIn("2.2.2.2", out.getvalue()) + + def test_group_ips_json(self): + out = StringIO() + call_command("group_ips", "--json", stdout=out) + self.assertIn("1.1.1.1", out.getvalue()) + + def test_group_ips_multiple_json(self): + out = StringIO() + call_command("group_ips", "--json", "--multiple", stdout=out) + self.assertIn("1.1.1.1", out.getvalue()) + self.assertNotIn("2.2.2.2", out.getvalue())