Skip to content
Draft
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
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,25 @@ just want to use the general CORGIS Github instance. No further work is required
Your `CONSUMER_KEY` uniquely identifies you to Canvas, `CONSUMER_SECRET` is to be shared with instructors using your
BlockPy instance. The goal is to keep it relatively secretive. You can choose anything you want for your Key and Secret.

For LTI 1.3 launches and LTI Advantage AGS grade passback, configure one or more platforms with `LTI13_PLATFORMS` in
your instance configuration. Each entry should provide an `issuer`, `client_id`, `auth_login_url`, `auth_token_url`,
`jwks_url`, and either `client_secret` or a `private_key` / `private_key_file` (plus optional `kid`). You can also set
`deployment_ids` and `service` when you need to scope a platform more narrowly or keep Canvas-specific behavior.

```python
LTI13_PLATFORMS = [{
"issuer": "https://canvas.instructure.com",
"client_id": "YOUR_DEVELOPER_KEY_ID",
"deployment_ids": ["YOUR_DEPLOYMENT_ID"],
"auth_login_url": "https://canvas.instructure.com/api/lti/authorize_redirect",
"auth_token_url": "https://canvas.instructure.com/login/oauth2/token",
"jwks_url": "https://canvas.instructure.com/api/lti/security/jwks",
"private_key_file": "/full/path/lti13_private_key.pem",
"kid": "OPTIONAL_JWK_KEY_ID",
"service": "canvas"
}]
```

## Database setup

You're going to need to create a new Postgres database and prepopulate some schemas. Our database is named `blockpydb`.
Expand Down Expand Up @@ -719,4 +738,3 @@ CC106 --> SSC

