From 69ad4c4d3619c4a3c499f8f7661dc4b181fb5d0b Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Tue, 10 Sep 2024 17:01:07 -0500 Subject: [PATCH] Add PrairieTest integration --- .ci/run-mypy.sh | 2 +- course/admin.py | 15 +- course/utils.py | 8 +- prairietest/__init__.py | 0 prairietest/admin.py | 102 +++++++++++++ prairietest/migrations/0001_initial.py | 86 +++++++++++ prairietest/migrations/__init__.py | 0 prairietest/models.py | 108 +++++++++++++ prairietest/urls.py | 41 +++++ prairietest/utils.py | 126 +++++++++++++++ prairietest/views.py | 203 +++++++++++++++++++++++++ relate/settings.py | 1 + relate/urls.py | 2 + 13 files changed, 687 insertions(+), 7 deletions(-) create mode 100644 prairietest/__init__.py create mode 100644 prairietest/admin.py create mode 100644 prairietest/migrations/0001_initial.py create mode 100644 prairietest/migrations/__init__.py create mode 100644 prairietest/models.py create mode 100644 prairietest/urls.py create mode 100644 prairietest/utils.py create mode 100644 prairietest/views.py diff --git a/.ci/run-mypy.sh b/.ci/run-mypy.sh index f3fa65e29..5143b999c 100755 --- a/.ci/run-mypy.sh +++ b/.ci/run-mypy.sh @@ -1,3 +1,3 @@ #! /bin/bash -mypy relate course accounts +mypy relate course accounts prairietest diff --git a/course/admin.py b/course/admin.py index 697d2a599..9948284ce 100644 --- a/course/admin.py +++ b/course/admin.py @@ -23,10 +23,11 @@ THE SOFTWARE. """ -from typing import Any +from typing import TYPE_CHECKING, Any from django import forms from django.contrib import admin +from django.db.models import QuerySet from django.utils.translation import gettext_lazy as _, pgettext from course.constants import exam_ticket_states, participation_permission as pperm @@ -56,9 +57,13 @@ from relate.utils import string_concat +if TYPE_CHECKING: + from accounts.models import User + + # {{{ permission helpers -def _filter_courses_for_user(queryset, user): +def _filter_courses_for_user(queryset: QuerySet, user: User) -> QuerySet: if user.is_superuser: return queryset z = queryset.filter( @@ -67,7 +72,7 @@ def _filter_courses_for_user(queryset, user): return z -def _filter_course_linked_obj_for_user(queryset, user): +def _filter_course_linked_obj_for_user(queryset: QuerySet, user: User) -> QuerySet: if user.is_superuser: return queryset return queryset.filter( @@ -76,7 +81,9 @@ def _filter_course_linked_obj_for_user(queryset, user): ) -def _filter_participation_linked_obj_for_user(queryset, user): +def _filter_participation_linked_obj_for_user( + queryset: QuerySet, user: User + ) -> QuerySet: if user.is_superuser: return queryset return queryset.filter( diff --git a/course/utils.py b/course/utils.py index 54e4ccd70..9daaed707 100644 --- a/course/utils.py +++ b/course/utils.py @@ -1039,7 +1039,7 @@ class FacilityFindingMiddleware: def __init__(self, get_response): self.get_response = get_response - def __call__(self, request): + def __call__(self, request: http.HttpRequest) -> http.HttpResponse: pretend_facilities = request.session.get("relate_pretend_facilities") if pretend_facilities is not None: @@ -1051,12 +1051,16 @@ def __call__(self, request): facilities = set() - for name, props in get_facilities_config(request).items(): + facilities_config = get_facilities_config(request) + if facilities_config is None: + facilities_config = {} + for name, props in facilities_config.items(): ip_ranges = props.get("ip_ranges", []) for ir in ip_ranges: if remote_address in ipaddress.ip_network(str(ir)): facilities.add(name) + request = cast(RelateHttpRequest, request) request.relate_facilities = frozenset(facilities) return self.get_response(request) diff --git a/prairietest/__init__.py b/prairietest/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/prairietest/admin.py b/prairietest/admin.py new file mode 100644 index 000000000..79c465712 --- /dev/null +++ b/prairietest/admin.py @@ -0,0 +1,102 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2024 University of Illinois Board of Trustees" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +from typing import TYPE_CHECKING + +from django import forms, http +from django.contrib import admin +from django.db.models import QuerySet + +from accounts.models import User +from course.constants import participation_permission as pperm +from prairietest.models import AllowEvent, DenyEvent, PrairieTestFacility + + +if TYPE_CHECKING: + from accounts.models import User + + +class PrairieTestFacilityAdminForm(forms.ModelForm): + class Meta: + model = PrairieTestFacility + fields = "__all__" + widgets = { + "secret": forms.PasswordInput, + } + + +@admin.register(PrairieTestFacility) +class PrairieTestFacilityAdmin(admin.ModelAdmin): + def get_queryset(self, request: http.HttpRequest) -> QuerySet: + assert request.user.is_authenticated + + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + from course.admin import _filter_course_linked_obj_for_user + return _filter_course_linked_obj_for_user(qs, request.user) + + list_display = ["identifier", "course"] + list_filter = ["identifier", "course"] + + form = PrairieTestFacilityAdminForm + + +def _filter_events_for_user(queryset: QuerySet, user: User) -> QuerySet: + if user.is_superuser: + return queryset + return queryset.filter( + test_facility__course__participations__user=user, + test_facility__course__participations__roles__permissions__permission=pperm.use_admin_interface) + + +@admin.register(AllowEvent) +class AllowEventAdmin(admin.ModelAdmin): + def get_queryset(self, request: http.HttpRequest) -> QuerySet: + assert request.user.is_authenticated + + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + return _filter_events_for_user(qs, request.user) + + list_display = [ + "event_id", "test_facility", "user_uid", "start", "end", "exam_uuid"] + list_filter = ["test_facility", "user_uid", "exam_uuid"] + + +@admin.register(DenyEvent) +class DenyEventAdmin(admin.ModelAdmin): + def get_queryset(self, request: http.HttpRequest) -> QuerySet: + assert request.user.is_authenticated + + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + return _filter_events_for_user(qs, request.user) + + list_display = [ + "event_id", "test_facility", "start", "end", "deny_uuid"] + list_filter = ["test_facility"] diff --git a/prairietest/migrations/0001_initial.py b/prairietest/migrations/0001_initial.py new file mode 100644 index 000000000..55a98aa6d --- /dev/null +++ b/prairietest/migrations/0001_initial.py @@ -0,0 +1,86 @@ +# Generated by Django 5.1 on 2024-09-12 14:41 + +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('course', '0121_alter_flowaccessexceptionentry_permission_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PrairieTestFacility', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('identifier', models.CharField(db_index=True, max_length=200, unique=True, validators=[django.core.validators.RegexValidator('^(?P[a-zA-Z][a-zA-Z0-9_]*)$')])), + ('description', models.TextField(blank=True, null=True)), + ('secret', models.CharField(max_length=220)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.course')), + ], + options={ + 'verbose_name': 'PrairieTest facility', + 'verbose_name_plural': 'PrairieTest facilities', + }, + ), + migrations.CreateModel( + name='DenyEvent', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('event_id', models.UUIDField()), + ('created', models.DateTimeField(verbose_name='Created time')), + ('received_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Received time')), + ('deny_uuid', models.UUIDField()), + ('start', models.DateTimeField(db_index=True, verbose_name='Start time')), + ('end', models.DateTimeField(db_index=True, verbose_name='End time')), + ('cidr_blocks', models.JSONField()), + ('test_facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='prairietest.prairietestfacility')), + ], + ), + migrations.CreateModel( + name='AllowEvent', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('event_id', models.UUIDField()), + ('created', models.DateTimeField(verbose_name='Created time')), + ('received_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Received time')), + ('user_uid', models.CharField(max_length=200)), + ('user_uin', models.CharField(max_length=200)), + ('exam_uuid', models.UUIDField()), + ('start', models.DateTimeField(verbose_name='Start time')), + ('end', models.DateTimeField(verbose_name='End time')), + ('cidr_blocks', models.JSONField()), + ('test_facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='prairietest.prairietestfacility')), + ], + ), + migrations.AddIndex( + model_name='prairietestfacility', + index=models.Index(fields=['course', 'identifier'], name='prairietest_course__462b09_idx'), + ), + migrations.AddIndex( + model_name='denyevent', + index=models.Index(fields=['deny_uuid', 'created'], name='prairietest_deny_uu_bbbbf1_idx'), + ), + migrations.AddIndex( + model_name='denyevent', + index=models.Index(fields=['deny_uuid', 'start'], name='prairietest_deny_uu_b13822_idx'), + ), + migrations.AddIndex( + model_name='denyevent', + index=models.Index(fields=['deny_uuid', 'end'], name='prairietest_deny_uu_3c9537_idx'), + ), + migrations.AddIndex( + model_name='allowevent', + index=models.Index(fields=['user_uid', 'exam_uuid', 'start'], name='prairietest_user_ui_e93827_idx'), + ), + migrations.AddIndex( + model_name='allowevent', + index=models.Index(fields=['user_uid', 'exam_uuid', 'end'], name='prairietest_user_ui_e11aa4_idx'), + ), + ] diff --git a/prairietest/migrations/__init__.py b/prairietest/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/prairietest/models.py b/prairietest/models.py new file mode 100644 index 000000000..dac9bca15 --- /dev/null +++ b/prairietest/models.py @@ -0,0 +1,108 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2024 University of Illinois Board of Trustees" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +from django.core.validators import RegexValidator +from django.db import models +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ + +from course.models import Course + + +TEST_FACILITY_ID_REGEX = "(?P[a-zA-Z][a-zA-Z0-9_]*)" + + +class PrairieTestFacility(models.Model): + id = models.BigAutoField(primary_key=True) + + course = models.ForeignKey(Course, on_delete=models.CASCADE) + identifier = models.CharField(max_length=200, unique=True, + validators=[RegexValidator(f"^{TEST_FACILITY_ID_REGEX}$")], + db_index=True) + + description = models.TextField(blank=True, null=True) + secret = models.CharField(max_length=220) + + class Meta: + indexes = [ + models.Index(fields=["course", "identifier"]), + ] + verbose_name = _("PrairieTest facility") + verbose_name_plural = _("PrairieTest facilities") + + def __str__(self) -> str: + return f"PrairieTest Facility '{self.identifier}' in {self.course.identifier}" + + +class PrairieTestEvent(models.Model): + id = models.BigAutoField(primary_key=True) + + test_facility = models.ForeignKey(PrairieTestFacility, on_delete=models.CASCADE) + event_id = models.UUIDField() + created = models.DateTimeField(verbose_name=_("Created time")) + received_time = models.DateTimeField(default=now, + verbose_name=_("Received time")) + + class Meta: + abstract = True + indexes = [ + models.Index(fields=["event_id"]), + models.Index(fields=["created"]), + ] + + +class AllowEvent(PrairieTestEvent): + user_uid = models.CharField(max_length=200) + user_uin = models.CharField(max_length=200) + exam_uuid = models.UUIDField() + start = models.DateTimeField(verbose_name=_("Start time")) + end = models.DateTimeField(verbose_name=_("End time")) + cidr_blocks = models.JSONField() + + def __str__(self) -> str: + return f"PrairieTest allow event {self.event_id} for {self.user_uid}" + + class Meta: + indexes = [ + models.Index(fields=["user_uid", "exam_uuid", "start"]), + models.Index(fields=["user_uid", "exam_uuid", "end"]), + ] + + +class DenyEvent(PrairieTestEvent): + deny_uuid = models.UUIDField() + start = models.DateTimeField(verbose_name=_("Start time"), db_index=True) + end = models.DateTimeField(verbose_name=_("End time"), db_index=True) + cidr_blocks = models.JSONField() + + def __str__(self) -> str: + return f"PrairieTest deny event {self.event_id} with {self.deny_uuid}" + + class Meta: + indexes = [ + models.Index(fields=["deny_uuid", "created"]), + models.Index(fields=["deny_uuid", "start"]), + models.Index(fields=["deny_uuid", "end"]), + ] diff --git a/prairietest/urls.py b/prairietest/urls.py new file mode 100644 index 000000000..14d19378b --- /dev/null +++ b/prairietest/urls.py @@ -0,0 +1,41 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2024 University of Illinois Board of Trustees" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +from django.urls import re_path + +from course.constants import COURSE_ID_REGEX +from prairietest.models import TEST_FACILITY_ID_REGEX +from prairietest.views import webhook + + +urlpatterns = [ + re_path( + r"^course" + "/" + COURSE_ID_REGEX + + "/webhook" + "/" + TEST_FACILITY_ID_REGEX + + "/", + webhook) +] diff --git a/prairietest/utils.py b/prairietest/utils.py new file mode 100644 index 000000000..fc5b1fdc5 --- /dev/null +++ b/prairietest/utils.py @@ -0,0 +1,126 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2024 University of Illinois Board of Trustees" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +from collections.abc import Collection, Mapping, Sequence +from datetime import datetime +from functools import lru_cache +from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network + +from django.db.models import F, Max, Q, Window + +from course.models import Course +from prairietest.models import AllowEvent, DenyEvent + + +def has_access_to_exam( + course: Course, + user_uid: str, + exam_uuid: str, + now: datetime, + ip_address: IPv4Address | IPv6Address + ) -> None | AllowEvent: + # NOTE: This assumes that there aren't two exams with the same UUID + # across multiple testing centers. Otherwise, allow events could be missed. + allow_events = list(AllowEvent.objects.filter( + test_facility__course=course, + user_uid=user_uid, + exam_uuid=exam_uuid + ).order_by("-created")[:1]) + + if not allow_events: + return None + + allow_event, = allow_events + + if now < allow_event.start or allow_event.end < now: + return None + + if any( + ip_address in ip_network(cidr_block) + for cidr_block in allow_event.cidr_blocks): + return allow_event + else: + return None + + +def denials_at( + now: datetime, + course: Course | None, + ) -> Sequence[DenyEvent]: + deny_events = DenyEvent.objects + if course is not None: + deny_events.filter(test_facility__course=course) + + latest_deny_events = deny_events.annotate( + latest_created=Window( + expression=Max("created"), + partition_by=[F("deny_uuid")], + ) + ).filter( + created=F("latest_created"), + ).filter( + # NOTE: It would be appealing to filter by start/end first, before the + # window function logic. But that would not be correct, since an event + # could be 'updated' to shorten its end time. + + # NOTE: Putting 'end' first since it has a chance to exclude more records. + + Q(end__ge=now) | Q(start__le=now) + ).prefetch_related("test_facility", "test_facility__course") + + return list(latest_deny_events) + + +@lru_cache(10) +def _denials_cache_backend( + now_bucket: int, + course_id: int, + ) -> Mapping[tuple[int, str], Collection[IPv6Network | IPv4Network]]: + deny_events = denials_at( + datetime.fromtimestamp(now_bucket), + Course.objects.get(id=course_id), + ) + result: dict[tuple[int, str], set[IPv6Network | IPv4Network]] = {} + for devt in deny_events: + result.setdefault( + (devt.test_facility.course.id, devt.test_facility.identifier), + set() + ).update(ip_network(cidr_block) for cidr_block in devt.cidr_blocks) + + return result + + +def denied_ip_networks_cached_at( + now: datetime, + course: Course | None, + ) -> Mapping[tuple[int, str], Collection[IPv6Network | IPv4Network]]: + """ + :returns: a mapping from (course_id, test_facility_id) to a collection of + networks. + """ + return _denials_cache_backend( + int(now.timestamp() // 60) * 60, + course.id if course else None, + ) diff --git a/prairietest/views.py b/prairietest/views.py new file mode 100644 index 000000000..e81e87131 --- /dev/null +++ b/prairietest/views.py @@ -0,0 +1,203 @@ +from __future__ import annotations + + +__copyright__ = """ +Copyright (C) 2024 University of Illinois Board of Trustees +Copyright (C) 2023 (?) PrairieTest Developers +""" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +The license for the code marked as copied from the PrairieTest docs +is unknown. +""" + +import hashlib +import hmac +import json +import time +from collections.abc import Mapping +from datetime import datetime + +from django import http +from django.core.exceptions import BadRequest, SuspiciousOperation +from django.shortcuts import get_object_or_404 + +from prairietest.models import ( + AllowEvent, + DenyEvent, + PrairieTestEvent, + PrairieTestFacility, +) + + +# Create your views here. + + +# {{{ begin code copied from PrairieTest docs + +# Source: +# https://us.prairietest.com/pt/docs/api/exam-access#testing-the-lms-integration +# Retrieved Sep 10, 2024 +# included with light modifications (type annotation, linter conformance) + +def check_signature( + headers: Mapping[str, str], + body: bytes, + secret: str) -> tuple[bool, str]: + """Check the signature of a webhook event. + + Arguments: + headers -- a dictionary of HTTP headers from the webhook request + body -- the body of the webhook request (as a bytes object) + secret -- the shared secret string + + Returns: + A tuple (signature_ok, message) where signature_ok is True if the signature + is valid, False otherwise, + and message is a string describing the reason for the failure (if any). + """ + if "PrairieTest-Signature" not in headers: + return False, "Missing PrairieTest-Signature header" + prairietest_signature = headers["PrairieTest-Signature"] + + # get the timestamp + timestamp = None + for block in prairietest_signature.split(","): + if block.startswith("t="): + timestamp = block[2:] + break + if timestamp is None: + return False, "Missing timestamp in PrairieTest-Signature" + + # check the timestamp + try: + timestamp_val = int(timestamp) + except ValueError: + return False, "Invalid timestamp in PrairieTest-Signature" + if abs(timestamp_val - time.time()) > 3000: + return False, "Timestamp in PrairieTest-Signature is too old or too new" + + # get the signature + signature = None + for block in prairietest_signature.split(","): + if block.startswith("v1="): + signature = block[3:] + break + if signature is None: + return False, "Missing v1 signature in PrairieTest-Signature" + + # check the signature + signed_payload = bytes(timestamp, "ascii") + b"." + body + expected_signature = hmac.new( + secret.encode("utf-8"), signed_payload, hashlib.sha256).digest().hex() + if signature != expected_signature: + return False, "Incorrect v1 signature in PrairieTest-Signature" + + # everything checks out + return True, "" + +# }}} + + +def webhook( + request: http.HttpRequest, + course_identifier: str, + test_facility_id: str, + ) -> http.HttpResponse: + body = request.body + + test_facility = get_object_or_404( + PrairieTestFacility, + course__identifier=course_identifier, + identifier=test_facility_id, + ) + + sig_valid, msg = check_signature(request.headers, body, test_facility.secret) + if not sig_valid: + raise SuspiciousOperation(f"Invalid PrairieTest signature: {msg}") + + event = json.loads(body) + api_ver: str = event["api_version"] + if api_ver != "2023-07-18": + raise BadRequest(f"Unknown PrairieTest API version: {api_ver}") + + event_id: str = event["event_id"] + if ( + AllowEvent.objects.filter( + test_facility=test_facility, event_id=event_id).exists() + or DenyEvent.objects.filter( + test_facility=test_facility, event_id=event_id).exists() + ): + return http.HttpResponse(b"OK", content_type="text/plain", status=200) + + evt_type: str = event["type"] + created = datetime.fromisoformat(event["created"]) + + if evt_type == "allow_access": + user_uid: str = event["user_uid"] + exam_uuid: str = event["exam_uuid"] + + has_newer_allows = AllowEvent.objects.filter( + test_facility=test_facility, + user_uid=user_uid, + exam_uuid=exam_uuid, + created__ge=created, + ).exists() + + if not has_newer_allows: + allow_evt = AllowEvent( + test_facility=test_facility, + event_id=event_id, + created=created, + user_uid=user_uid, + user_uin=event["user_uin"], + exam_uuid=exam_uuid, + start=datetime.fromisoformat(event["start"]), + end=datetime.fromisoformat(event["end"]), + cidr_blocks=event["cidr_blocks"], + ) + allow_evt.save() + + return http.HttpResponse(b"OK", content_type="text/plain", status=200) + + elif evt_type == "deny_access": + deny_uuid = event["deny_uuid"] + has_newer_denies = DenyEvent.objects.filter( + test_facility=test_facility, + deny_uuid=deny_uuid, + created__ge=created, + ).exists() + + if not has_newer_denies: + deny_evt = DenyEvent( + test_facility=test_facility, + event_id=event_id, + created=created, + deny_uuid=deny_uuid, + start=datetime.fromisoformat(event["start"]), + end=datetime.fromisoformat(event["end"]), + cidr_blocks=event["cidr_blocks"], + ) + deny_evt.save() + + return http.HttpResponse(b"OK", content_type="text/plain", status=200) + else: + raise BadRequest(f"Unknown PrairieTest event type: {evt_type}") diff --git a/relate/settings.py b/relate/settings.py index 05f8faeaf..d45d7d1a3 100644 --- a/relate/settings.py +++ b/relate/settings.py @@ -64,6 +64,7 @@ "accounts", "course", + "prairietest", ) if local_settings.get("RELATE_SIGN_IN_BY_SAML2_ENABLED"): diff --git a/relate/urls.py b/relate/urls.py index 7e7c8052a..4d51c2b58 100644 --- a/relate/urls.py +++ b/relate/urls.py @@ -546,6 +546,8 @@ course.exam.access_exam, name="relate-access_exam"), + path("prairietest/", include("prairietest.urls")), + # }}} path(r"select2/", include("django_select2.urls")),