diff --git a/.env.sample b/.env.sample index 34a2175..eabf8ae 100644 --- a/.env.sample +++ b/.env.sample @@ -9,7 +9,8 @@ MYSQL_PORT=3306 # Django settings # python manage.py shell -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" SECRET_KEY='you-django-key' -DEBUG=True +DJANGO_DEBUG=True +DJANGO_LOG_LEVEL=DEBUG TZ=America/Detroit CSRF_TRUSTED_ORIGINS=https://*.instructure.com,https://*.umich.edu -ALLOWED_HOSTS=.loophole.site,.ngrok-free.app \ No newline at end of file +ALLOWED_HOSTS=.loophole.site,.ngrok-free.app, 127.0.0.1, localhost \ No newline at end of file diff --git a/README.md b/README.md index 8d6fd53..03976b3 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,64 @@ # canvas-lti-redirect-tool This is Canvas LTI redirect tool which use the CAI developed [LTI library](https://pypi.org/project/django-lti/) -## Generating Django security key +### Prerequisites + +To follow the instructions below, you will at minimum need the following: +1. **[Docker Desktop](https://www.docker.com/products/docker-desktop/)**. +1. **[Git](https://git-scm.com/downloads)** +### Installation and Setup +1. You need to web Proxy like Loophole or ngrok to run the application. Loophole offers custom domain + ```sh + loophole http 6000 --hostname + ``` +1. Copy the `.env.sample` file as `.env`. + ```sh + cp .env.sample .env +1. Examine the `.env` file. It will have the suggested default environment variable settings, +mostly just MySQL information as well as locations of other configuration files. + +1. Start the Docker build process (this will take some time). + ```sh + docker compose build + ``` + +1. Start up the web server and database containers. + ```sh + docker compose up + ``` + +1. generate Django secret using below command ```sh python manage.py shell -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" ``` ## LTI install -1. Need to run this command once in order for LTI to work +1. Need to run this command once docker container is up in order for LTI to work. This is important step otherwise the LTI tool launch won't happen ```sh docker exec -it clrt_web /bin/bash -c \ "python manage.py rotate_keys" -``` -2. Create superuser via using `python manage.py createsuperuser', need to run a proxy like loophole or ngrok for LTI installation and login with that user. -https:///admin/ -4. Use `LTIRegistration` to configure an LTI tool +``` + +2. Create superuser via using `python manage.py createsuperuser', need to run a proxy like loophole or ngrok for LTI installation and login with that user. Go to https://{app-hostname}/admin/. +3. Go to Canvas instance, choose Developer Keys in admin site +4. Add LTI Key +5. Choose Paste JSON method +6. Goto `LTIRegistration` to configure an LTI tool from admin console. This will create the `uuid` automatically. Hold on to that value and update the `OpenID Connect Initiation Url` in the LTI tool registration from Canvas with this id. + ` for Eg: https://clrt-local.loophole.site/init/0b54a91b-cac6-4c96-ba1e/` +7. use the `setup/lti-config.json` for registing the LTI tool. Replace all the `{app-hostname}` with your web proxy url and with UUID value from LTI tool registration. +8. Configure the LTI configuration from CLRT tool going to admin again. Give the following value. Note: `: ['canvas.test', 'canvas.beta']` + 1. Name: any name + 2. Issuer: https://.instructure.com + 2. Client ID: (get this from Platform) + 3. Auth URL: https://.instructure.com/api/lti/authorize_redirect + 4. Access token URL: https://.instructure.com/login/oauth2/token + 5. Keyset URL: https://.instructure.com/api/lti/security/jwks + 6. DEPLOYMENT ID: get this as it is described the step 7 and paste +9. Save +10. Go to the Canvas(platform) add the LTI tool at account/course level and copy the deployment id by clicking the setting button next to it. + +## Make a user superuser +1. go to the `auth_user` table and set `is_superuser` and `is_staff` to `1` or `true` this will give the logged user access to admin interface + + diff --git a/backend/settings.py b/backend/settings.py index e148b76..678073b 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -10,14 +10,16 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ """ -import os +import os, logging from pathlib import Path from decouple import config # from csp.constants import SELF, UNSAFE_INLINE +logger = logging.getLogger(__name__) +logging.basicConfig(level='INFO') + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent -print(BASE_DIR) # Quick-start development settings - unsuitable for production @@ -27,11 +29,12 @@ # Read the CSRF_TRUSTED_ORIGINS variable from the .env file csrf_trusted_origins = config('CSRF_TRUSTED_ORIGINS', '') CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in csrf_trusted_origins.split(',')] -print(CSRF_TRUSTED_ORIGINS) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = config('DJANGO_DEBUG', default=False, cast=bool) +print(DEBUG) +RANDOM_PASSWORD_DEFAULT_LENGTH = 32 allowed_hosts = config('ALLOWED_HOSTS', '') ALLOWED_HOSTS = [host.strip() for host in allowed_hosts.split(',')] @@ -78,6 +81,44 @@ "BACKEND": 'whitenoise.storage.CompressedManifestStaticFilesStorage', }, } +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + "generic": { + "format": "%(asctime)s [%(levelname)s] [%(filename)s:%(lineno)d] %(message)s", + "datefmt": "[%Y-%m-%d %H:%M:%S %z]", + "class": "logging.Formatter", + } + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'generic', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'propagate': False, + 'level': config('DJANGO_LOG_LEVEL', 'DEBUG'), + }, + 'rules': { + 'handlers': ['console'], + 'propagate': False, + 'level': 'INFO', + }, + '': { + 'level': 'WARNING', + 'handlers': ['console'], + }, + + }, + 'root': { + 'level': 'INFO', + 'handlers': ['console'] + }, +} TEMPLATES = [ { @@ -85,6 +126,7 @@ 'DIRS': [os.path.join(BASE_DIR, "templates")], 'APP_DIRS': True, 'OPTIONS': { + 'debug': DEBUG, 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', diff --git a/lti_redirect/static/home.css b/lti_redirect/static/home.css index bff979b..17fbb40 100644 --- a/lti_redirect/static/home.css +++ b/lti_redirect/static/home.css @@ -12,29 +12,26 @@ } .navbar a { - text-decoration: none; color: inherit; } .navbar-brand { font-size: 1.2em; font-weight: 600; + text-decoration: none; } +.navbar-user{ + font-size: 1em; + font-weight: 600; + padding-left: 30px; +} .navbar-item { - font-variant: small-caps; - margin-left: 30px; + float: right; + text-decoration: underline; } .body-content { padding: 5px; font-family:'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } -input[name=message] { - width: 80%; -} - -.message_list th,td { - text-align: left; - padding-right: 15px; -} \ No newline at end of file diff --git a/lti_redirect/templates/error.html b/lti_redirect/templates/error.html new file mode 100644 index 0000000..fdee9a4 --- /dev/null +++ b/lti_redirect/templates/error.html @@ -0,0 +1,13 @@ + + + + + {% block title %}{% endblock %} + {% load static %} + + + + +

