forked from mitodl/mit_lti_flask_sample
-
Notifications
You must be signed in to change notification settings - Fork 0
/
pyltiflask.py
255 lines (211 loc) · 7.7 KB
/
pyltiflask.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# -*- coding: utf-8 -*-
"""
PyLTI decorator implementation for flask framework
"""
from __future__ import absolute_import
from functools import wraps
import logging
from flask import session, current_app, Flask
from flask import request as flask_request
from pylti.common import (
LTI_SESSION_KEY,
LTI_PROPERTY_LIST,
verify_request_common,
default_error,
LTIException,
LTINotInSessionException,
LTIBase
)
from models import LtiSession
log = logging.getLogger('pylti.flask') # pylint: disable=invalid-name
LTI_PROPERTIES = [
'custom_exercise',
LTI_SESSION_KEY
] + LTI_PROPERTY_LIST
class LTI(LTIBase):
"""
LTI Object represents abstraction of current LTI session. It provides
callback methods and methods that allow developer to inspect
LTI basic-launch-request.
This object is instantiated by @lti wrapper.
"""
def __init__(self, lti_args, lti_kwargs):
self.lti_session = self.get_ltisession()
LTIBase.__init__(self, lti_args, lti_kwargs)
# Set app to current_app if not specified
if not self.lti_kwargs['app']:
self.lti_kwargs['app'] = current_app
def _consumers(self):
"""
Gets consumer's map from app config
:return: consumers map
"""
app_config = self.lti_kwargs['app'].config
config = app_config.get('PYLTI_CONFIG', dict())
consumers = config.get('consumers', dict())
return consumers
def verify_request(self):
"""
Verify LTI request
:raises: LTIException is request validation failed
"""
if flask_request.method == 'POST':
params = flask_request.form.to_dict()
else:
params = flask_request.args.to_dict()
log.debug(params)
self.lti_session.delete()
self.lti_session = self.get_ltisession(params=params)
log.debug('verify_request?')
try:
verify_request_common(self._consumers(), flask_request.url,
flask_request.method, flask_request.headers,
params)
log.debug('verify_request success')
# All good to go, store all of the LTI params into a
# session dict for use in views
for prop in LTI_PROPERTIES:
if params.get(prop, None):
log.debug("params %s=%s", prop, params[prop])
setattr(self.lti_session, prop, params[prop])
session['user_id'] = params['user_id']
# Set logged in session key
setattr(self.lti_session, LTI_SESSION_KEY, True)
self.lti_session.save()
return True
except LTIException:
log.debug('verify_request failed')
self.close_session()
raise
@property
def response_url(self):
"""
Returns remapped lis_outcome_service_url
uses PYLTI_URL_FIX map to support edX dev-stack
:return: remapped lis_outcome_service_url
"""
url = self.lti_session.lis_outcome_service_url or ""
app_config = self.lti_kwargs['app'].config
urls = app_config.get('PYLTI_URL_FIX', dict())
# url remapping is useful for using devstack
# devstack reports httpS://localhost:8000/ and listens on HTTP
for prefix, mapping in urls.items():
if url.startswith(prefix):
for _from, _to in mapping.items():
url = url.replace(_from, _to)
return url
def _verify_any(self):
"""
Verify that an initial request has been made, or failing that, that
the request is in the session
:raises: LTIException
"""
log.debug('verify_any enter')
# Check to see if there is a new LTI launch request incoming
newrequest = False
if flask_request.method == 'POST':
params = flask_request.form.to_dict()
initiation = "basic-lti-launch-request"
if params.get("lti_message_type", None) == initiation:
newrequest = True
# Scrub the session of the old authentication
self.close_session()
# Attempt the appropriate validation
# Both of these methods raise LTIException as necessary
if newrequest:
self.verify_request()
else:
self._verify_session()
def get_ltisession(self, params={}):
"""
Get lti_session based on flask.path. Flask path must be the same
as the exercise name
Otherwise the lti_session is treated as initial request.
current_app.config['EXERCISE_MAP'] MUST be defined
"""
custom_exercise = None
path = flask_request.path.split('/')[1].split('?')[0]
for exercise in current_app.config['EXERCISE_MAP'].keys():
if exercise == path:
custom_exercise = exercise
break
else:
# a new LTI launch request
custom_exercise = params.get('custom_exercise', None)
lti_session = LtiSession \
.where('user_id', '=', session.get('user_id', None)) \
.where('custom_exercise', '=', custom_exercise) \
.first()
if not lti_session:
lti_session = LtiSession()
self.session = lti_session
return lti_session
def _verify_session(self):
"""
Verify that session was already created
:raises: LTIException
"""
def _fail():
log.debug('verify_session failed')
raise LTINotInSessionException('Session expired or unavailable')
try:
if not getattr(self.lti_session, LTI_SESSION_KEY):
_fail()
except AttributeError:
_fail()
def close_session(self):
"""
Invalidates session
"""
if session.get('user_id', None):
del session['user_id']
self.lti_session.delete()
self.lti_session = self.get_ltisession()
def lti(app=None, request='any', error=default_error, role='any',
*lti_args, **lti_kwargs):
"""
LTI decorator
:param: app - Flask App object (optional).
:py:attr:`flask.current_app` is used if no object is passed in.
:param: error - Callback if LTI throws exception (optional).
:py:attr:`pylti.flask.default_error` is the default.
:param: request - Request type from
:py:attr:`pylti.common.LTI_REQUEST_TYPE`. (default: any)
:param: roles - LTI Role (default: any)
:return: wrapper
"""
def _lti(function):
"""
Inner LTI decorator
:param: function:
:return:
"""
@wraps(function)
def wrapper(*args, **kwargs):
"""
Pass LTI reference to function or return error.
"""
try:
the_lti = LTI(lti_args, lti_kwargs)
the_lti.verify()
the_lti._check_role() # pylint: disable=protected-access
kwargs['lti'] = the_lti
return function(*args, **kwargs)
except LTIException as lti_exception:
error = lti_kwargs.get('error')
exception = dict()
exception['exception'] = lti_exception
exception['kwargs'] = kwargs
exception['args'] = args
return error(exception=exception)
return wrapper
lti_kwargs['request'] = request
lti_kwargs['error'] = error
lti_kwargs['role'] = role
if (not app) or isinstance(app, Flask):
lti_kwargs['app'] = app
return _lti
else:
# We are wrapping without arguments
lti_kwargs['app'] = None
return _lti(app)