diff --git a/api/views.py b/api/views.py index 8d2da8318..3a6810372 100644 --- a/api/views.py +++ b/api/views.py @@ -1,12 +1,14 @@ from yaksh.models import ( Question, Quiz, QuestionPaper, QuestionSet, AnswerPaper, Course, Answer ) +from yaksh.seb_utils import check_seb_access from api.serializers import ( QuestionSerializer, QuizSerializer, QuestionPaperSerializer, AnswerPaperSerializer, CourseSerializer ) from rest_framework.views import APIView from rest_framework.response import Response +from django.urls import reverse from rest_framework import status from rest_framework import permissions from rest_framework.authtoken.models import Token @@ -60,6 +62,21 @@ def get(self, request, course_id, quiz_id, format=None): quiz = self.get_quiz(quiz_id, user) questionpaper = quiz.questionpaper_set.first() + # Safe Exam Browser Check + if quiz.is_seb_required: + course = Course.objects.get(id=course_id) + module = course.learning_module.filter(learning_unit__quiz=quiz).first() + module_id = module.id if module else 0 + + seb_valid, seb_msg = check_seb_access(request, quiz, module_id, course_id) + if not seb_valid: + seb_file_url = request.build_absolute_uri(reverse('yaksh:download_seb_config', args=[quiz.id, module_id, course_id])) if (quiz.seb_settings or quiz.seb_config_file) else None + return Response({ + 'message': seb_msg, + 'requires_seb': True, + 'seb_file_url': seb_file_url + }, status=status.HTTP_403_FORBIDDEN) + last_attempt = AnswerPaper.objects.get_user_last_attempt( questionpaper, user, course_id) if last_attempt and last_attempt.is_attempt_inprogress(): @@ -187,6 +204,18 @@ def post(self, request, answerpaper_id, question_id, format=None): user = request.user answerpaper = self.get_answerpaper(answerpaper_id, user) question = self.get_question(question_id, answerpaper) + + # Check SEB for every request during the quiz + quiz = answerpaper.question_paper.quiz + if quiz.is_seb_required: + course_id = answerpaper.course.id if answerpaper.course else 0 + module = answerpaper.course.learning_module.filter(learning_unit__quiz=quiz).first() if answerpaper.course else None + module_id = module.id if module else 0 + + seb_valid, seb_msg = check_seb_access(request, quiz, module_id, course_id) + if not seb_valid: + return Response({'message': seb_msg}, status=status.HTTP_403_FORBIDDEN) + try: if question.type == 'mcq' or question.type == 'mcc': user_answer = request.data['answer'] @@ -429,4 +458,4 @@ def get(self, request, answerpaper_id, format=None): answerpaper.status = 'completed' answerpaper.save() serializer = AnswerPaperSerializer(answerpaper) - return Response(serializer.data) \ No newline at end of file + return Response(serializer.data) diff --git a/docker/Dockerfile_codeserver b/docker/Dockerfile_codeserver index 1f030d51f..3b2f3a08d 100644 --- a/docker/Dockerfile_codeserver +++ b/docker/Dockerfile_codeserver @@ -1,13 +1,13 @@ FROM ubuntu:16.04 -MAINTAINER FOSSEE +ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ apt-get install git python3-pip libmysqlclient-dev sudo default-jre default-jdk -y VOLUME /Sites/online_test -ADD Files/requirements-* /tmp/ +ADD Files/requirements/requirements-* /tmp/ RUN pip3 install -r /tmp/requirements-codeserver.txt && mkdir -p /Sites/online_test/yaksh_data/output /Sites/online_test/yaksh_data/data diff --git a/docker/Dockerfile_django b/docker/Dockerfile_django index daddc453b..fce0a62c4 100644 --- a/docker/Dockerfile_django +++ b/docker/Dockerfile_django @@ -1,16 +1,16 @@ FROM ubuntu:16.04 -MAINTAINER FOSSEE +ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update -y && apt-get install git python3-pip vim libmysqlclient-dev sudo -y +RUN apt-get update -y && apt-get install git python3-pip vim libmysqlclient-dev sudo -y -RUN apt-get install apache2 libapache2-mod-wsgi-py3 python3-django -y && mkdir -p /Sites/online_test +RUN apt-get install apache2 libapache2-mod-wsgi-py3 python3-django -y && mkdir -p /Sites/online_test VOLUME /Sites/online_test -ADD Files/requirements-* /tmp/ +ADD Files/requirements/requirements-* /tmp/ -RUN cd /Sites/online_test && pip3 install -r /tmp/requirements-py3.txt +RUN cd /Sites/online_test && pip3 install -r /tmp/requirements-production.txt ADD Files/000-default.conf /etc/apache2/sites-enabled/ diff --git a/online_test/settings.py b/online_test/settings.py index 4bd800d30..2b94873d5 100644 --- a/online_test/settings.py +++ b/online_test/settings.py @@ -255,6 +255,12 @@ CORS_ORIGIN_ALLOW_ALL = True CORS_ALLOW_CREDENTIALS = True +from corsheaders.defaults import default_headers +CORS_ALLOW_HEADERS = list(default_headers) + [ + 'x-safeexambrowser-configkeyhash', + 'x-safeexambrowser-requestkeyhash', +] + # AWS Credentials USE_AWS = False diff --git a/yaksh/forms.py b/yaksh/forms.py index 01e691d50..45055493f 100644 --- a/yaksh/forms.py +++ b/yaksh/forms.py @@ -257,6 +257,12 @@ def __init__(self, *args, **kwargs): self.fields['pass_criteria'].widget.attrs.update( {'class': form_input_class} ) + self.fields['seb_config_key'].widget.attrs.update( + {'class': form_input_class} + ) + self.fields['seb_config_file'].widget.attrs.update( + {'class': "custom-file-input"} + ) self.fields["instructions"].initial = dedent("""\