@enduml
```

56 changes: 47 additions & 9 deletions controllers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import json
from functools import wraps

from flask import current_app, g, jsonify, make_response, request, abort
from flask import current_app, g, jsonify, make_response, request, abort, redirect
from werkzeug.datastructures import ImmutableMultiDict
from werkzeug.wrappers import Request
from flask_jwt_extended import create_access_token, get_jwt_identity, verify_jwt_in_request, \
Expand All @@ -28,6 +28,12 @@
from flask_security.core import current_user
import flask_security
from controllers.pylti.flask import LTI_SESSION_KEY, LTI, LTIException
from controllers.pylti.lti13 import (
build_login_redirect_url,
find_platform,
is_lti13_launch,
is_lti13_login_initiation,
)
from flask_jwt_extended import create_access_token

from controllers.services import ValidUserPermissionLayer, InvalidUserPermissionLayer
Expand Down Expand Up @@ -130,6 +136,8 @@ def login_user_if_able():
# During the login process, we will let the user be anonymous
return make_user_anonymous(request.remote_addr)
# If LTI parameters are available, let's try setting that up
if is_lti13_login_request(request):
return try_lti13_login_initiation()
if is_lti_launch_request(request):
return try_lti_login_initial()
# If a stored LTI session was provided, we can try that instead
Expand Down Expand Up @@ -164,6 +172,12 @@ def get_consumer_secrets(app=None):
}


def get_lti13_platforms(app=None):
if app is None:
app = current_app
return app.config.get('LTI13_PLATFORMS', [])


def load_logged_in_user():
"""
If a current_user is available, then logs them in as the current user.
Expand All @@ -173,7 +187,7 @@ def load_logged_in_user():
g.user = current_user
g.safely = ValidUserPermissionLayer(g.user)
if session.get(LTI_SESSION_KEY, False):
g.lti = LTI(get_consumer_secrets())
g.lti = LTI(get_consumer_secrets(), lti13_platforms=get_lti13_platforms())
if 'lti_course_id' in session and g.user:
g.course = Course.by_id(session['lti_course_id'])
g.roles = g.user.get_course_roles(g.course.id)
Expand All @@ -192,7 +206,7 @@ def get_user() -> (User, int):


def try_lti_login_initial():
g.lti = LTI(get_consumer_secrets())
g.lti = LTI(get_consumer_secrets(), lti13_platforms=get_lti13_platforms())
g.lti.verify_request()
# TODO: Provide any other LTI information that we need
load_lti_user()
Expand All @@ -208,17 +222,18 @@ def load_lti_user():
Those fields will not be updated if they are found in the session.
:return:
"""
lti_service = session.get('lti_service', "canvas")
# 1) check whether the user needs to be updated
old_user = g.user if 'user' in g else None
g.user = User.from_lti("canvas",
g.user = User.from_lti(lti_service,
session["pylti_user_id"],
session.get("lis_person_contact_email_primary", ""),
session.get("lis_person_name_given", "Canvas"),
session.get("lis_person_name_family", "User"))
g.safely = ValidUserPermissionLayer(g.user)
# 2) Check the course
new_outcome_url = request.form.get('lis_outcome_service_url', "")
g.course = Course.from_lti("canvas",
new_outcome_url = request.form.get('lis_outcome_service_url', session.get('lis_outcome_service_url', ""))
g.course = Course.from_lti(lti_service,
session["context_id"],
session.get("context_title", ""),
g.user.id,
Expand All @@ -232,7 +247,9 @@ def load_lti_user():
# 4) Generally update the LTI status
session['is_lti_active'] = True
# Keep track of the chosen oauth_consumer_key
g.oauth_consumer_key = request.form.get('oauth_consumer_key', "")
g.oauth_consumer_key = request.form.get('oauth_consumer_key',
session.get('oauth_consumer_key',
session.get('lti_client_id', "")))
# 5) If the user changed, then log them in again
handle_login_change(old_user)

Expand Down Expand Up @@ -284,7 +301,27 @@ def is_lti_launch_request(request) -> bool:
Determines if the request is an LTI launch request. Does NOT check that the request
is a *valid* LTI launch request, just that it has the potential to be one.
"""
return request.method == 'POST' and request.form.get('lti_message_type') == 'basic-lti-launch-request'
return request.method == 'POST' and (
request.form.get('lti_message_type') == 'basic-lti-launch-request'
or is_lti13_launch(request.form)
)


def is_lti13_login_request(request) -> bool:
return request.method in ('GET', 'POST') and is_lti13_login_initiation(request.values)


def try_lti13_login_initiation():
platform = find_platform(get_lti13_platforms(), request.values.get('iss'),
request.values.get('client_id'),
request.values.get('lti_deployment_id'))
if platform is None:
raise LTIException("Unknown LTI 1.3 login initiation platform")
session['lti13_state'] = uuid.uuid4().hex
session['lti13_nonce'] = uuid.uuid4().hex
return redirect(build_login_redirect_url(platform, request.values,
session['lti13_state'],
session['lti13_nonce']))


def is_stored_lti_launch_request(request) -> bool:
Expand Down Expand Up @@ -318,7 +355,8 @@ def try_lti_login_stored():
try:
verify_jwt_in_request()
user_id = get_jwt_identity()
g.lti = LTI(get_consumer_secrets(), use_request=get_jwt())
g.lti = LTI(get_consumer_secrets(), use_request=get_jwt(),
lti13_platforms=get_lti13_platforms())
load_jwt_user(user_id, get_jwt())
g.access_token = create_user_token()
return True
Expand Down
3 changes: 3 additions & 0 deletions controllers/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ def parse_assignment_load(assignment_id_or_url=None):
course_id = int(g.course.id) if 'course' in g and g.course else None
# LTI submission URL
new_submission_url = request.form.get('lis_result_sourcedid', None)
if (new_submission_url is None and request.method == 'POST'
and request.form.get('id_token') and session.get('lti_version') == 'LTI-1p3'):
new_submission_url = session.get('lis_result_sourcedid', None)
new_due_date = from_canvas_isotime(request.form.get('custom_canvas_assignment_dueat', None))
new_lock_date = from_canvas_isotime(request.form.get('custom_canvas_assignment_lockat', None))
# Embedded?
Expand Down
10 changes: 4 additions & 6 deletions controllers/pylti/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def _post_patched_request(consumers, lti_key, body,

if lti_cert:
client.add_certificate(key=key_cert, cert=lti_cert, domain='')
log.debug("cert %s", lti_cert)
log.debug("client certificate configured")
import httplib2

http = httplib2.Http
Expand Down Expand Up @@ -212,10 +212,9 @@ def my_normalize(self, headers):
http._normalize_headers = monkey_patch_function

log.debug("key %s", lti_key)
log.debug("secret %s", secret)
log.debug("url %s", url)
log.debug("response %s", response)
log.debug("content %s", format(content))
log.debug("content length %s", len(content) if content is not None else 0)

return response, content

Expand Down Expand Up @@ -285,11 +284,10 @@ def verify_request_common(consumers, url, method, headers, params):
:return: is request valid
"""

log.debug("consumers %s", consumers)
log.debug("url %s", url)
log.debug("method %s", method)
log.debug("headers %s", headers)
log.debug("params %s", params)
log.debug("header keys %s", list(headers.keys()) if hasattr(headers, 'keys') else [])
log.debug("param keys %s", list(params.keys()) if hasattr(params, 'keys') else [])

oauth_server = LTIOAuthServer(consumers)

Expand Down
Loading