diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..2fc7061 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index ea29c21..f715c58 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -28,7 +28,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ ubuntu-latest, windows-latest, macos-12 ] + os: [ ubuntu-latest, windows-latest, macos-14 ] python-version: [ "3.11" ] steps: @@ -37,6 +37,9 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Install PostgreSQL (macOS) + if: runner.os == 'macOS' + run: brew install postgresql - name: Install dependencies run: | python -m pip install --upgrade pip && python -m pip install -r requirements.txt diff --git a/.gitignore b/.gitignore index 6b37ad4..3bdeb41 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ venv/ logs/ .env debug.log + +src/lti/config/ +teamable_keys_needed/ \ No newline at end of file diff --git a/db.env b/db.env index 9678324..fde8178 100644 --- a/db.env +++ b/db.env @@ -3,19 +3,19 @@ ################################################################################ # Specify Postgres host. # Default: localhost -POSTGRES_HOST=db +POSTGRES_HOST=db_teamable # Specify Postgres port. # Default: 5432 -POSTGRES_PORT=5432 +POSTGRES_PORT=15432 # Name of the database to use. Used only in production. # Default: postgres -POSTGRES_DB=postgres +POSTGRES_DB=postgres_teamable # User who can access this database. Used only in production. # Default: postgres -POSTGRES_USER=postgres +POSTGRES_USER=postgres_teamable # Password of the user. Cannot be blank. Used only in production. # Default: NO DEFAULT, YOU MUST SET YOUR PASSWORD diff --git a/docs/local_canvas.md b/docs/local_canvas.md new file mode 100644 index 0000000..c5f781d --- /dev/null +++ b/docs/local_canvas.md @@ -0,0 +1,53 @@ +## Backend Development (with Local Canvas Instance) +> IMPORTANT: These instructions were taken from another project, so you won't be able to follow them exactly. + +> Local canvas installation is only required if you need the LTI login/launch process as a part of the development/testing. For example, you need to test whether a code should only work for certain user group(s)/course(s). + +#### 1. Pre-requisite: Clone course-insights repo and create public.pem, private.pem, and public-jwk.json files for local course-insights application +1. Make sure you activate the Python virtual environment you created for this project. +1. Use the `/backend/generate_jwk/generate_jwk.py` script to create public.pem, private.pem, and public-jwk.json files. + - 2.1. Go to `/course-insights/backend/generate_jwk` folder. + - 2.2. Type `python generate_jwk.py`. + - This will generate `private.pem`, `public.pem`, and `pubcli_jwk.json` files into the `conf/development` folder. + +#### 2. How to launch the local instance of Canvas-lms +1. Clone the canvas-lms repo. `git clone https://github.com/instructure/canvas-lms.git` +1. Locate `config/security.yml` file. Update the value for `lti_iss` from `https://canvas.instructure.com` to `http://canvas.docker`. +1. Go to the repo directory. `cd canvas-lms` +1. Run the automated setup script. `./script/docker_dev_setup.sh`. + - This process will ask you to create an admin user. Remember the credentials you provide. +1. When the setup is complete, start the app by typing `docker compose up -d`. +1. On the web browser, type `http://canvas.docker`. This will bring up the login page. +1. Use the admin credentials from step 4 to log in. + +#### 3. How to create a new course on local Canvas LMS + +1. Type `http://canvas.docker` on your browser. +1. Log in using the administrator credentials you created during the Canvas installation. +1. From the Canvas Dashboard page, click `Start a New Course`. + - Enter course name and click `Create course`. + +#### 4. How to add Course Insights to a Canvas course + +1. On the main Canvas admin page. + - 1.1. Click `Admin` on the left side menu. + - 1.2. Select your admin account. ex) Test Account + - 1.3. Click `developer key` on the left side menu. +1. Create a developer key for course-insights LTI key. + - 2.1. Click `+ Developer Key`. + - 2.2. Click `+ LTI Key`. + - 2.3. On the Configure page, select `Paste JSON`. + - 2.4. On the `LTI 1.3 Configuration`, copy and paste the JSON data from `/conf/development/canvas/developer_key.json`. + - 2.5. Update `kid` and `n` value under `public_jwk` in the copy-and-pasted JSON data. + - The values for `kid` and `n` can be found from `/conf/development/public_jwk.json`. + - 2.6. Provide `Key Name`. ex) Course Insights LTI + - 2.7. Click `Save` button. +1. Go to the new Canvas course page that you created. +1. Add course-insights to your course. + 4.1. Click `Settings` from the left-side menu. + 4.2. Click `Apps` -> `+App`. + 4.3. For configuration type, select `By Client ID`. + 4.4. For Client ID, provide the developer key ID that you created from step 9. It's the long integer value that appears in the Details column of the Developer Keys page. + 4.5. Click `Submit`. +1. Refresh the page and you will see the `course-insights` link on the left-side menu. +1. Click the `course-insights` link and it will start the LTI login and launch process, and bring up the course-insights application page. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cb5438d..87e0db9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,41 @@ +arrow==1.3.0 +asgiref==3.8.1 black==23.12.1 canvasapi==3.2.0 +certifi==2025.1.31 +cffi==1.17.1 +charset-normalizer==3.4.1 +click==8.1.8 +cryptography==44.0.1 +Django==5.0 django-cors-headers==4.3.1 django-extensions==3.2.3 django-filter==23.5 django-stubs==5.1.0 -Django==5.0 -djangorestframework-stubs==3.15.1 +django-stubs-ext==5.1.3 djangorestframework==3.14.0 +djangorestframework-stubs==3.15.1 Faker==24.2.0 gunicorn==23.0.0 +idna==3.10 +jwcrypto==1.5.6 Markdown==3.5.2 +mypy-extensions==1.0.0 +packaging==24.2 +pathspec==0.12.1 +platformdirs==4.3.6 psycopg2==2.9.9 -pyjwt==2.8.0 +pycparser==2.22 +PyJWT==2.8.0 +PyLTI1p3==2.0.0 +python-dateutil==2.9.0.post0 python-dotenv==1.0.1 +pytz==2025.1 +requests==2.32.3 +six==1.17.0 +sqlparse==0.5.3 +types-python-dateutil==2.9.0.20241206 +types-PyYAML==6.0.12.20241230 +types-requests==2.32.0.20241016 +typing_extensions==4.12.2 +urllib3==2.3.0 diff --git a/sample.env b/sample.env index f3d3def..4bafcb7 100644 --- a/sample.env +++ b/sample.env @@ -1,2 +1,19 @@ DEBUG=TRUE -SERVER_NAME=* \ No newline at end of file +SERVER_NAME=* +LOCAL_DB=TRUE + +# Local Values +TEAM_GENERATION_API_URL= +TEAM_GENERATION_API_KEY= + +# LTI Config +LTI_CONF_FOLDER=lti/config +LTI_CONF_FILE_NAME=lti_config_settings.json + +# Canvas OAuth2 Config +CANVAS_BASE_URL=http://canvas.docker +CANVAS_OAUTH_CLIENT_ID=client_id +CANVAS_OAUTH_CLIENT_SECRET=client_secret + +# Configuring frontend +FRONTEND_BASE_URL=http://localhost:3000 \ No newline at end of file diff --git a/src/app/canvas/canvas_api.py b/src/app/canvas/canvas_api.py new file mode 100644 index 0000000..67c5a78 --- /dev/null +++ b/src/app/canvas/canvas_api.py @@ -0,0 +1,9 @@ +from canvasapi import Canvas + +from app.models import Organization +from canvas_oauth.oauth import get_oauth_token + + +def init_canvas(request, organization: Organization) -> Canvas: + access_token = get_oauth_token(request) + return Canvas(organization.lms_api_url, access_token) diff --git a/src/app/canvas/export_team.py b/src/app/canvas/export_team.py index 802d5fe..e2c58de 100644 --- a/src/app/canvas/export_team.py +++ b/src/app/canvas/export_team.py @@ -1,13 +1,14 @@ -from canvasapi import Canvas, exceptions +from canvasapi import exceptions from canvasapi.course import Course from canvasapi.group import GroupCategory +from app.canvas.canvas_api import init_canvas from app.models.course_member import CourseMember from app.models.organization import LMSTypeOptions from app.models.team import TeamSet -def export_team_to_canvas(team_set: TeamSet): +def export_team_to_canvas(request, team_set: TeamSet): course = team_set.course if ( course.organization is None @@ -15,7 +16,7 @@ def export_team_to_canvas(team_set: TeamSet): ): return - canvas = Canvas(course.organization.lms_api_url, course.lms_access_token) + canvas = init_canvas(request, course.organization) canvas_course = canvas.get_course(course.lms_course_id) group_category = create_group_category_with_unique_name( diff --git a/src/app/canvas/import_attribute.py b/src/app/canvas/import_attribute.py index 7a9fa52..d5ba44c 100644 --- a/src/app/canvas/import_attribute.py +++ b/src/app/canvas/import_attribute.py @@ -1,8 +1,8 @@ -from typing import Dict, List +from typing import List -from canvasapi import Canvas -from canvasapi.assignment import Assignment, AssignmentGroup +from canvasapi.assignment import Assignment +from app.canvas.canvas_api import init_canvas from app.models.attribute import ( Attribute, AttributeManageType, @@ -13,7 +13,6 @@ from app.models.course import Course from app.models.course_member import UserRole from app.models.organization import LMSTypeOptions -from app.views import attribute ABOVE_AVERAGE_LABEL = "Above Average" BELOW_AVERAGE_LABEL = "Below Average" @@ -53,14 +52,14 @@ def get_or_create_gradebook_attribute(course: Course, assignment: Assignment): # Study buddy specific function -def import_gradebook_attribute_from_canvas(course: Course): +def import_gradebook_attribute_from_canvas(request, course: Course): if ( course.organization is None or course.organization.lms_type != LMSTypeOptions.CANVAS ): return - canvas = Canvas(course.organization.lms_api_url, course.lms_access_token) + canvas = init_canvas(request, course.organization) canvas_course = canvas.get_course(course.lms_course_id) course_members = course.course_members.filter(role=UserRole.STUDENT) diff --git a/src/app/canvas/import_students.py b/src/app/canvas/import_students.py index 87908aa..30988bd 100644 --- a/src/app/canvas/import_students.py +++ b/src/app/canvas/import_students.py @@ -1,22 +1,23 @@ from typing import List from canvasapi.quiz import QuizQuestion + +from app.canvas.canvas_api import init_canvas from app.models.course import Course -from canvasapi import Canvas from canvasapi.enrollment import Enrollment from app.models.course_member import CourseMember, UserRole from app.models.organization import LMSTypeOptions -def import_students_from_canvas(course: Course): +def import_students_from_canvas(request, course: Course): if ( course.organization is None or course.organization.lms_type != LMSTypeOptions.CANVAS ): return - canvas = Canvas(course.organization.lms_api_url, course.lms_access_token) + canvas = init_canvas(request, course.organization) canvas_course = canvas.get_course(course.lms_course_id) students: List[Enrollment] = list( diff --git a/src/app/canvas/opt_in_quiz.py b/src/app/canvas/opt_in_quiz.py index 0ec7de7..f7fecb8 100644 --- a/src/app/canvas/opt_in_quiz.py +++ b/src/app/canvas/opt_in_quiz.py @@ -1,17 +1,16 @@ -from canvasapi import Canvas - +from app.canvas.canvas_api import init_canvas from app.models.course import Course from app.models.organization import LMSTypeOptions -def create_opt_in_quiz_canvas(course: Course): +def create_opt_in_quiz_canvas(request, course: Course): if ( course.organization is None or course.organization.lms_type != LMSTypeOptions.CANVAS ): return - canvas = Canvas(course.organization.lms_api_url, course.lms_access_token) + canvas = init_canvas(request, course.organization) canvas_course = canvas.get_course(course.lms_course_id) quiz = canvas_course.create_quiz( diff --git a/src/app/views/course.py b/src/app/views/course.py index d8f830d..ec9ffaf 100644 --- a/src/app/views/course.py +++ b/src/app/views/course.py @@ -100,7 +100,7 @@ def get_serializer_class( ) def import_students_from_lms(self, request, pk=None): course = self.get_object() - import_students_from_canvas(course) + import_students_from_canvas(request, course=course) return Response(status=status.HTTP_200_OK) @action( @@ -118,7 +118,7 @@ def export_team_to_lms(self, request, pk=None): # Pylance doesn't know that validated_data is valid after is_valid() check team_set = serializer.validated_data["team_set"] # type: ignore - export_team_to_canvas(team_set) + export_team_to_canvas(request, team_set=team_set) return Response(status=status.HTTP_200_OK) @action( @@ -129,7 +129,7 @@ def export_team_to_lms(self, request, pk=None): ) def create_opt_in_quiz_lms(self, request, pk=None): course = self.get_object() - create_opt_in_quiz_canvas(course) + create_opt_in_quiz_canvas(request, course=course) return Response(status=status.HTTP_200_OK) @action( @@ -155,7 +155,7 @@ def get_onboarding_progress(self, request, pk=None): ) def import_gradebook_attribute_from_lms(self, request, pk=None): course = self.get_object() - import_gradebook_attribute_from_canvas(course) + import_gradebook_attribute_from_canvas(request, course=course) return Response(status=status.HTTP_200_OK) @action( diff --git a/src/canvas_oauth/LICENSE b/src/canvas_oauth/LICENSE new file mode 100755 index 0000000..ccd5ca7 --- /dev/null +++ b/src/canvas_oauth/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 penzance + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/canvas_oauth/__init__.py b/src/canvas_oauth/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/src/canvas_oauth/canvas.py b/src/canvas_oauth/canvas.py new file mode 100755 index 0000000..466751d --- /dev/null +++ b/src/canvas_oauth/canvas.py @@ -0,0 +1,106 @@ +import logging +from datetime import timedelta + +import requests +from django.utils import timezone + +from canvas_oauth.exceptions import InvalidOAuthReturnError +from canvas_oauth import settings + +logger = logging.getLogger(__name__) + +AUTHORIZE_URL_PATTERN = "%s/login/oauth2/auth" +ACCESS_TOKEN_URL_PATTERN = "%s/login/oauth2/token" + +SCOPES_USED = [ + "url:GET|/api/v1/courses/:id", + "url:GET|/api/v1/courses/:course_id/enrollments", + "url:POST|/api/v1/courses/:course_id/group_categories", + "url:POST|/api/v1/group_categories/:group_category_id/groups", + "url:POST|/api/v1/groups/:group_id/memberships", + "url:GET|/api/v1/courses/:course_id/assignments", + "url:GET|/api/v1/courses/:course_id/assignments/:assignment_id/submissions", + "url:GET|/api/v1/courses/:course_id/quizzes/:id", + "url:GET|/api/v1/courses/:course_id/quizzes/:quiz_id/questions", + "url:GET|/api/v1/courses/:course_id/quizzes/:quiz_id/submissions", + "url:GET|/api/v1/quiz_submissions/:quiz_submission_id/questions", + "url:POST|/api/v1/courses/:course_id/quizzes", + "url:POST|/api/v1/courses/:course_id/quizzes/:quiz_id/questions", +] + + +def get_oauth_login_url( + client_id, + redirect_uri, + response_type="code", + state=None, + purpose=None, + force_login=None, +): + """Builds an OAuth request url for Canvas.""" + authorize_url = AUTHORIZE_URL_PATTERN % settings.CANVAS_OAUTH_CANVAS_DOMAIN + + auth_request_params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": response_type, + "state": state, + "scopes": " ".join(SCOPES_USED), + "purpose": purpose, + "force_login": force_login, + } + # Use requests library to help build our url + auth_request = requests.Request("GET", authorize_url, params=auth_request_params) + # Prepared request url uses urlencode for encoding and scrubs any None + # key-value pairs + return auth_request.prepare().url + + +def get_access_token( + grant_type, client_id, client_secret, redirect_uri, code=None, refresh_token=None +): + """Performs one of the two grant types supported by Canvas' OAuth endpoint to + to retrieve an access token. Expect a `code` kwarg when performing an + `authorization_code` grant; otherwise, assume we're doing a `refresh_token` + grant. + + Return a tuple of the access token, expiration date as a timezone aware DateTime, + and refresh token (returned by `authorization_code` requests only). + """ + # Call Canvas endpoint to + oauth_token_url = ACCESS_TOKEN_URL_PATTERN % settings.CANVAS_OAUTH_CANVAS_DOMAIN + post_params = { + "grant_type": grant_type, # Use 'authorization_code' for new tokens + "client_id": client_id, + "client_secret": client_secret, + "redirect_uri": redirect_uri, + } + + # Need to add in code or refresh_token, depending on the grant_type + if grant_type == "authorization_code": + post_params["code"] = code + else: + post_params["refresh_token"] = refresh_token + + r = requests.post(oauth_token_url, post_params) + logger.info("%s POST response from Canvas is %s", grant_type, r.text) + if r.status_code != 200: + raise InvalidOAuthReturnError( + "%s request failed to get a token: %s" % (grant_type, r.text) + ) + + # Parse the response for the access_token, expiration time, and (possibly) + # the refresh token + response_data = r.json() + access_token = response_data["access_token"] + seconds_to_expire = response_data["expires_in"] + # Convert the expiration time in seconds to a DateTime + expires = timezone.now() + timedelta(seconds=seconds_to_expire) + # Whether a refresh token is included in the response depends on the + # grant_type - it only appears to be returned for 'authorization_code', + # but to be safe check the response_data for it + refresh_token = None + if "refresh_token" in response_data: + refresh_token = response_data["refresh_token"] + + return (access_token, expires, refresh_token) diff --git a/src/canvas_oauth/decorators.py b/src/canvas_oauth/decorators.py new file mode 100644 index 0000000..7788364 --- /dev/null +++ b/src/canvas_oauth/decorators.py @@ -0,0 +1,11 @@ +from functools import wraps +from canvas_oauth.oauth import get_oauth_token + + +def token_required(func): + @wraps(func) + def inner(request, *args, **kwargs): + access_token = get_oauth_token(request) + return func(request, *args, **kwargs) + + return inner diff --git a/src/canvas_oauth/exceptions.py b/src/canvas_oauth/exceptions.py new file mode 100755 index 0000000..a5fecd9 --- /dev/null +++ b/src/canvas_oauth/exceptions.py @@ -0,0 +1,14 @@ +class CanvasOAuthError(Exception): + pass + + +class MissingTokenError(CanvasOAuthError): + pass + + +class InvalidOAuthStateError(CanvasOAuthError): + pass + + +class InvalidOAuthReturnError(CanvasOAuthError): + pass diff --git a/src/canvas_oauth/middleware.py b/src/canvas_oauth/middleware.py new file mode 100755 index 0000000..34a08d2 --- /dev/null +++ b/src/canvas_oauth/middleware.py @@ -0,0 +1,23 @@ +from canvas_oauth.exceptions import MissingTokenError, CanvasOAuthError +from canvas_oauth.oauth import handle_missing_token, render_oauth_error + + +class OAuthMiddleware(object): + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + return response + + """On catching a MissingTokenError - as is raised by the get_token function + if there is no saved token for the user - this begins the oauth dance with + canvas to get a new token. For other CanvasOAuthErrors, an error page with + the exception text is rendered.""" + + def process_exception(self, request, exception): + if isinstance(exception, MissingTokenError): + return handle_missing_token(request) + elif isinstance(exception, CanvasOAuthError): + return render_oauth_error(str(exception)) + return diff --git a/src/canvas_oauth/migrations/0001_initial.py b/src/canvas_oauth/migrations/0001_initial.py new file mode 100644 index 0000000..3f0fc47 --- /dev/null +++ b/src/canvas_oauth/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2016-04-22 20:57 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="CanvasOAuth2Token", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("access_token", models.TextField()), + ("refresh_token", models.TextField()), + ("expires", models.DateTimeField()), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="canvas_oauth2_token", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/src/canvas_oauth/migrations/0002_alter_canvasoauth2token_id.py b/src/canvas_oauth/migrations/0002_alter_canvasoauth2token_id.py new file mode 100644 index 0000000..e35c493 --- /dev/null +++ b/src/canvas_oauth/migrations/0002_alter_canvasoauth2token_id.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0 on 2025-03-03 05:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("canvas_oauth", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="canvasoauth2token", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ] diff --git a/src/canvas_oauth/migrations/__init__.py b/src/canvas_oauth/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/src/canvas_oauth/models.py b/src/canvas_oauth/models.py new file mode 100755 index 0000000..8502dc7 --- /dev/null +++ b/src/canvas_oauth/models.py @@ -0,0 +1,48 @@ +from __future__ import unicode_literals + +from django.db import models +from django.conf import settings +from django.utils import timezone + + +class CanvasOAuth2Token(models.Model): + """ + A CanvasOAuth2Token instance represents the access token + response from Canvas when the user requests an authorization + grant as in :rfc:`6749`. Canvas tokens are short-lived, and + so they issue refresh tokens as part of the grant response. + The refresh tokens are used to retrieve new access tokens once + they expire. + Fields: + * :attr:`user` The Django user representing resources' owner + * :attr:`access_token` Access token + * :attr:`refresh_token` Refresh token + * :attr:`expires` Date and time of token expiration, in DateTime format + * :attr:`created_on` When the initial access token was granted, + in DateTime format + * :attr:`updated_on` When the token was refreshed (or first created), in + DateTime format + """ + + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="canvas_oauth2_token", + ) + access_token = models.TextField() + refresh_token = models.TextField() + expires = models.DateTimeField() + created_on = models.DateTimeField(auto_now_add=True) + updated_on = models.DateTimeField(auto_now=True) + + def expires_within(self, delta): + """ + Check token expiration with timezone awareness within + the given amount of time, expressed as a timedelta. + + :param delta: The timedelta to check expiration against + """ + if not self.expires: + return False + + return self.expires - timezone.now() <= delta diff --git a/src/canvas_oauth/oauth.py b/src/canvas_oauth/oauth.py new file mode 100755 index 0000000..a634af6 --- /dev/null +++ b/src/canvas_oauth/oauth.py @@ -0,0 +1,161 @@ +import logging +import os + +from django.http.response import HttpResponse, HttpResponseRedirect +from django.shortcuts import redirect +from django.urls import reverse +from django.template import loader +from django.template.exceptions import TemplateDoesNotExist +from django.utils.crypto import get_random_string +from rest_framework.authtoken.models import Token + +from canvas_oauth import canvas, settings +from canvas_oauth.models import CanvasOAuth2Token +from canvas_oauth.exceptions import MissingTokenError, InvalidOAuthStateError + +logger = logging.getLogger(__name__) + + +def get_oauth_token(request): + """Retrieve a stored Canvas OAuth2 access token from Canvas for the + currently logged in user. If the token has expired (or has exceeded an + expiration threshold as defined by the consuming project), a fresh token + is generated via the saved refresh token. + + If the user does not have a stored token, the method raises a + MissingTokenError exception. If this happens inside a view, this exception + will be handled by the middleware component of this library with a call to + handle_missing_token. If this happens outside of a view, then the user must + be directed by other means to the Canvas site in order to authorize a token. + """ + try: + oauth_token = request.user.canvas_oauth2_token + except CanvasOAuth2Token.DoesNotExist: + """If this exception is raised by a view function and not caught, + it is probably because the oauth_middleware is not installed, since it + is supposed to catch this error.""" + raise MissingTokenError("No token found for user %s" % request.user.pk) + + # Check to see if we're within the expiration threshold of the access token + if oauth_token.expires_within(settings.CANVAS_OAUTH_TOKEN_EXPIRATION_BUFFER): + oauth_token = refresh_oauth_token(request) + + return oauth_token.access_token + + +def handle_missing_token(request): + """ + Redirect user to canvas with a request for token. + """ + # Store where the user came from so they can be redirected back there + # at the end. https://canvas.instructure.com/doc/api/file.oauth.html + request.session["canvas_oauth_initial_uri"] = request.get_full_path() + + # The request state is a recommended security check on the callback, so + # store in session for later + oauth_request_state = get_random_string(32) + request.session["canvas_oauth_request_state"] = oauth_request_state + + # The return URI is required to be the same when POSTing to generate + # a token on callback, so also store it in session (although it could + # be regenerated again via the same method call). + oauth_redirect_uri = request.build_absolute_uri(reverse("canvas-oauth-callback")) + request.session["canvas_oauth_redirect_uri"] = oauth_redirect_uri + + authorize_url = canvas.get_oauth_login_url( + settings.CANVAS_OAUTH_CLIENT_ID, + redirect_uri=oauth_redirect_uri, + state=oauth_request_state, + ) + + if authorize_url is None: + raise Exception("Invalid authorize_url") + + return HttpResponseRedirect(authorize_url) + + +def oauth_callback(request): + """Receives the callback from canvas and saves the token to the database. + Redirects user to the page they came from at the start of the oauth + procedure.""" + error = request.GET.get("error") + if error: + return render_oauth_error(error) + code = request.GET.get("code") + state = request.GET.get("state") + + if state != request.session["canvas_oauth_request_state"]: + raise InvalidOAuthStateError("OAuth state mismatch!") + + # Make the `authorization_code` grant type request to retrieve a + access_token, expires, refresh_token = canvas.get_access_token( + grant_type="authorization_code", + client_id=settings.CANVAS_OAUTH_CLIENT_ID, + client_secret=settings.CANVAS_OAUTH_CLIENT_SECRET, + redirect_uri=request.session["canvas_oauth_redirect_uri"], + code=code, + ) + + CanvasOAuth2Token.objects.create( + user=request.user, + access_token=access_token, + expires=expires, + refresh_token=refresh_token, + ) + + return redirect(request.session["canvas_oauth_initial_uri"]) + + +def initiate_oauth_check(request): + """ + This url will be responsible for starting the process of getting the access token. + It accepts a GET request so that the redirects work + """ + get_oauth_token(request) + + auth_user = request.user + + # make the authentication token for the frontend if needed + auth_token, auth_token_created = Token.objects.get_or_create(user=auth_user) + redirect_path = request.GET.get("redirect_path") + + client_url = os.environ.get("FRONTEND_BASE_URL") + return redirect( + f"{client_url}/authenticate?token={auth_token}&path={redirect_path}" + ) + + +def refresh_oauth_token(request): + """Makes refresh_token grant request with Canvas to get a fresh + access token. Update the oauth token model with the new token + and new expiration date and return the saved model. + """ + oauth_token = request.user.canvas_oauth2_token + + # Get the new access token and expiration date via + # a refresh token grant + oauth_token.access_token, oauth_token.expires, _ = canvas.get_access_token( + grant_type="refresh_token", + client_id=settings.CANVAS_OAUTH_CLIENT_ID, + client_secret=settings.CANVAS_OAUTH_CLIENT_SECRET, + redirect_uri=request.build_absolute_uri(reverse("canvas-oauth-callback")), + refresh_token=oauth_token.refresh_token, + ) + + # Update the model with new token and expiration + oauth_token.save() + + return oauth_token + + +def render_oauth_error(error_message): + """If there is an error in the oauth callback, attempts to render it in a + template that can be styled; otherwise, if OAUTH_ERROR_TEMPLATE not + found, this will return a HttpResponse with status 403""" + try: + template = loader.render_to_string( + settings.CANVAS_OAUTH_ERROR_TEMPLATE, {"message": error_message} + ) + except TemplateDoesNotExist: + return HttpResponse("Error: %s" % error_message, status=403) + return HttpResponse(template, status=403) diff --git a/src/canvas_oauth/settings.py b/src/canvas_oauth/settings.py new file mode 100755 index 0000000..aecbdd0 --- /dev/null +++ b/src/canvas_oauth/settings.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" +canvas_oauth specific settings +""" +from __future__ import unicode_literals +from datetime import timedelta + +from django.core.exceptions import ImproperlyConfigured +from django.conf import settings + + +def get_required_setting(oauth_setting): + """ + Check for and return required OAuth setting here so we can + raise an error if not found. + """ + if not hasattr(settings, oauth_setting): + raise ImproperlyConfigured( + "Missing %s setting that is required to use the Django Canvas OAuth library" + % oauth_setting + ) + return getattr(settings, oauth_setting) + + +# Get required settings from project conf +CANVAS_OAUTH_CLIENT_ID = get_required_setting("CANVAS_OAUTH_CLIENT_ID") +CANVAS_OAUTH_CLIENT_SECRET = get_required_setting("CANVAS_OAUTH_CLIENT_SECRET") + +# fixme: This value is hardcoded through environment variables. If multiple +# organizations with multiple Canvas API urls are added in the future, +# this will need to be defined per-organization +CANVAS_OAUTH_CANVAS_DOMAIN = get_required_setting("CANVAS_OAUTH_CANVAS_DOMAIN") + +# Optional settings +# ----------------- + +# A buffer for refreshing a token when retrieving via `get_token`, expressed +# as a timedelta. Default to having no expiration buffer. +CANVAS_OAUTH_TOKEN_EXPIRATION_BUFFER = getattr( + settings, + "CANVAS_OAUTH_TOKEN_EXPIRATION_BUFFER", + timedelta(), +) + +CANVAS_OAUTH_ERROR_TEMPLATE = getattr( + settings, "CANVAS_OAUTH_ERROR_TEMPLATE", "oauth_error.html" +) diff --git a/src/canvas_oauth/templates/canvas_oauth/oauth_error.html b/src/canvas_oauth/templates/canvas_oauth/oauth_error.html new file mode 100755 index 0000000..5ed3745 --- /dev/null +++ b/src/canvas_oauth/templates/canvas_oauth/oauth_error.html @@ -0,0 +1,2 @@ +
OAuth Error: {{ message }}
diff --git a/src/canvas_oauth/urls.py b/src/canvas_oauth/urls.py new file mode 100755 index 0000000..3c650d9 --- /dev/null +++ b/src/canvas_oauth/urls.py @@ -0,0 +1,7 @@ +from canvas_oauth.oauth import oauth_callback, initiate_oauth_check +from django.urls import path + +urlpatterns = [ + path("oauth-callback", oauth_callback, name="canvas-oauth-callback"), + path("initiate", initiate_oauth_check, name="canvas-oauth-initiate"), +] diff --git a/src/lti/__init__.py b/src/lti/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lti/lti_util.py b/src/lti/lti_util.py new file mode 100644 index 0000000..e198cd6 --- /dev/null +++ b/src/lti/lti_util.py @@ -0,0 +1,40 @@ +import os +import json +from pylti1p3.tool_config.dict import ToolConfDict +from pylti1p3.tool_config.json_file import ToolConfJsonFile + + +def is_lti_enabled(): + return ( + os.environ.get("LTI_CONF_JSON") + and os.environ.get("LTI_CONF_JSON_PRIVATE_KEYS") + or os.environ.get("LTI_CONF_FOLDER") + and os.environ.get("LTI_CONF_FILE_NAME") + ) + + +def get_tool_conf(): + if not is_lti_enabled(): + return None + + tool_conf = None + + lti_conf_json = os.environ.get("LTI_CONF_JSON") + lti_conf_json_private_keys = os.environ.get("LTI_CONF_JSON_PRIVATE_KEYS") + + lti_conf_folder = os.environ.get("LTI_CONF_FOLDER") + lti_conf_file_name = os.environ.get("LTI_CONF_FILE_NAME") + + if lti_conf_json is not None and lti_conf_json_private_keys is not None: + conf_dict = json.loads(lti_conf_json) + keys_dict = json.loads(lti_conf_json_private_keys) + tool_conf = ToolConfDict(conf_dict) + + for issuer, private_key in keys_dict.items(): + tool_conf.set_private_key(issuer, private_key) + + elif lti_conf_folder is not None and lti_conf_file_name is not None: + conf_file_location = os.path.join(lti_conf_folder, lti_conf_file_name) + tool_conf = ToolConfJsonFile(conf_file_location) + + return tool_conf diff --git a/src/lti/urls.py b/src/lti/urls.py new file mode 100644 index 0000000..02b73e9 --- /dev/null +++ b/src/lti/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from lti import views + +urlpatterns = [ + path("login/", views.login), + path("launch/", views.launch), +] diff --git a/src/lti/views.py b/src/lti/views.py new file mode 100644 index 0000000..4bc2604 --- /dev/null +++ b/src/lti/views.py @@ -0,0 +1,143 @@ +from django.urls import reverse +from pylti1p3.contrib.django.oidc_login import DjangoOIDCLogin +from pylti1p3.contrib.django.message_launch import DjangoMessageLaunch +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth import logout as auth_logout, login as auth_login +from django.shortcuts import redirect +from django.conf import settings +from rest_framework.authtoken.models import Token + +from accounts.models import MyUser +from app.models import CourseMember, Course, Organization +from app.models.course_member import UserRole +from lti import lti_util +from pprint import pprint + + +# do not require deployments in config +class ExtendedDjangoMessageLaunch(DjangoMessageLaunch): + def validate_deployment(self): + return self + + +@csrf_exempt +def login(request): + if not settings.LTI_ENABLED: + raise Exception("LTI disabled") + + # clear old sessions (just to be safe) + auth_logout(request) + + tool_conf = lti_util.get_tool_conf() + oidc_login = DjangoOIDCLogin(request, tool_conf) + + target_link_uri = request.POST.get( + "target_link_uri", request.GET.get("target_link_uri") + ) + if not target_link_uri: + raise Exception('Missing "target_link_uri" param') + + return oidc_login.redirect(target_link_uri) + + +@csrf_exempt +@require_POST +def launch(request): + if not settings.LTI_ENABLED: + raise Exception("LTI disabled") + + tool_conf = lti_util.get_tool_conf() + message_launch = ExtendedDjangoMessageLaunch(request, tool_conf) + + message_launch_data = message_launch.get_launch_data() + + # check if instructor (only instructors can LTI launch at this time) + roles = message_launch_data.get( + "https://purl.imsglobal.org/spec/lti/claim/roles", [] + ) + + has_admin_role = ( + "http://purl.imsglobal.org/vocab/lis/v2/membership#Administrator" in roles + ) + has_content_developer_role = ( + "http://purl.imsglobal.org/vocab/lis/v2/membership#ContentDeveloper" in roles + ) + has_instructor_role = ( + "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor" in roles + ) + has_ta_role = ( + "http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistant" + in roles + ) + + role = UserRole.STUDENT + if has_admin_role: + role = UserRole.INSTRUCTOR + elif has_content_developer_role: + role = UserRole.INSTRUCTOR + elif has_instructor_role and not has_ta_role: + role = UserRole.INSTRUCTOR + elif has_ta_role: + role = UserRole.INSTRUCTOR + + if settings.DEBUG: + pprint(message_launch_data) + + launch_iss = message_launch_data.get("iss") + + # create/sync user + # custom variables need to be defined in Canvas, defined by consulting + # https://gist.github.com/jbasdf/15fb8abed66fbaf623cdfbdd45bfb1c4 + canvas_course_id = message_launch_data.get( + "https://purl.imsglobal.org/spec/lti/claim/custom", {} + ).get("canvas_course_id") + canvas_user_id = message_launch_data.get( + "https://purl.imsglobal.org/spec/lti/claim/custom", {} + ).get("canvas_user_id") + + auth_user, created = MyUser.objects.get_or_create( + # Make the username unique to the launch ISS, so different + # institution's Canvas instance creates unique usernames + username=f"{canvas_user_id}+{launch_iss}" + ) + auth_user.first_name = message_launch_data.get("given_name") + auth_user.last_name = message_launch_data.get("family_name") + + auth_user.set_unusable_password() + auth_user.save() + + auth_user.backend = "django.contrib.auth.backends.ModelBackend" # type: ignore + auth_login(request, auth_user) + + # make the authentication token for the frontend + Token.objects.get_or_create(user=auth_user) + + # todo: this is only true for now + organization = Organization.objects.first() + + course, created = Course.objects.get_or_create( + lms_course_id=canvas_course_id, + defaults={ + "name": message_launch_data.get( + "https://purl.imsglobal.org/spec/lti/claim/context", {} + ).get("label"), + "organization": organization, + }, + ) + + course_member, created = CourseMember.objects.get_or_create( + user=auth_user, + course=course, + defaults={ + "first_name": auth_user.first_name, + "last_name": auth_user.last_name, + "lms_id": canvas_user_id, + }, + ) + course_member.role = role + course_member.save() + + return redirect( + f"{reverse('canvas-oauth-initiate')}?redirect_path=/course/{course.pk}" + ) diff --git a/src/teamable/settings.py b/src/teamable/settings.py index 71a0f85..60fec46 100644 --- a/src/teamable/settings.py +++ b/src/teamable/settings.py @@ -14,6 +14,8 @@ import os from dotenv import load_dotenv +from lti.lti_util import is_lti_enabled + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -30,6 +32,7 @@ if DEBUG: ALLOWED_HOSTS = ["*"] + CSRF_TRUSTED_ORIGINS = ["http://localhost:8002"] else: ALLOWED_HOSTS = os.environ["SERVER_NAME"].split() CSRF_TRUSTED_ORIGINS = list( @@ -53,6 +56,8 @@ "rest_framework", "accounts", "app", + "lti", + "canvas_oauth", ] MIDDLEWARE = [ @@ -64,6 +69,7 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "canvas_oauth.middleware.OAuthMiddleware", ] ROOT_URLCONF = "teamable.urls" @@ -86,6 +92,7 @@ WSGI_APPLICATION = "teamable.wsgi.application" +SESSION_COOKIE_SAMESITE = None # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases @@ -167,6 +174,14 @@ AUTH_USER_MODEL = "accounts.MyUser" +# Canvas API & OAuth +CANVAS_BASE_URL = os.environ.get("CANVAS_BASE_URL") +CANVAS_OAUTH_CLIENT_ID = os.environ.get("CANVAS_OAUTH_CLIENT_ID") +CANVAS_OAUTH_CLIENT_SECRET = os.environ.get("CANVAS_OAUTH_CLIENT_SECRET") +CANVAS_OAUTH_CANVAS_DOMAIN = CANVAS_BASE_URL + +LTI_ENABLED = is_lti_enabled() + LOGGING = { "version": 1, "disable_existing_loggers": False, diff --git a/src/teamable/urls.py b/src/teamable/urls.py index ffc3ac9..c1eb620 100644 --- a/src/teamable/urls.py +++ b/src/teamable/urls.py @@ -7,4 +7,6 @@ path("api/v1/", include("app.urls")), path("admin/", admin.site.urls), path("api/auth/", include("rest_framework.urls")), + path("lti/", include("lti.urls")), + path("oauth/", include("canvas_oauth.urls")), ]