@@ -285,6 +291,9 @@ def __init__(self, *args, **kwargs):

We hope you enjoy taking this exam !!!

""") + self.fields['seb_settings'].widget = forms.HiddenInput() + self.fields['seb_settings'].required = False + class Meta: model = Quiz exclude = ["is_trial", "creator", "is_exercise"] diff --git a/yaksh/migrations/0032_auto_20260630_0730.py b/yaksh/migrations/0032_auto_20260630_0730.py new file mode 100644 index 000000000..f89199a2e --- /dev/null +++ b/yaksh/migrations/0032_auto_20260630_0730.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.7 on 2026-06-30 07:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('yaksh', '0030_alter_answerpaper_user_ip_max_len'), + ] + + operations = [ + migrations.AddField( + model_name='quiz', + name='is_seb_required', + field=models.BooleanField(default=False, verbose_name='Require Safe Exam Browser'), + ), + migrations.AddField( + model_name='quiz', + name='seb_config_file', + field=models.FileField(blank=True, help_text='Upload the .seb file for automatic student launching', null=True, upload_to='seb_configs/', verbose_name='SEB Config File'), + ), + migrations.AddField( + model_name='quiz', + name='seb_config_key', + field=models.CharField(blank=True, help_text='The Config Key Hash generated by SEB Configuration Tool', max_length=255, null=True, verbose_name='SEB Config Key'), + ), + ] diff --git a/yaksh/migrations/0033_quiz_seb_settings.py b/yaksh/migrations/0033_quiz_seb_settings.py new file mode 100644 index 000000000..823d44cb2 --- /dev/null +++ b/yaksh/migrations/0033_quiz_seb_settings.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.7 on 2026-07-02 11:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('yaksh', '0032_auto_20260630_0730'), + ] + + operations = [ + migrations.AddField( + model_name='quiz', + name='seb_settings', + field=models.JSONField(blank=True, default=dict, help_text='JSON mapping for dynamically generated SEB configurations.', null=True, verbose_name='SEB Dynamic Settings'), + ), + ] diff --git a/yaksh/models.py b/yaksh/models.py index bf38b9735..5217b4c0b 100644 --- a/yaksh/models.py +++ b/yaksh/models.py @@ -532,6 +532,35 @@ class Quiz(models.Model): is_exercise = models.BooleanField(default=False) + is_seb_required = models.BooleanField( + "Require Safe Exam Browser", + default=False + ) + + seb_config_key = models.CharField( + "SEB Config Key", + max_length=255, + blank=True, + null=True, + help_text="The Config Key Hash generated by SEB Configuration Tool" + ) + + seb_config_file = models.FileField( + "SEB Config File", + upload_to='seb_configs/', + blank=True, + null=True, + help_text="Upload the .seb file for automatic student launching" + ) + + seb_settings = models.JSONField( + "SEB Dynamic Settings", + default=dict, + blank=True, + null=True, + help_text="JSON mapping for dynamically generated SEB configurations." + ) + creator = models.ForeignKey(User, null=True, on_delete=models.CASCADE) objects = QuizManager() diff --git a/yaksh/seb_utils.py b/yaksh/seb_utils.py new file mode 100644 index 000000000..048ea88aa --- /dev/null +++ b/yaksh/seb_utils.py @@ -0,0 +1,60 @@ +import hashlib +import plistlib +import hmac +from django.urls import reverse + +def check_seb_access(request, quiz, module_id, course_id): + """ + Checks if the current request satisfies Safe Exam Browser requirements for the given quiz. + Returns (True, None) if successful, (False, error_message) if it fails. + """ + if not quiz.is_seb_required: + return True, None + + user_agent = request.META.get('HTTP_USER_AGENT', '') + if 'SEB' not in user_agent: + return False, 'This quiz requires Safe Exam Browser. Please launch the quiz using the provided .seb configuration file.' + + seb_hash_header = request.META.get('HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH') + if not seb_hash_header: + return False, 'This quiz requires Safe Exam Browser. Please launch the quiz using the provided .seb configuration file.' + + requested_url = request.build_absolute_uri() + + if quiz.seb_settings: + # Dynamic SEB Config validation + questionpaper = quiz.questionpaper_set.first() + if not questionpaper: + # Cannot validate dynamic hash without a question paper to form startURL + return True, None + + start_url = request.build_absolute_uri( + reverse('yaksh:start_quiz', args=[questionpaper.id, module_id, course_id]) + ) + settings = quiz.seb_settings + config = { + 'startURL': start_url, + 'sebMode': 0, + 'browserViewMode': 1 if settings.get('seb_use_fullscreen') else 0, + 'enableZoomPage': bool(settings.get('seb_enable_zoom')), + 'enableZoomText': bool(settings.get('seb_enable_zoom')), + 'showReloadButton': bool(settings.get('seb_show_reload')), + 'showTime': bool(settings.get('seb_show_time')), + 'showKeyboardLayout': bool(settings.get('seb_show_keyboard')), + 'hashedQuitPassword': hashlib.sha256(settings.get('seb_quit_password', 'yaksh').encode('utf-8')).hexdigest(), + } + plist_bytes = plistlib.dumps(config, fmt=plistlib.FMT_XML) + config_key = hashlib.sha256(plist_bytes).hexdigest() + expected_hash = hashlib.sha256((requested_url + config_key).encode('utf-8')).hexdigest() + elif quiz.seb_config_key: + # Static SEB Config validation + expected_hash = hashlib.sha256((requested_url + quiz.seb_config_key).encode('utf-8')).hexdigest() + else: + # If SEB is required but no settings or key provided, we can't validate hash + # But we still enforce SEB User-Agent, which is already checked + return True, None + + if not hmac.compare_digest(seb_hash_header.lower(), expected_hash.lower()): + return False, 'Safe Exam Browser configuration mismatch. Please use the exact .seb file provided by your instructor.' + + return True, None diff --git a/yaksh/templates/yaksh/add_quiz.html b/yaksh/templates/yaksh/add_quiz.html index d43c1c6c5..78396e973 100644 --- a/yaksh/templates/yaksh/add_quiz.html +++ b/yaksh/templates/yaksh/add_quiz.html @@ -19,9 +19,70 @@ $(document).ready(function() { $("#id_start_date_time").datetimepicker({format: 'Y-m-d H:i:s'}); $("#id_end_date_time").datetimepicker({format: 'Y-m-d H:i:s'}); + + /////////////// + + function toggleSebFields() { + if ($("#id_is_seb_required").is(":checked")) { + $("#seb-mode-row").show(); + if ($("#seb_mode").val() === "dynamic") { + $("#id_seb_config_key").closest("tr").hide(); + $("#id_seb_config_file").closest("tr").hide(); + $("#custom-seb-settings").show(); + } else { + $("#id_seb_config_key").closest("tr").show(); + $("#id_seb_config_file").closest("tr").show(); + $("#custom-seb-settings").hide(); + } + } else { + $("#seb-mode-row").hide(); + $("#id_seb_config_key").closest("tr").hide(); + $("#id_seb_config_file").closest("tr").hide(); + $("#custom-seb-settings").hide(); + } + } + toggleSebFields(); + $("#id_is_seb_required").change(toggleSebFields); + + $(document).on('change', '#seb_mode', toggleSebFields); + + try { + var settingsVal = $("#id_seb_settings").val(); + if (settingsVal && settingsVal !== "{}" && settingsVal !== '""' && settingsVal !== "None") { + var settings = JSON.parse(settingsVal); + if (settings.seb_enable_zoom) $("#seb_enable_zoom").prop("checked", true); + if (settings.seb_show_reload) $("#seb_show_reload").prop("checked", true); + if (settings.seb_show_time) $("#seb_show_time").prop("checked", true); + if (settings.seb_show_keyboard) $("#seb_show_keyboard").prop("checked", true); + if (settings.seb_use_fullscreen) $("#seb_use_fullscreen").prop("checked", true); + $("#seb_mode").val("dynamic"); + } else if ($("#id_seb_config_key").val() || $("#id_seb_config_file").val()) { + $("#seb_mode").val("manual"); + } + } catch (e) { + console.error("Failed to parse seb_settings", e); + } + $("#submit").click(function() { + if ($("#seb_mode").val() === "dynamic" && $("#id_is_seb_required").is(":checked")) { + var settings = { + "seb_enable_zoom": $("#seb_enable_zoom").is(":checked"), + "seb_show_reload": $("#seb_show_reload").is(":checked"), + "seb_show_time": $("#seb_show_time").is(":checked"), + "seb_show_keyboard": $("#seb_show_keyboard").is(":checked"), + "seb_use_fullscreen": $("#seb_use_fullscreen").is(":checked") + }; + $("#id_seb_settings").val(JSON.stringify(settings)); + } else { + $("#id_seb_settings").val("{}"); + } + return true; + }); }); + + ///////////// + {% endblock %} {% block onload %} onload="javascript:test();" {% endblock %} {% block content %} @@ -45,6 +106,40 @@
{{ form.as_table }} + + + + + + + + + + + +