Error occured during launch the Canvas LTI Redirect Tool

+ + \ No newline at end of file diff --git a/lti_redirect/templates/home.html b/lti_redirect/templates/home.html index 62c307b..5a63fde 100644 --- a/lti_redirect/templates/home.html +++ b/lti_redirect/templates/home.html @@ -10,6 +10,12 @@
diff --git a/lti_redirect/urls.py b/lti_redirect/urls.py index cdf061f..b139521 100644 --- a/lti_redirect/urls.py +++ b/lti_redirect/urls.py @@ -4,6 +4,7 @@ from lti_redirect.views import ApplicationLaunchView urlpatterns = [ path('', views.get_home_template, name = 'home'), + path('error', views.error, name="error" ), # LTI launch urls path(".well-known/jwks.json", jwks, name="jwks"), diff --git a/lti_redirect/views.py b/lti_redirect/views.py index 489358b..a11e80a 100644 --- a/lti_redirect/views.py +++ b/lti_redirect/views.py @@ -1,15 +1,23 @@ +import random, logging +import string import requests import jwt +import django.contrib.auth +from django.conf import settings from django.shortcuts import redirect, render from lti_tool.views import LtiLaunchBaseView +from django.contrib.auth.models import User + +logger = logging.getLogger(__name__) # Create your views here. def get_home_template(request): return render(request, 'home.html') +def error(request): + return render(request, "error.html") + def get_restrucutured_data(launch_data): - print('you are in get_restructured_data') - # print(launch_data) custom = launch_data['https://purl.imsglobal.org/spec/lti/claim/custom'] course_title = launch_data['https://purl.imsglobal.org/spec/lti/claim/context']['title'] lis = launch_data['https://purl.imsglobal.org/spec/lti/claim/lis'] @@ -43,21 +51,41 @@ def get_restrucutured_data(launch_data): "sis_id": custom["course_sis_account_id"] } } - print(restructured_data) return restructured_data - +def login_user_from_lti(request, launch_data): + try: + first_name = launch_data['given_name'] + last_name = launch_data['family_name'] + email = launch_data['email'] + username = launch_data['https://purl.imsglobal.org/spec/lti/claim/custom']['login_id'] + logger.info(f'the user {first_name} {last_name} {email} {username} launch the tool') + user_obj = User.objects.get(username=username) + except User.DoesNotExist: + logger.warn(f'user {username} never logged into the app, hence creating the user') + password = ''.join(random.sample(string.ascii_letters, settings.RANDOM_PASSWORD_DEFAULT_LENGTH)) + user_obj = User.objects.create_user(username=username, email=email, password=password, first_name=first_name, + last_name=last_name) + except Exception as e: + logger.error(f'error occured while getting the user info from auth_user table due to {e}') + return False + + + try: + django.contrib.auth.login(request, user_obj) + except (ValueError, TypeError, Exception) as e: + logger.error(f'Logging user after LTI launch failed due to {e}') + return False + return True class ApplicationLaunchView(LtiLaunchBaseView): # @xframe_options_exempt def handle_resource_launch(self, request, lti_launch): ... # Required. Typically redirects the users to the appropriate page. - print('you are in LTI launch') launch_data = lti_launch.get_launch_data() - custom = launch_data['https://purl.imsglobal.org/spec/lti/claim/custom'] - redirect_url = custom['redirect_url'] - print(custom) + if not login_user_from_lti(request, launch_data): + return redirect("error") return redirect("home") def handle_deep_linking_launch(self, request, lti_launch): diff --git a/setup/lti-config.json b/setup/lti-config.json new file mode 100644 index 0000000..2dcc2e1 --- /dev/null +++ b/setup/lti-config.json @@ -0,0 +1,44 @@ +{ + "title": "Canvas LTI Redirect Tool", + "scopes": [], + "extensions": [ + { + "domain": "{app-hostname}", + "platform": "canvas.instructure.com", + "settings": { + "platform": "canvas.instructure.com", + "placements": [ + { + "default": "disabled", + "placement": "course_navigation", + "message_type": "LtiResourceLinkRequest", + "target_link_uri": "https://{app-hostname}/ltilaunch" + } + ] + }, + "privacy_level": "public" + } + ], + "public_jwk": {}, + "description": "This is canvas LTI redirect tool", + "custom_fields": { + "roles": "$Canvas.membership.roles", + "term_id": "$Canvas.term.id", + "login_id": "$Canvas.user.loginId", + "term_end": "$Canvas.term.endAt", + "course_id": "$Canvas.course.id", + "term_name": "$Canvas.term.name", + "canvas_url": "$Canvas.api.baseUrl", + "term_start": "$Canvas.term.startAt", + "redirect_url": "https://dev.dev.umgpt.umich.edu/", + "course_status": "$Canvas.course.workflowState", + "user_canvas_id": "$Canvas.user.id", + "course_account_name": "$Canvas.account.name", + "course_enroll_status": "$Canvas.enrollment.enrollmentState", + "course_sis_account_id": "$Canvas.course.sisSourceId", + "course_canvas_account_id": "$Canvas.account.id" + }, + "public_jwk_url": "https://{app-hostname}/.well-known/jwks.json", + "target_link_uri": "https://{app-hostname}/ltilaunch", + "oidc_initiation_url": "https://{app-hostname}/init//" +} \ No newline at end of file