Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion api/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -429,4 +458,4 @@ def get(self, request, answerpaper_id, format=None):
answerpaper.status = 'completed'
answerpaper.save()
serializer = AnswerPaperSerializer(answerpaper)
return Response(serializer.data)
return Response(serializer.data)
4 changes: 2 additions & 2 deletions docker/Dockerfile_codeserver
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
FROM ubuntu:16.04

MAINTAINER FOSSEE <pythonsupport@fossee.in>
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

Expand Down
10 changes: 5 additions & 5 deletions docker/Dockerfile_django
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
FROM ubuntu:16.04

MAINTAINER FOSSEE <pythonsupport@fossee.in>
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/

Expand Down
6 changes: 6 additions & 0 deletions online_test/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions yaksh/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("""\
<p>
Expand Down Expand Up @@ -285,6 +291,9 @@ def __init__(self, *args, **kwargs):
<p>We hope you enjoy taking this exam !!!</p>
""")

self.fields['seb_settings'].widget = forms.HiddenInput()
self.fields['seb_settings'].required = False

class Meta:
model = Quiz
exclude = ["is_trial", "creator", "is_exercise"]
Expand Down
28 changes: 28 additions & 0 deletions yaksh/migrations/0032_auto_20260630_0730.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
18 changes: 18 additions & 0 deletions yaksh/migrations/0033_quiz_seb_settings.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
29 changes: 29 additions & 0 deletions yaksh/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
60 changes: 60 additions & 0 deletions yaksh/seb_utils.py
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions yaksh/templates/yaksh/add_quiz.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});

/////////////
</script>


{% endblock %}
{% block onload %} onload="javascript:test();" {% endblock %}
{% block content %}
Expand All @@ -45,6 +106,40 @@
<center>
<table class="table table-responsive-sm">
{{ form.as_table }}
<tbody id="seb-mode-row" style="display: none;">
<tr>
<th><label>SEB Configuration Method:</label></th>
<td style="text-align: left;">
<select id="seb_mode" class="form-control">
<option value="dynamic" selected>Generate Dynamically (Recommended)</option>
<option value="manual">Upload Manual Key & File</option>
</select>
<p class="text-muted"><small>Choose whether to automatically build the SEB file or manually upload one.</small></p>
</td>
</tr>
</tbody>
<tbody id="custom-seb-settings" style="display: none;">
<tr>
<th><label>Dynamic SEB Settings:</label></th>
<td style="text-align: left;">
<p class="text-muted"><small>Checking any of these options will auto-generate the SEB file on the backend.</small></p>
<input type="checkbox" id="seb_enable_zoom" class="seb-dyn-setting">
<label for="seb_enable_zoom" title="Allows the student to zoom the web page content in and out.">Enable Page Zoom ℹ️</label><br>

<input type="checkbox" id="seb_show_reload" class="seb-dyn-setting">
<label for="seb_show_reload" title="Displays a reload button in the SEB taskbar so students can refresh the page if it hangs.">Show Reload Button ℹ️</label><br>

<input type="checkbox" id="seb_show_time" class="seb-dyn-setting">
<label for="seb_show_time" title="Displays the current computer time in the SEB taskbar.">Show Time ℹ️</label><br>

<input type="checkbox" id="seb_show_keyboard" class="seb-dyn-setting">
<label for="seb_show_keyboard" title="Allows the student to switch their keyboard layout from the taskbar.">Show Keyboard Layout ℹ️</label><br>

<input type="checkbox" id="seb_use_fullscreen" class="seb-dyn-setting">
<label for="seb_use_fullscreen" title="Forces SEB to fill the entire screen, preventing access to the computer desktop.">Use Full Screen Mode ℹ️</label>
</td>
</tr>
</tbody>
</table>
<br/>
<button class="btn btn-success btn-lg" id="submit" name="questionpaper">
Expand Down
Loading