Skip to content

Commit 8a03022

Browse files
committed
working on adding a minimum version check for lockdown apps
We want to set a minimum version of the lockdown app that students must have, so we can deprecate old versions and ensure compatibility with the Numbas runtime. The immediate motivation is that the new Numbas default theme will use CSS features that weren't supported by the old versions of the Numbas lockdown app or SEB. This also consolidates a lot of the lockdown app launch logic in a class called numbas_lti.lockdown_app.LockdownApp, which has subclasses for the Numbas app and SEB, as well as a trivial one for "no lockdown app required". TODO: * Check version numbers on the iOS and Android Numbas apps * Check how SEB puts its version number in the user agent
1 parent f6c16cc commit 8a03022

8 files changed

Lines changed: 253 additions & 154 deletions

File tree

numbas_lti/lockdown_app.py

Lines changed: 194 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django_auth_lti.patch_reverse import reverse
66
from django.conf import settings
77
from django.core import signing
8+
from django.shortcuts import render
89
from django.utils.translation import gettext_lazy as _
910
import gzip
1011
import hashlib
@@ -36,33 +37,8 @@ def get_ltip3_session_id(request):
3637
launch_data_storage = DjangoCacheDataStorage()
3738
launch_data_storage.set_request(lti1p3_request)
3839
cookie_service = DjangoCookieService(lti1p3_request)
39-
return cookie_service.get_cookie(launch_data_storage.get_session_cookie_name())
40-
41-
def make_link(request):
42-
params = request.GET.copy()
43-
params.update({
44-
'session_key': request.session.session_key,
45-
'lti1p3-session-id': get_ltip3_session_id(request),
46-
})
47-
48-
49-
launch_url = add_query_param(
50-
request.build_absolute_uri(reverse('lockdown_launch')),
51-
params
52-
)
53-
54-
token = token_for_request(request)
55-
56-
link_settings = {
57-
'url': launch_url,
58-
'token': token,
59-
}
60-
61-
password = request.resource.get_lockdown_app_password(user=request.user)
62-
63-
iv, encrypted = encrypt(password, json.dumps(link_settings))
64-
url = f'numbas://{request.get_host()}/'+binascii.hexlify(iv+encrypted).decode('ascii')
65-
return url
40+
cookie_name = launch_data_storage.get_session_cookie_name() or ''
41+
return cookie_service.get_cookie(cookie_name)
6642

6743
def token_for_request(request):
6844
return signing.dumps({
@@ -74,73 +50,215 @@ def validate_token(request, token):
7450
d = signing.loads(token)
7551
return isinstance(d,dict) and d.get('resource') == request.resource.pk and d.get('user') == request.user.pk
7652

77-
def is_lockdown_app(request):
78-
required, lockdown_app_password, seb_settings = request.resource.require_lockdown_app_for_user(request.user)
79-
80-
checker = {
81-
'numbas': is_numbas_lockdown_app,
82-
'seb': is_seb,
83-
}
53+
class OldVersionException(Exception):
54+
def __init__(self, version, min_version):
55+
self.version = version
56+
self.min_version = min_version
8457

58+
def parse_version(version_string: str):
8559
try:
86-
fn = checker[required]
87-
except KeyError:
88-
return False
60+
return [int(x) for x in version_string.split('.')]
61+
except ValueError:
62+
raise ValueError(f"{version_string} is not a valid version number.")
63+
64+
def compare_versions(a: str, b: str) -> int:
65+
a = parse_version(a)
66+
b = parse_version(b)
67+
return 1 if a > b else -1 if a < b else 0
68+
69+
class LockdownApp:
70+
app_name = ''
71+
app_name_display = ''
72+
73+
def __init__(self, request):
74+
self.request = request
75+
76+
def is_lockdown_app(self) -> bool:
77+
raise NotImplementedError
78+
79+
def get_app_version(self):
80+
""" Return (version: str, platform: str)
81+
"""
82+
raise NotImplementedError
83+
84+
def get_install_url(self) -> str:
85+
raise NotImplementedError
86+
87+
def show_lockdown_link(self):
88+
"""
89+
Show the link to open the lockdown app.
90+
"""
91+
92+
raise NotImplementedError
93+
94+
95+
def check_version(self):
96+
"""
97+
Raises an OldVersionException if the user's version of the app is older than the minimum specified in settings.LOCKDOWN_APP['minimum_version'][this.app_name][platform]
98+
"""
99+
version, platform = self.get_app_version()
100+
101+
min_version = settings.LOCKDOWN_APP.get('minimum_version',{}).get(self.app_name,{}).get(platform)
102+
if min_version is not None:
103+
if compare_versions(version, min_version) < 0:
104+
raise OldVersionException(version, min_version)
105+
106+
def old_version_response(self, err: OldVersionException):
107+
return render(
108+
self.request,
109+
'numbas_lti/lockdown_launch/must_upgrade_app.html',
110+
{
111+
'app_name': self.app_name_display,
112+
'version': err.version,
113+
'min_version': err.min_version,
114+
'install_url': self.get_install_url(),
115+
'client_version': err.version,
116+
}
117+
)
89118

90-
return fn(request, lockdown_app_password=lockdown_app_password, seb_settings=seb_settings)
119+
def show_lockdown_link(self):
120+
launch_url = self.make_launch_url()
121+
password = self.request.resource.get_lockdown_app_password(user=self.request.user)
122+
return render(
123+
self.request,
124+
'numbas_lti/lockdown_launch/app_link.html',
125+
{
126+
'launch_url': launch_url,
127+
'install_url': settings.LOCKDOWN_APP.get('seb_install_url'),
128+
'password': password,
129+
'app_name': self.app_name_display,
130+
}
131+
)
91132

92-
def is_numbas_lockdown_app(request, **kwargs):
93-
header = request.META.get('HTTP_AUTHORIZATION')
94-
if header is None:
95-
return False
96133

97-
m = re.match(r'^Basic (?P<token>.*)$',header)
98-
if not m:
99-
return False
100-
101-
token = m.group('token')
134+
class NoLockdownApp(LockdownApp):
135+
def is_lockdown_app(self):
136+
return True
102137

103-
try:
104-
validate_token(request, token)
105-
except signing.BadSignature:
106-
return False
138+
def check_version(self):
139+
pass
107140

108-
return True
109141

110-
def make_seb_link(request):
111-
params = request.GET.copy()
112-
params.update({
113-
'session_key': request.session.session_key,
114-
'lti1p3-session-id': get_ltip3_session_id(request),
115-
})
142+
class NumbasLockdownApp(LockdownApp):
143+
app_name = 'numbas'
144+
app_name_display = _('Numbas lockdown app')
116145

117-
query = '&'.join(f'{k}={urllib.parse.quote(v)}' for k,v in params.items() if v is not None)
146+
def get_install_url(self):
147+
return settings.LOCKDOWN_APP.get('install_url')
118148

119-
_, _, seb_settings = request.resource.require_lockdown_app_for_user(request.user)
120-
settings_url = seb_settings.settings_file.url
149+
def is_lockdown_app(self):
150+
_, lockdown_app_password, _ = self.request.resource.require_lockdown_app_for_user(self.request.user)
121151

122-
scheme = 'sebs' if request.is_secure() else 'seb'
123-
url = urllib.parse.urlunparse((scheme, request.get_host(), settings_url, '', '', ''))+'??'+query
124-
return url
152+
header = self.request.META.get('HTTP_AUTHORIZATION')
153+
if header is None:
154+
return False
155+
156+
m = re.match(r'^Basic (?P<token>.*)$',header)
157+
if not m:
158+
return False
159+
160+
token = m.group('token')
161+
162+
try:
163+
validate_token(self.request, token)
164+
except signing.BadSignature:
165+
return False
166+
167+
return True
168+
169+
def get_app_version(self):
170+
user_agent = self.request.META['HTTP_USER_AGENT']
171+
info = dict(re.findall(r'\((?P<key>.*?): (?P<value>.*?)\)', user_agent))
172+
version = info.get('Version')
173+
platform = info.get('Platform')
174+
175+
return (version, platform)
176+
177+
def make_launch_url(self):
178+
params = self.request.GET.copy()
179+
params.update({
180+
'session_key': self.request.session.session_key,
181+
'lti1p3-session-id': get_ltip3_session_id(self.request),
182+
})
125183

126-
def is_seb(request, seb_settings=None, **kwargs):
127-
"""
128-
Check that the request has come from SEB.
129-
The Mac and iOS apps don't send this header any more, so this only works for Windows SEB.
130184

131-
There's a description of how this is supposed to work at https://safeexambrowser.org/developer/seb-config-key.html
185+
launch_url = add_query_param(
186+
self.request.build_absolute_uri(reverse('lockdown_launch')),
187+
params
188+
)
189+
190+
token = token_for_request(self.request)
191+
192+
link_settings = {
193+
'url': launch_url,
194+
'token': token,
195+
}
196+
197+
password = self.request.resource.get_lockdown_app_password(user=self.request.user)
198+
199+
iv, encrypted = encrypt(password, json.dumps(link_settings))
200+
url = f'numbas://{self.request.get_host()}/'+binascii.hexlify(iv+encrypted).decode('ascii')
201+
return url
202+
203+
204+
class SEBApp(LockdownApp):
205+
app_name = 'seb'
206+
app_name_display = 'Safe Exam Browser'
207+
launch_link_template = 'numbas_lti/lockdown_launch/seb_link.html'
208+
209+
def is_lockdown_app(self):
210+
"""
211+
Check that the request has come from SEB.
212+
The Mac and iOS apps don't send this header any more, so this only works for Windows SEB.
213+
214+
There's a description of how this is supposed to work at https://safeexambrowser.org/developer/seb-config-key.html
215+
"""
216+
217+
_, _, seb_settings = self.request.resource.require_lockdown_app_for_user(self.request.user)
218+
219+
if seb_settings is None:
220+
return False
221+
222+
header_hash = self.request.headers.get('X-Safeexambrowser-Configkeyhash')
223+
uri = self.request.build_absolute_uri()
224+
key = seb_settings.config_key_hash
225+
226+
expected_hash = hashlib.sha256((uri + key).encode('utf-8')).hexdigest()
227+
228+
return header_hash == expected_hash
229+
230+
def make_launch_url(self):
231+
params = self.request.GET.copy()
232+
params.update({
233+
'session_key': self.request.session.session_key,
234+
'lti1p3-session-id': get_ltip3_session_id(self.request),
235+
})
236+
237+
query = '&'.join(f'{k}={urllib.parse.quote(v)}' for k,v in params.items() if v is not None)
238+
239+
_, _, seb_settings = self.request.resource.require_lockdown_app_for_user(self.request.user)
240+
settings_url = seb_settings.settings_file.url
241+
242+
scheme = 'sebs' if self.request.is_secure() else 'seb'
243+
url = urllib.parse.urlunparse((scheme, self.request.get_host(), settings_url, '', '', ''))+'??'+query
244+
return url
245+
246+
247+
def lockdown_app_controller(request):
248+
"""
249+
Get the appropriate subclass of LockdownApp for the resource associated with the request.
132250
"""
251+
required, _, _ = request.resource.require_lockdown_app_for_user(request.user)
133252

134-
if seb_settings is None:
135-
return False
253+
controllers = {
254+
'numbas': NumbasLockdownApp,
255+
'seb': SEBApp,
256+
}
136257

137-
header_hash = request.headers.get('X-Safeexambrowser-Configkeyhash')
138-
uri = request.build_absolute_uri()
139-
key = seb_settings.config_key_hash
258+
controller_cls = controllers.get(required, NoLockdownApp)
140259

141-
expected_hash = hashlib.sha256((uri + key).encode('utf-8')).hexdigest()
260+
return controller_cls(request)
142261

143-
return header_hash == expected_hash
144262

145263
SaltBitSize = 64
146264
KeyBitSize = 256

numbas_lti/templates/numbas_lti/lockdown_launch/numbas_app_link.html renamed to numbas_lti/templates/numbas_lti/lockdown_launch/app_link.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
{% block meta %}
66
{{block.super}}
7-
<meta http-equiv="refresh" content="0;URL='{{lockdown_url}}'" />
7+
<meta http-equiv="refresh" content="0;URL='{{launch_url}}'" />
88
{% endblock meta %}
99

1010
{% block title %}{{request.resource.title}} - {{block.super}}{% endblock title %}
@@ -17,11 +17,11 @@ <h1>
1717
</header>
1818

1919
<main>
20-
<p>{% translate "This resource must run in the Numbas lockdown app." %}</p>
21-
<p>{% translate "The Numbas lockdown app will open automatically, if you have it installed." %}</p>
20+
<p>{% blocktranslate with app_name=app_name %}This resource must run in {{app_name}}.{% endblocktranslate %}</p>
21+
<p>{% blocktranslate with app_name=app_name %}{{app_name}} will open automatically, if you have it installed.{% endblocktranslate %}</p>
2222

2323
{% if install_url %}
24-
<p><a class="button info" href="{{install_url}}" target="_blank">{% icon 'download' %} {% translate "Install the app" %}</a></p>
24+
<p><a class="button info" href="{{install_url}}" target="_blank">{% icon 'download' %} {% blocktranslate with app_name=app_name %}Install {{app_name}}{% endblocktranslate %}</a></p>
2525
{% endif %}
2626

2727
{% if request.resource.show_lockdown_app_password and password %}
@@ -31,8 +31,8 @@ <h1>
3131
</div>
3232
{% endif %}
3333

34-
<p>{% translate "Or, click the link below to open this resource in the Numbas lockdown app:" %}</p>
35-
<p><a class="button large primary" href="{{lockdown_url}}">{% translate "Launch in the Numbas lockdown app" %}</a></p>
34+
<p>{% blocktranslate with app_name=app_name %}Or, click the link below to open this resource in {{app_name}}{% endblocktranslate %}</p>
35+
<p><a class="button large primary" href="{{launch_url}}">{% blocktranslate with app_name=app_name %}Launch in {{app_name}}{% endblocktranslate %}</a></p>
3636
</main>
3737
</div>
3838
{% endblock content %}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{% extends "numbas_lti/base.html" %}
2+
{% load i18n %}
3+
{% load icon %}
4+
5+
{% block title %}{% blocktranslate with app=app %}Please upgrade {{app}}{% endblocktranslate %} - {{block.super}}{% endblock title %}
6+
7+
{% block content %}
8+
<header>
9+
<h1>
10+
{% blocktranslate with app=app %}Please upgrade {{app}}{% endblocktranslate %}
11+
</h1>
12+
</header>
13+
14+
<main>
15+
<p>{% blocktranslate with app_name=app_name %}The version of {{app_name}} that you are using is out of date.{% endblocktranslate %}</p>
16+
<p>{% blocktranslate with version=version min_version=min_version %}You have version {{version}} but version {{min_version}} or newer is required.{% endblocktranslate %}</p>
17+
<p>{% blocktranslate %}Please install the latest version, and then open this link again.{% endblocktranslate %}</p>
18+
19+
<p><a class="button info" href="{{install_url}}" target="_blank">{% icon 'download' %} {% translate "Install the app" %}</a></p>
20+
</main>
21+
</div>
22+
{% endblock content %}
23+

0 commit comments

Comments
 (0)