diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..20d7ebb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.dockerignore +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env +venv* +pip-log.txt +pip-delete-this-directory.txt +.tox +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +*.log +.git +*.template +.env* +fake-s3 diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..e452872 --- /dev/null +++ b/.env.template @@ -0,0 +1,16 @@ +TOOL_TITLE=Faculty Tools +THEME_DIR= +BASE_CANVAS_SERVER_URL=https://example.com/ +SECRET_KEY=CHANGEME +LTI_KEY=key +LTI_SECRET=secret +OAUTH2_URI=http://127.0.0.1:9001/oauthlogin +OAUTH2_ID=CHANGEME +OAUTH2_KEY=CHANGEME +GOOGLE_ANALYTICS=GA-000000 +CONFIG=config.DevelopmentConfig +DATABASE_URI=mysql://root:secret@db/faculty_tools + +REQUIREMENTS=test_requirements.txt + +WHITELIST_JSON=whitelist.json \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..796eb2d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,96 @@ +name: Run Python Tests and Build Image + +on: + push: + branches: + - issue/21-dockerize + - develop + - master + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + REQUIREMENTS: requirements.txt + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install Python 3 + uses: actions/setup-python@v1 + with: + python-version: 3.7 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r test_requirements.txt + pip install coveralls + - name: Setup Repo + run: | + cp whitelist.json.template whitelist.json + cp .env.template .env + - name: Run flake8 + run: flake8 + - name: Run black + run: black --check . + - name: Lint markdown files + uses: bewuethr/mdl-action@v1 + # - name: Load dotenv + # uses: falti/dotenv-action@v0.2.5 + - name: Environment Variables from Dotenv + uses: c-py/action-dotenv-to-setenv@v3 + # - name: Print Repo + # run: | + # env + - name: Run unittests + run: coverage run -m unittest discover + + + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Print Buildx + run: | + docker buildx ls + + - name: Build and push Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: REQUIREMENTS=${{ env.REQUIREMENTS }} + platforms: linux/amd64 + + - name: Build and push ARM64 Docker image + uses: docker/build-push-action@v3 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: REQUIREMENTS=${{ env.REQUIREMENTS }} + platforms: linux/arm64 + + - name: Build and push ARMv7 Docker image + uses: docker/build-push-action@v3 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: REQUIREMENTS=${{ env.REQUIREMENTS }} + platforms: linux/arm/v7 + diff --git a/.gitignore b/.gitignore index 0e980ad..86319a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # config/settings logs -settings.py whitelist.json +.env # local theming themes/* diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 802f960..0000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -language: python -matrix: - include: - - python: 3.7 - - python: 3.8 -install: -- pip install -r test_requirements.txt -- pip install coveralls -- gem install mdl -before_script: -- cp settings.py.template settings.py; cp whitelist.json.template whitelist.json -- mkdir logs; touch logs/faculty-tools.log -script: -- flake8 -- black --check . -- mdl . -- coverage run -m unittest discover -after_success: -- coveralls -notifications: - slack: - rooms: - secure: F3YANiuNHZjtbgiJC8M1JOKBKhct2EyDEkCitW4FMwrauV0G5XcWKqtqLyRzSchI1LUALn+Dnj032ElGEx7D7cJrXlqC700UjQ4wXv938GQObVnRfTVCVrsktpr5gf077dIXvwcYJYt9ZSu4uIyk+HqyNnHLX4qIL0zhFR8HytoOeXVdik35SQoLJLvorgf4EGfqU8Yo25LUJArp2AB7RceAiMg3QXmi+nDHumFFczURexaYXIDBrRYTyZgfpYP245HOUmEf/LD6G53e6FU+8hiISYr7nE0hTkNkj3U4WNga25//9VIdpjWW8VWd+G7vf8CuhzHYuWEtredoVqNnJDwKE/MLhixleA+1lAEUypAEp+0k3+zMfTk748gdl1buJ/kINjoNqjhLv6MtDH/YTw/eQKEXA+V+odoudDiHUCztHQbCaIXYmIDFnebzO9u/Gz7QJ7PfpBlmUASQru5qTFPL0tmHP/w6/zrog0n07+uwl4qo2d6qxslgtmw4+K0VxGXl1Z8STArEgD6a8KoTZ1N8XDFF0E7KXE3kGYEtLHNoV7Z9OaohB3AFwJaKPTytYyIQ+OPvzYmpyUwRGIWBfRIP7t9qemyOExbYoinw0rF7jCMm7T3gLxkwxHNOCt+Gc1kwfLvuDy8QhQR2iHspMM8NeuDQKVzK4L3FeLx7bTo= diff --git a/CHANGELOG.md b/CHANGELOG.md index 0170404..83de120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### General +- Implement LTI 1.3 standard for tool launch +- Move refresh and access tokens from session storage to db storage +- add Makefile for easier docker and utility commands + ## [1.2.0] - 2019-09-10 ### General @@ -18,8 +22,7 @@ ### Bugfixes -- Fixed an issue where switching browsers would cause Faculty Tools to ask the - user to reauthorize with Canvas, leading to multiple access tokens. +- Fixed an issue where switching browsers would cause Faculty Tools to ask the user to reauthorize with Canvas, leading to multiple access tokens. ## [1.0.0] - 2018-08-29 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bd76409 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12 as base +ARG REQUIREMENTS +COPY requirements.txt /app/ +COPY test_requirements.txt /app/ +# RUN pip install --upgrade pip +RUN pip install -r /app/$REQUIREMENTS +WORKDIR /app +COPY ./ /app/ +EXPOSE 9001 +CMD ["gunicorn", "--conf", "gunicorn_conf.py", "--bind", "0.0.0.0:9001", "lti:app"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..66e703b --- /dev/null +++ b/Makefile @@ -0,0 +1,83 @@ +COMPOSE_FILE=docker-compose.yml +DOCKER_COMPOSE=docker compose -f $(COMPOSE_FILE) + +BLACK := $(shell tput -Txterm setaf 0) +RED := $(shell tput -Txterm setaf 1) +GREEN := $(shell tput -Txterm setaf 2) +YELLOW := $(shell tput -Txterm setaf 3) +LIGHTPURPLE := $(shell tput -Txterm setaf 4) +PURPLE := $(shell tput -Txterm setaf 5) +BLUE := $(shell tput -Txterm setaf 6) +WHITE := $(shell tput -Txterm setaf 7) +RESET := $(shell tput -Txterm sgr0) + +default: build + +#============================================== +# Building and cleaning the Docker environment +#============================================== +build: ## Build all Docker images + @echo "Building Faculty Tools' Docker images" + @$(DOCKER_COMPOSE) build + +build-no-cache: ## Build all Docker images + @echo "Building Faculty Tools' Docker images" + @$(DOCKER_COMPOSE) build --no-cache + +clean: stop-lti remove-lti-volumes build-no-cache ## Stops and removes existing existing containers before rebuilding images + +nuke: ## Stops and removes existing existing containers and volumes, including the database + @echo "${YELLOW}Stopping running containers and purging existing volumes${RESET}" + $(DOCKER_COMPOSE) down -v + +#================================================================================ +# Managing the Docker environment (e.g. starting, stopping, deleting containers) +#================================================================================ +start: start-daemon ## Start Faculty Tools (default: daemon mode) + +start-attached: ## Start Faculty Tools in attached mode + @echo "${GREEN}Starting Faculty Tools in attached mode${RESET}" + $(DOCKER_COMPOSE) up + +start-daemon: ## Start Faculty Tools in daemon mode + @echo "${GREEN}Starting Faculty Tools in daemon mode${RESET}" + @echo "Run \`make start-attached\` to run in attached mode, or view container logs with \`make logs\`" + $(DOCKER_COMPOSE) up -d + +stop: ## Stop Faculty Tools + @echo "${YELLOW}Stopping Faculty Tools${RESET}" + $(DOCKER_COMPOSE) down + +logs: ## View container logs (optionally specifying a service name, like `lti`) + $(DOCKER_COMPOSE) logs -f + +#================================= +# Application management commands +#================================= + +# TODO ensure the new way using `db-init` covers same actions +# create-db: +# @echo "Initializing the database" +# docker-compose up -d lti +# docker-compose exec lti python -c "from lti import app, db; app.app_context().push(); db.create_all()" + +db-init: ## Initialize the database + ${DOCKER_COMPOSE} run --rm -e FLASK_APP=lti.py lti flask db init + +makemigrations: ## Create a new DB migration + ${DOCKER_COMPOSE} run --rm -e FLASK_APP=lti.py lti flask db migrate + +migrate-run: ## Run an existing DB migration + ${DOCKER_COMPOSE} run --rm -e FLASK_APP=lti.py lti flask db upgrade + +downgrade: ## Downgrade the database + ${DOCKER_COMPOSE} run --rm -e FLASK_APP=lti.py lti flask db downgrade + +generate-keys: ## Create new public and private keys and assign them to a keyset + ${DOCKER_COMPOSE} run --rm -e FLASK_APP=lti.py lti flask generate_keys + +register: ## Add a new registration for Zapt in a platform + ${DOCKER_COMPOSE} run --rm -e FLASK_APP=lti.py lti flask register + +deploy: ## Add a new deployment for Zapt to an existing registration + ${DOCKER_COMPOSE} run --rm -e FLASK_APP=lti.py lti flask deploy diff --git a/README.md b/README.md index 529383f..4c08942 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,54 @@ # Documentation for Faculty Tools -## Settings +## Setting up Faculty Tools with Docker & Docker-Compose -Create a new `settings.py` file from the template +First clone and setup the repo. ```sh -cp settings.py.template settings.py +git clone git@github.com:ucfopen/faculty-tools.git +cd faculty-tools +cp whitelist.json.template whitelist.json +cp .env.template .env ``` -Edit `settings.py` to configure the application. All fields are required, +### Environment Variables + +Edit `.env` to configure the application. All fields are required, unless specifically noted. -## Developer Key +To create a good secure secret key, run this command: + +```sh +docker-compose run --rm lti python -c "import os, binascii; print(binascii.b2a_base64(os.urandom(24)).decode('ascii'))" +``` + +```sh +TOOL_TITLE=Faculty Tools # Window Title of Page +THEME_DIR= # Keep blank unless building out your own theme directory +BASE_CANVAS_SERVER_URL=https://example.com/ # the URL for your canvas server +SECRET_KEY=CHANGEME # Random key used to secure portions of Flask - Follow instructions above +LTI_KEY=key # Random key - This is public - Used to install LTI. +LTI_SECRET=secret # Random secret key - Used to install LTI. Do not share! +OAUTH2_URI=http://127.0.0.1:9001/oauthlogin # URL of faculty tools oauthlogin page +OAUTH2_ID=CHANGEME # ID given by LMS Admins / Developer Key (API Key) page in Canvas +OAUTH2_KEY=CHANGEME # ID given by LMS Admins / Developer Key (API Key) page in Canvas +GOOGLE_ANALYTICS=GA-000000 # Your Google Analytics id. +DATABASE_URI=mysql://root:secret@db/faculty_tools # Your mysql connection string. + +# config.py configuration settings +# config.Development for Dev, config.BaseConfig for production +CONFIG=config.DevelopmentConfig + + +# test_requirements for development / requirements.txt for production. +REQUIREMENTS=test_requirements.txt + +WHITELIST_JSON=whitelist.json # See below + +``` + +## Developer Key -> API Key You will need a developer key for the OAuth2 flow. Check out the [Canvas documentation for creating a new developer key](https://community.canvaslms.com/docs/DOC-12657-4214441833) @@ -57,67 +93,55 @@ Add the tools you want instructors and faculty to see to `whitelist.json`. ] ``` -## Virtual Environment - -Create a new virtual environment. - -```sh -virtualenv env -``` - -Activate the environment. - -```sh -source env/bin/activate -``` - -Install everything: - -```sh -pip install -r requirements.txt -``` - ## Create DB -Change directory into the project folder. Create the database in python shell: +We need to generate the database and tables for faculty tools to run properly. +The MySQL docker image automatically creates the user, password, and database +name set in the `docker-compose.yml` file. ```sh -from lti import db -db.create_all() +make create-db ``` If you want to look at your users table in the future, you can do so in the python shell: ```python +docker-compose run lti python from lti import Users Users.query.all() ``` -## Environment Variables +## Run the App -Set the flask app to `lti.py` and debug to true. +It's time to use docker-compose to bring up the application. ```sh -export FLASK_APP=lti.py -export FLASK_DEBUG=1 +make start-daemon ``` -Alternatively, you can run the setup script to simultaneously setup environment -variables and the virtual environment. +You can also run the app in the foreground with: ```sh -source setup.sh +docker-compose up ``` -## Run the App +Go to the /xml page, by default. + +Copy the xml, and install it into a course (Course->Settings->Apps). -Run the lti script while your virtual environment is active. +## View the Logs + +To view the logs while the application is running use this command: ```sh -flask run +make logs ``` -Go to the /xml page, [http://0.0.0.0:5000/xml](http://0.0.0.0:5000/xml) by default +## Stopping the App + +To shutdown Faculty Tools -Copy the xml, install it into a course. +```sh +make stop +``` diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..64a87c9 --- /dev/null +++ b/cli.py @@ -0,0 +1,211 @@ +from Crypto.PublicKey import RSA +from flask import Flask +from sqlalchemy.exc import IntegrityError + + +def register_cli(app: Flask): + @app.cli.command("generate_keys") + def generate_keys(): + from lti import Key, KeySet, db + + def get_keyset(): + all_keysets = KeySet.query.all() + + print( + "Would you like to create your new key in a new key set or use an existing one?\n" + " 1 - Create a new key set\n" + f" 2 - Use an existing key set ({len(all_keysets)} available)" + ) + new_or_old = input("Selection number: ") + + if new_or_old == "1": + print("Creating new key set...") + selected_keyset = KeySet() + db.session.add(selected_keyset) + db.session.commit() + print(f"Created new key set: {selected_keyset.id}") + elif new_or_old == "2": + print("Which keyset would you like to use?") + for keyset in all_keysets: + # TODO: improve keyset details with related registration info + print(f" {keyset.id} - {len(keyset.keys)} keys") + + keyset_id = input("Key Set ID: ") + + selected_keyset = KeySet.query.filter_by(id=keyset_id).first() + if selected_keyset: + print(f"Using key set: {selected_keyset.id}") + else: + print( + f"Unable to find keyset with key set ID '{keyset_id}'. Cancelling..." + ) + return None + + else: + print(f"Invalid option '{new_or_old}'. Cancelling...") + return None + + return selected_keyset + + def create_keys(keyset, alg="RS256"): + print("Starting key generation...") + + key = RSA.generate(4096) + + print("Generating Private Key...") + private_key = key.exportKey() + + print("Generating Public Key...") + public_key = key.publickey().exportKey() + + newkey = Key( + key_set_id=keyset.id, + public_key=public_key, + private_key=private_key, + alg=alg, + ) + db.session.add(newkey) + db.session.commit() + print( + f"Created new Public and Private key #{newkey.id} ({newkey.alg}) " + f"in key set #{newkey.key_set_id}" + ) + + keyset = get_keyset() + if not keyset: + print("Unable to get valid keyset. Exiting.") + return 1 + + create_keys(keyset) + + print( + "Keys created.\n\n" + "To add a new registration: \n" + "- If you are using a makefile, run `make register`\n" + "- If you are running this script directly, run `flask register`\n" + ) + + @app.cli.command("register") + def add_registration(): + from lti import KeySet, Registration, db + + platform_options = [ + { + "name": "Production Canvas", + "url_base": "https://sso.canvaslms.com", + "issuer": "https://canvas.instructure.com", + }, + { + "name": "Test Canvas", + "url_base": "https://sso.test.canvaslms.com", + "issuer": "https://canvas.test.instructure.com", + }, + { + "name": "Beta Canvas", + "url_base": "https://sso.beta.canvaslms.com", + "issuer": "https://canvas.beta.instructure.com", + }, + { + "name": "Other Canvas Platform", + "url_base": "replaceme", + "issuer": "https://canvas.instructure.com", + }, + ] + + print("Which platform are you using?") + for index, platform in enumerate(platform_options, start=1): + print(f" {index} - {platform['name']}") + + platform_choice = input("Platform Number: ") + + try: + platform_choice = int(platform_choice) + assert platform_choice > 0 + + selected_platform = platform_options[platform_choice - 1] + if platform_choice == len(platform_options): + # Update other platform url base from user input + print("Provide your server url. Remove any trailing slashes.") + platform_url = input("Server URL: ") + selected_platform["url_base"] = platform_url + + except (AssertionError, IndexError, ValueError): + print(f"Invalid option '{platform_choice}'") + return + + print(f"Registering in {selected_platform['name']}...") + + # client_id + print(f"Input the Client ID provided by {selected_platform['name']}s") + client_id = input("Client ID: ") + + # key_set_id + print("Which keyset would you like to use?") + for keyset in KeySet.query.all(): + # TODO: improve keyset details with related registration info + print(f" {keyset.id} - {len(keyset.keys)} keys") + + keyset_id = input("Key Set ID: ") + + selected_keyset = KeySet.query.filter_by(id=keyset_id).first() + if selected_keyset: + print(f"Using key set: {selected_keyset.id}") + else: + print(f"Unable to find keyset with key set ID '{keyset_id}'. Cancelling...") + # TODO: handle bad keyset case + + try: + new_reg = Registration( + issuer=selected_platform["issuer"], + client_id=client_id, + platform_login_auth_endpoint=( + f"{selected_platform['url_base']}/api/lti/authorize_redirect" + ), + platform_service_auth_endpoint=( + f"{selected_platform['url_base']}/login/oauth2/token" + ), + platform_jwks_endpoint=f"{selected_platform['url_base']}/api/lti/security/jwks", + key_set_id=selected_keyset.id, + ) + db.session.add(new_reg) + db.session.commit() + + print( + f"Created new registration: {new_reg.id}\n" + "Don't forget to enable the Client ID!\n\n" + "To add a deployment: \n" + "- If you are using a makefile, run `make deploy`\n" + "- If you are running this script directly, run `flask deploy`\n" + ) + except IntegrityError: + print("A registration with that issuer and client_id already exists.") + # TODO: handle duplicate registration case + + @app.cli.command("deploy") + def add_deployment(): + from lti import Deployment, Registration, db + + print("Which registration would you like to add a deployment for?") + for registration in Registration.query.all(): + print( + f" {registration.id} - {registration.client_id} {registration.issuer}\n" + f" ({len(registration.deployments)} deployments)" + ) + reg_id = input("Select Registration: ") + + registration = Registration.query.filter_by(id=reg_id).first() + if not registration: + print(f"Unable to find registration with ID '{reg_id}'. Cancelling...") + return + + print( + f"Provide the new deployment ID to attach to registration {registration.id}" + ) + deploy_id = input("Deployment ID: ") + + new_deploy = Deployment( + deployment_id=deploy_id, registration_id=registration.id + ) + db.session.add(new_deploy) + db.session.commit() + print(f"Added deployment {new_deploy.id} ({new_deploy.deployment_id}).") diff --git a/config.py b/config.py index c360a34..9a8c1dc 100644 --- a/config.py +++ b/config.py @@ -1,12 +1,14 @@ -import settings +import os -class Config(object): +class BaseConfig(object): + DEBUG = False + TESTING = False + + PREFERRED_URL_SCHEME = "https" + # make the warning shut up until Flask-SQLAlchemy v3 comes out SQLALCHEMY_TRACK_MODIFICATIONS = True - SQLALCHEMY_DATABASE_URI = settings.select_db("Config") - - PYLTI_CONFIG = settings.PYLTI_CONFIG SESSION_COOKIE_NAME = "ft_session" @@ -14,33 +16,59 @@ class Config(object): SESSION_COOKIE_SECURE = True SESSION_COOKIE_SAMESITE = "None" + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URI") -class BaseConfig(object): - DEBUG = False - TESTING = False + # Title of the tool. Appears in the element, headers, and configuration XML + TOOL_TITLE = os.environ.get("TOOL_TITLE", "Faculty Tools") - # make the warning shut up until Flask-SQLAlchemy v3 comes out - SQLALCHEMY_TRACK_MODIFICATIONS = True - SQLALCHEMY_DATABASE_URI = settings.select_db("BaseConfig") + # Which theme directory to use. Leave blank for default. + THEME_DIR = os.environ.get("THEME_DIR", "") - PYLTI_CONFIG = settings.PYLTI_CONFIG + # Canvas instance URL. ex: https://example.instructure.com/ + BASE_URL = os.environ.get("BASE_CANVAS_SERVER_URL", "https://example.com/") + API_URL = BASE_URL + "api/v1/" - SESSION_COOKIE_NAME = "ft_session" + # Secret key to sign Flask sessions with. KEEP THIS SECRET! + # Set this in .env file. + SECRET_KEY = os.environ.get("SECRET_KEY") - # Chrome 80 SameSite=None; Secure fix - SESSION_COOKIE_SECURE = True - SESSION_COOKIE_SAMESITE = "None" + # LTI consumer key and shared secret + # CONSUMER_KEY = os.environ.get("LTI_KEY") + # SHARED_SECRET = os.environ.get("LTI_SECRET") + + # Configuration for pylti library. Uses the above key and secret + # PYLTI_CONFIG = { + # "consumers": {CONSUMER_KEY: {"secret": SHARED_SECRET}}, + # # Custom configurable roles + # "roles": { + # "staff": [ + # "urn:lti:instrole:ims/lis/Administrator", + # "Instructor", + # "ContentDeveloper", + # "urn:lti:role:ims/lis/TeachingAssistant", + # ] + # }, + # } + + # The "Oauth2 Redirect URI" that you provided to Instructure. + # Set in .env file + OAUTH2_URI = os.environ.get("OAUTH2_URI") # ex. 'http://localhost:9001/oauthlogin' + # The Client_ID Instructure gave you + OAUTH2_ID = os.environ.get("OAUTH2_ID") + # The Secret Instructure gave you + OAUTH2_KEY = os.environ.get("OAUTH2_KEY") + + WHITELIST = os.environ.get("WHITELIST_JSON") + + # Google Analytics Tracking ID (optional) + GOOGLE_ANALYTICS = os.environ.get("GOOGLE_ANALYTICS", "GA-") class DevelopmentConfig(BaseConfig): DEBUG = True TESTING = True - SQLALCHEMY_DATABASE_URI = settings.select_db("DevelopmentConfig") - class TestingConfig(BaseConfig): DEBUG = False TESTING = True - - SQLALCHEMY_DATABASE_URI = settings.select_db("TestingConfig") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4f0f163 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +version: "3.1" + +services: + lti: + build: + context: . + args: + - "REQUIREMENTS=${REQUIREMENTS}" + ports: + - "9001:9001" + env_file: + - .env + depends_on: + - db + volumes: + - .:/app + + db: + image: mysql:5.7 + platform: linux/amd64 + volumes: + - ft_dbdata:/var/lib/mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: faculty_tools + MYSQL_PASSWORD: secret + ports: + - "33061:3306" +volumes: + ft_dbdata: {} diff --git a/gunicorn_conf.py b/gunicorn_conf.py new file mode 100644 index 0000000..cad3455 --- /dev/null +++ b/gunicorn_conf.py @@ -0,0 +1,9 @@ +# Gunicorn config variables +loglevel = "info" +errorlog = "-" # stderr +accesslog = "-" # stdout +worker_tmp_dir = "/dev/shm" +graceful_timeout = 120 +timeout = 120 +keepalive = 5 +threads = 3 diff --git a/lti.py b/lti.py index 3b98987..37cfaf4 100644 --- a/lti.py +++ b/lti.py @@ -1,7 +1,10 @@ +import logging from logging import Formatter, INFO -from logging.handlers import RotatingFileHandler +from urllib.parse import urlparse import json +import functools import os +import sys import time from canvasapi.exceptions import CanvasException @@ -16,26 +19,45 @@ send_from_directory, ) from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_caching import Cache +from sqlalchemy import text import jinja2 -from pylti.flask import lti + +# from pylti.flask import lti + +from pylti1p3.contrib.flask import ( + FlaskCacheDataStorage, + FlaskMessageLaunch, + FlaskOIDCLogin, + FlaskRequest, +) +from pylti1p3.deep_link_resource import DeepLinkResource +from pylti1p3.tool_config import ToolConfDict + import requests from requests.exceptions import HTTPError from utils import filter_tool_list, slugify -import settings + +from cli import register_cli app = Flask(__name__) -app.config.from_object(settings.configClass) -app.secret_key = settings.secret_key +app.config.from_object(os.environ.get("CONFIG", "config.DevelopmentConfig")) +app.secret_key = app.config["SECRET_KEY"] + +cache = Cache(app, config={"CACHE_TYPE": "simple"}) db = SQLAlchemy(app) +migrate = Migrate(app, db) +register_cli(app) def select_theme_dirs(): """ Load theme templates, if applicable """ - if settings.THEME_DIR: - return ["themes/" + settings.THEME_DIR + "/templates", "templates"] + if app.config["THEME_DIR"]: + return ["themes/" + app.config["THEME_DIR"] + "/templates", "templates"] else: return ["templates"] @@ -44,11 +66,7 @@ def select_theme_dirs(): app.jinja_loader = jinja2.ChoiceLoader([jinja2.FileSystemLoader(theme_dirs)]) # Logging -handler = RotatingFileHandler( - settings.ERROR_LOG, - maxBytes=settings.LOG_MAX_BYTES, - backupCount=settings.LOG_BACKUP_COUNT, -) +handler = logging.StreamHandler(sys.stdout) handler.setLevel(INFO) handler.setFormatter( Formatter( @@ -59,27 +77,71 @@ def select_theme_dirs(): app.logger.addHandler(handler) -# DB Model +# ============================================ +# DB Models +# ============================================ class Users(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, unique=True) refresh_key = db.Column(db.String(255)) expires_in = db.Column(db.BigInteger) + api_key = db.Column(db.String(255)) - def __init__(self, user_id, refresh_key, expires_in): + def __init__(self, user_id, refresh_key, expires_in, api_key): self.user_id = user_id self.refresh_key = refresh_key self.expires_in = expires_in + self.api_key = api_key def __repr__(self): return "<User %r>" % self.user_id +class Key(db.Model): + __tablename__ = "key" + id = db.Column(db.Integer, primary_key=True) + key_set_id = db.Column(db.Integer, db.ForeignKey("key_set.id"), nullable=False) + public_key = db.Column(db.Text, nullable=False) + private_key = db.Column(db.Text, nullable=False) + alg = db.Column(db.Text, nullable=False) # defaults to RS256 + + +class KeySet(db.Model): + __tablename__ = "key_set" + id = db.Column(db.Integer, primary_key=True) + registrations = db.relationship("Registration", backref="key_set", lazy=True) + keys = db.relationship("Key", backref="key_set", lazy=True) + + +class Registration(db.Model): + __tablename__ = "registration" + id = db.Column(db.Integer, primary_key=True) + issuer = db.Column(db.String(255), nullable=False) + client_id = db.Column(db.String(255), nullable=False) + platform_login_auth_endpoint = db.Column(db.String(255), nullable=False) + platform_service_auth_endpoint = db.Column(db.String(255), nullable=False) + platform_jwks_endpoint = db.Column(db.String(255), nullable=False) + key_set_id = db.Column(db.Integer, db.ForeignKey("key_set.id"), nullable=False) + deployments = db.relationship("Deployment", backref="registration", lazy=True) + __table_args__ = (db.UniqueConstraint("issuer", "client_id"),) + + +class Deployment(db.Model): + __tablename__ = "deployment" + id = db.Column(db.Integer, primary_key=True) + deployment_id = db.Column(db.String(255), nullable=False) + registration_id = db.Column( + db.Integer, db.ForeignKey("registration.id"), nullable=False + ) + + +# ============================================ # Utility Functions +# ============================================ @app.context_processor def ga_utility_processor(): def google_analytics(): - return settings.GOOGLE_ANALYTICS + return app.config["GOOGLE_ANALYTICS"] return dict(google_analytics=google_analytics()) @@ -87,7 +149,7 @@ def google_analytics(): @app.context_processor def title_utility_processor(): def title(): - return settings.TOOL_TITLE + return app.config["TOOL_TITLE"] return dict(title=title()) @@ -95,13 +157,13 @@ def title(): @app.context_processor def theme_static_files_processor(): def theme_static_files(folder): - if not settings.THEME_DIR: + if not app.config["THEME_DIR"]: return list() try: all_files = os.listdir( "themes/{theme_dir}/static/{folder}".format( - theme_dir=settings.THEME_DIR, folder=folder + theme_dir=app.config["THEME_DIR"], folder=folder ) ) @@ -127,36 +189,197 @@ def _slugify(string): return slugify(string) +def get_lti_config(): + registrations = Registration.query.all() + + from collections import defaultdict + + settings = defaultdict(list) + for registration in registrations: + settings[registration.issuer].append( + { + "client_id": registration.client_id, + "auth_login_url": registration.platform_login_auth_endpoint, + "auth_token_url": registration.platform_service_auth_endpoint, + "auth_audience": "null", # TODO: figure out what this is for? + "key_set_url": registration.platform_jwks_endpoint, + "key_set": None, + "deployment_ids": [d.deployment_id for d in registration.deployments], + } + ) + + # TODO: figure out more elegant way to set public/private keys without double loop + tool_conf = ToolConfDict(settings) + for registration in registrations: + # Currently pylti1.3 only allows one key per client id. For now just set first one. + key = registration.key_set.keys[0] + tool_conf.set_private_key( + registration.issuer, + # ensure type is string not bytes (varies based on DB type) + ( + key.private_key + if isinstance(key.private_key, str) + else key.private_key.decode("utf-8") + ), + client_id=registration.client_id, + ) + tool_conf.set_public_key( + registration.issuer, + # ensure type is string not bytes (varies based on DB type) + ( + key.public_key + if isinstance(key.public_key, str) + else key.public_key.decode("utf-8") + ), + client_id=registration.client_id, + ) + return tool_conf + + def return_error(msg): return render_template("error.html", msg=msg) # for the pylti decorator -def error(exception=None): - app.logger.error("PyLTI error: {}".format(exception)) - return return_error( - ( - "Authentication error, please refresh and try again. If this error " - "persists, please contact support." - ) - ) +# def error(exception=None): +# app.logger.error("PyLTI error: {}".format(exception)) +# return return_error( +# ( +# "Authentication error, please refresh and try again. If this error " +# "persists, please contact support." +# ) +# ) @app.route("/themes/static/<path:filename>") def theme_static(filename): # pragma: nocover - static_dir = "themes/{theme_dir}/static".format(theme_dir=settings.THEME_DIR) + static_dir = "themes/{theme_dir}/static".format(theme_dir=app.config["THEME_DIR"]) return send_from_directory(static_dir, filename) +# ============================================ +# LTI 1.3 +# ============================================ +def get_launch_data_storage(): + return FlaskCacheDataStorage(cache) + + +def lti_required(role=None): + """ + LTI Protector - only allow access to routes if user has been authenticated and has a launch ID. + You can also pass in a role to restrict access to certain roles e.g. @lti_required(role="staff") + + Args: + role (str, optional): The role to restrict access to. Defaults to None. + + Returns: + function: The decorated function. + """ + + def decorator(func): + @functools.wraps(func) + def secure_function(*args, **kwargs): + if "launch_id" not in session: + return ( + "<h2>Unauthorized</h2><p>You must use this tool in an LTI context.</p>", + 401, + ) + + if role == "staff": + if "roles" not in session or ( + "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator" + not in session["roles"] + and "http://purl.imsglobal.org/vocab/lis/v2/membership#Administrator" + not in session["roles"] + and "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor" + not in session["roles"] + ): + return ( + "<h2>Unauthorized</h2><p>You must be faculty to use this tool.</p>", + 401, + ) + + return func(*args, **kwargs) + + return secure_function + + return decorator + + +@app.route("/login/", methods=["GET", "POST"]) +def login(): + tool_conf = get_lti_config() + + launch_data_storage = get_launch_data_storage() + + flask_request = FlaskRequest() + + target_link_uri = flask_request.get_param("target_link_uri") + if not target_link_uri: + raise Exception('Missing "target_link_uri" param') + + oidc_login = FlaskOIDCLogin( + flask_request, tool_conf, launch_data_storage=launch_data_storage + ) + return oidc_login.enable_check_cookies( + main_msg="Your browser prohibits saving cookies in an iframe.", + click_msg="Click here to open the application in a new tab.", + ).redirect(target_link_uri) + + +@app.route("/launch/", methods=["POST"]) +def launch(): + tool_conf = get_lti_config() + + flask_request = FlaskRequest() + launch_data_storage = get_launch_data_storage() + message_launch = FlaskMessageLaunch( + flask_request, tool_conf, launch_data_storage=launch_data_storage + ) + session["launch_id"] = message_launch.get_launch_id() + session["course_id"] = message_launch.get_launch_data()[ + "https://purl.imsglobal.org/spec/lti/claim/custom" + ]["canvas_course_id"] + session["canvas_user_id"] = message_launch.get_launch_data()[ + "https://purl.imsglobal.org/spec/lti/claim/custom" + ]["canvas_user_id"] + session["roles"] = message_launch.get_launch_data()[ + "https://purl.imsglobal.org/spec/lti/claim/roles" + ] + session["error"] = False + + # redirect to the oauth flow + return redirect(url_for("auth")) + + +@app.route("/jwks/", methods=["GET"]) +def get_jwks(): + return get_lti_config().get_jwks() + + +# ============================================ # Web Views / Routes +# ============================================ + + @app.route("/") -@lti(error=error, role="staff", app=app) -def index(lti=lti): +@lti_required(role="staff") +def index(): """ Main entry point to web application, get all whitelisted LTIs and send the data to the template """ + user = Users.query.filter_by(user_id=int(session["canvas_user_id"])).first() + if int(time.time()) > user.expires_in: + app.logger.info( + ( + "User token expired before index API check; " + "delegating to auth refresh flow. User: {0}" + ).format(user.user_id) + ) + return redirect(url_for("auth")) - if "api_key" not in session: + api_key = user.api_key + if api_key is None: app.logger.error("api_key not set") return return_error( ( @@ -166,10 +389,13 @@ def index(lti=lti): ) # Test API key to see if they need to reauthenticate - auth_header = {"Authorization": "Bearer " + session["api_key"]} - r = requests.get(settings.API_URL + "users/self", headers=auth_header) + auth_header = {"Authorization": "Bearer " + api_key} + r = requests.get( + app.config["API_URL"] + "users/%s/profile" % (session["canvas_user_id"]), + headers=auth_header, + ) if "WWW-Authenticate" in r.headers: - # reroll oauth + # Let /auth try refresh first before forcing OAuth reauthorization. app.logger.info( ( "WWW-Authenticate found in headers, or status code was 401. " @@ -177,13 +403,7 @@ def index(lti=lti): ).format(r.status_code, r.headers, r.url) ) - return redirect( - settings.BASE_URL - + "login/oauth2/auth?client_id=" - + settings.oauth2_id - + "&response_type=code&redirect_uri=" - + settings.oauth2_uri - ) + return redirect(url_for("auth")) if "WWW-Authenticate" not in r.headers and r.status_code == 401: # not authorized @@ -209,16 +429,10 @@ def index(lti=lti): r.json(), ) ) - return redirect( - settings.BASE_URL - + "login/oauth2/auth?client_id=" - + settings.oauth2_id - + "&response_type=code&redirect_uri=" - + settings.oauth2_uri - ) + return redirect(url_for("auth")) r = requests.get( - settings.API_URL + app.config["API_URL"] + "courses/{0}/external_tools?include_parents=true&per_page=100".format( session["course_id"] ), @@ -226,8 +440,8 @@ def index(lti=lti): ) try: - tools_by_category, cagetory_order = filter_tool_list( - session["course_id"], session["api_key"] + tools_by_category, category_order = filter_tool_list( + session["course_id"], api_key ) except CanvasException: app.logger.exception("Couldn't connect to Canvas") @@ -245,7 +459,7 @@ def index(lti=lti): return render_template( "main_template.html", tools_by_category=tools_by_category, - category_order=cagetory_order, + category_order=category_order, course=session["course_id"], ) @@ -261,7 +475,7 @@ def status(): "checks": {"index": False, "xml": False, "db": False, "dev_key": False}, "url": url_for("index", _external=True), "xml_url": url_for("xml", _external=True), - "base_url": settings.BASE_URL, + "base_url": app.config["BASE_URL"], "debug": app.debug, } @@ -269,7 +483,7 @@ def status(): try: response = requests.get(url_for("index", _external=True), verify=False) index_check = ( - response.status_code == 200 and settings.TOOL_TITLE in response.text + response.status_code == 200 and app.config["TOOL_TITLE"] in response.text ) status["checks"]["index"] = index_check except Exception: @@ -286,7 +500,7 @@ def status(): # Check DB connection try: - db.session.query("1").all() + db.session.query(text("1")).all() status["checks"]["db"] = True except Exception: app.logger.exception("DB connection failed.") @@ -295,7 +509,9 @@ def status(): try: response = requests.get( "{}login/oauth2/auth?client_id={}&response_type=code&redirect_uri={}".format( - settings.BASE_URL, settings.oauth2_id, settings.oauth2_uri + app.config["BASE_URL"], + app.config["OAUTH2_ID"], + app.config["OAUTH2_URI"], ) ) status["checks"]["dev_key"] = response.status_code == 200 @@ -318,11 +534,24 @@ def xml(): ) +@app.route("/lticonfig/", methods=["GET"]) +def config(): + domain = urlparse(request.url_root).netloc + return Response( + render_template( + "lti.json", + domain=domain, + url_scheme=app.config["PREFERRED_URL_SCHEME"], + ), + mimetype="application/json", + ) + + # OAuth login # Redirect URI @app.route("/oauthlogin", methods=["POST", "GET"]) -@lti(error=error, request="session", role="staff", app=app) -def oauth_login(lti=lti): +@lti_required(role="staff") +def oauth_login(): code = request.args.get("code") @@ -337,12 +566,12 @@ def oauth_login(lti=lti): payload = { "grant_type": "authorization_code", - "client_id": settings.oauth2_id, - "redirect_uri": settings.oauth2_uri, - "client_secret": settings.oauth2_key, + "client_id": app.config["OAUTH2_ID"], + "redirect_uri": app.config["OAUTH2_URI"], + "client_secret": app.config["OAUTH2_KEY"], "code": code, } - r = requests.post(settings.BASE_URL + "login/oauth2/token", data=payload) + r = requests.post(app.config["BASE_URL"] + "login/oauth2/token", data=payload) try: r.raise_for_status() @@ -357,10 +586,8 @@ def oauth_login(lti=lti): ) if "access_token" in r.json(): - session["api_key"] = r.json()["access_token"] - - if "refresh_token" in r.json(): - session["refresh_token"] = r.json()["refresh_token"] + api_key = r.json()["access_token"] + refresh_token = r.json()["refresh_token"] if "expires_in" in r.json(): # expires in seconds @@ -374,8 +601,9 @@ def oauth_login(lti=lti): if user is not None: try: # update the current user's expiration time in db - user.refresh_key = session["refresh_token"] + user.refresh_key = refresh_token user.expires_in = session["expires_in"] + user.api_key = api_key db.session.add(user) db.session.commit() except Exception: @@ -395,8 +623,9 @@ def oauth_login(lti=lti): # add new user to db new_user = Users( session["canvas_user_id"], - session["refresh_token"], + refresh_token, session["expires_in"], + api_key, ) db.session.add(new_user) db.session.commit() @@ -435,25 +664,28 @@ def refresh_access_token(user): Use a user's refresh token to get a new access token. :rtype: dict - :returns: Dictionary with keys 'access_token' and 'expiration_date'. + :returns: Dictionary with keys 'access_token', 'expiration_date', + and 'refresh_token'. Values will be `None` if refresh fails. """ refresh_token = user.refresh_key payload = { "grant_type": "refresh_token", - "client_id": settings.oauth2_id, - "redirect_uri": settings.oauth2_uri, - "client_secret": settings.oauth2_key, + "client_id": app.config["OAUTH2_ID"], + "redirect_uri": app.config["OAUTH2_URI"], + "client_secret": app.config["OAUTH2_KEY"], "refresh_token": refresh_token, } - response = requests.post(settings.BASE_URL + "login/oauth2/token", data=payload) + response = requests.post( + app.config["BASE_URL"] + "login/oauth2/token", data=payload + ) try: response.raise_for_status() except HTTPError: app.logger.exception("Failed refresh. Probably bad refresh token.") - return {"access_token": None, "expiration_date": None} + return {"access_token": None, "expiration_date": None, "refresh_token": None} try: response_json = response.json() @@ -461,7 +693,7 @@ def refresh_access_token(user): app.logger.exception( "Unable to load JSON response of refresh. Possibly bad refresh token." ) - return {"access_token": None, "expiration_date": None} + return {"access_token": None, "expiration_date": None, "refresh_token": None} if "access_token" not in response_json: app.logger.warning( @@ -473,7 +705,7 @@ def refresh_access_token(user): "Session: {}" ).format(response.url, response.status_code, payload, session) ) - return {"access_token": None, "expiration_date": None} + return {"access_token": None, "expiration_date": None, "refresh_token": None} api_key = response_json["access_token"] app.logger.info("New access token created\n User: {0}".format(user.user_id)) @@ -488,14 +720,17 @@ def refresh_access_token(user): "Session: {}" ).format(response.url, response.status_code, payload, session) ) - return {"access_token": None, "expiration_date": None} + return {"access_token": None, "expiration_date": None, "refresh_token": None} current_time = int(time.time()) new_expiration_date = current_time + response_json["expires_in"] + new_refresh_token = response_json.get("refresh_token") try: - # Update expiration date in db + # Update expiration and rotated refresh token (if provided) in db. user.expires_in = new_expiration_date + if new_refresh_token: + user.refresh_key = new_refresh_token db.session.commit() except Exception: readable_expires_in = time.strftime( @@ -512,17 +747,19 @@ def refresh_access_token(user): "new_expiration_date: {}" ).format(session, readable_expires_in, readable_new_expiration) ) - return {"access_token": None, "expiration_date": None} + return {"access_token": None, "expiration_date": None, "refresh_token": None} - return {"access_token": api_key, "expiration_date": new_expiration_date} + return { + "access_token": api_key, + "expiration_date": new_expiration_date, + "refresh_token": new_refresh_token, + } # Checking the user in the db @app.route("/auth", methods=["POST", "GET"]) -@lti(error=error, request="initial", role="staff", app=app) -def auth(lti=lti): - session["course_id"] = request.form.get("custom_canvas_course_id") - session["canvas_user_id"] = request.form.get("custom_canvas_user_id") +@lti_required(role="staff") +def auth(): # Try to grab the user user = Users.query.filter_by(user_id=int(session["canvas_user_id"])).first() @@ -535,18 +772,18 @@ def auth(lti=lti): ) ) return redirect( - settings.BASE_URL + app.config["BASE_URL"] + "login/oauth2/auth?client_id=" - + settings.oauth2_id + + app.config["OAUTH2_ID"] + "&response_type=code&redirect_uri=" - + settings.oauth2_uri + + app.config["OAUTH2_URI"] ) # Get the expiration date expiration_date = user.expires_in # If expired or no api_key - if int(time.time()) > expiration_date or "api_key" not in session: + if int(time.time()) > expiration_date or not user.api_key: readable_time = time.strftime( "%a, %d %b %Y %H:%M:%S", time.localtime(user.expires_in) ) @@ -560,24 +797,28 @@ def auth(lti=lti): refresh = refresh_access_token(user) if refresh["access_token"] and refresh["expiration_date"]: - session["api_key"] = refresh["access_token"] + user.api_key = refresh["access_token"] + user.expires_in = refresh["expiration_date"] + if refresh["refresh_token"]: + user.refresh_key = refresh["refresh_token"] + db.session.commit() session["expires_in"] = refresh["expiration_date"] return redirect(url_for("index")) else: # Refresh didn't work. Reauthenticate. app.logger.info("Reauthenticating:\nSession: {}".format(session)) return redirect( - settings.BASE_URL + app.config["BASE_URL"] + "login/oauth2/auth?client_id=" - + settings.oauth2_id + + app.config["OAUTH2_ID"] + "&response_type=code&redirect_uri=" - + settings.oauth2_uri + + app.config["OAUTH2_URI"] ) else: # API key that shouldn't be expired. Test it. - auth_header = {"Authorization": "Bearer " + session["api_key"]} + auth_header = {"Authorization": "Bearer " + user.api_key} r = requests.get( - settings.API_URL + "users/%s/profile" % (session["canvas_user_id"]), + app.config["API_URL"] + "users/%s/profile" % (session["canvas_user_id"]), headers=auth_header, ) # check for WWW-Authenticate @@ -586,37 +827,43 @@ def auth(lti=lti): return redirect(url_for("index")) else: # Key is bad. First try to get new one using refresh - new_token = refresh_access_token(user)["access_token"] - - if new_token: - session["api_key"] = new_token + refresh = refresh_access_token(user) + + if refresh["access_token"] and refresh["expiration_date"]: + user.api_key = refresh["access_token"] + user.expires_in = refresh["expiration_date"] + if refresh["refresh_token"]: + user.refresh_key = refresh["refresh_token"] + db.session.commit() + session["expires_in"] = refresh["expiration_date"] return redirect(url_for("index")) else: # Refresh didn't work. Reauthenticate. app.logger.info("Reauthenticating\nSession: {}".format(session)) return redirect( - settings.BASE_URL + app.config["BASE_URL"] + "login/oauth2/auth?client_id=" - + settings.oauth2_id + + app.config["OAUTH2_ID"] + "&response_type=code&redirect_uri=" - + settings.oauth2_uri + + app.config["OAUTH2_URI"] ) @app.route("/get_sessionless_url/<lti_id>/<is_course_nav>") -@lti(error=error, role="staff", app=app) -def get_sessionless_url(lti_id, is_course_nav, lti=lti): +def get_sessionless_url(lti_id, is_course_nav): sessionless_launch_url = None + user = Users.query.filter_by(user_id=int(session["canvas_user_id"])).first() + if is_course_nav == "True": - auth_header = {"Authorization": "Bearer " + session["api_key"]} + auth_header = {"Authorization": "Bearer " + user.api_key} # get sessionless launch url for things that come from course nav url = ( "{0}courses/{1}/external_tools/sessionless_launch?id={2}" "&launch_type=course_navigation" ) r = requests.get( - url.format(settings.API_URL, session["course_id"], lti_id), + url.format(app.config["API_URL"], session["course_id"], lti_id), headers=auth_header, ) if r.status_code >= 400: @@ -637,10 +884,10 @@ def get_sessionless_url(lti_id, is_course_nav, lti=lti): sessionless_launch_url = r.json()["url"] if sessionless_launch_url is None: - auth_header = {"Authorization": "Bearer " + session["api_key"]} + auth_header = {"Authorization": "Bearer " + user.api_key} # get sessionless launch url r = requests.get( - settings.API_URL + app.config["API_URL"] + "courses/{0}/external_tools/sessionless_launch?id={1}".format( session["course_id"], lti_id ), diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/18e37632f9f5_.py b/migrations/versions/18e37632f9f5_.py new file mode 100644 index 0000000..6c05bd0 --- /dev/null +++ b/migrations/versions/18e37632f9f5_.py @@ -0,0 +1,80 @@ +"""empty message + +Revision ID: 18e37632f9f5 +Revises: 38c166803e4e +Create Date: 2024-02-16 21:53:23.203111 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "18e37632f9f5" +down_revision = "38c166803e4e" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "key_set", + sa.Column("id", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "key", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("key_set_id", sa.Integer(), nullable=False), + sa.Column("public_key", sa.Text(), nullable=False), + sa.Column("private_key", sa.Text(), nullable=False), + sa.Column("alg", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["key_set_id"], + ["key_set.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "registration", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("issuer", sa.String(length=255), nullable=False), + sa.Column("client_id", sa.String(length=255), nullable=False), + sa.Column( + "platform_login_auth_endpoint", sa.String(length=255), nullable=False + ), + sa.Column( + "platform_service_auth_endpoint", sa.String(length=255), nullable=False + ), + sa.Column("platform_jwks_endpoint", sa.String(length=255), nullable=False), + sa.Column("key_set_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["key_set_id"], + ["key_set.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("issuer", "client_id"), + ) + op.create_table( + "deployment", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("deployment_id", sa.String(length=255), nullable=False), + sa.Column("registration_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["registration_id"], + ["registration.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("deployment") + op.drop_table("registration") + op.drop_table("key") + op.drop_table("key_set") + # ### end Alembic commands ### diff --git a/migrations/versions/38c166803e4e_.py b/migrations/versions/38c166803e4e_.py new file mode 100644 index 0000000..e694deb --- /dev/null +++ b/migrations/versions/38c166803e4e_.py @@ -0,0 +1,37 @@ +"""empty message + +Revision ID: 38c166803e4e +Revises: +Create Date: 2024-02-16 21:52:47.294151 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "38c166803e4e" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("refresh_key", sa.String(length=255), nullable=True), + sa.Column("expires_in", sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("users") + # ### end Alembic commands ### diff --git a/migrations/versions/a3178965bb30_.py b/migrations/versions/a3178965bb30_.py new file mode 100644 index 0000000..0845b24 --- /dev/null +++ b/migrations/versions/a3178965bb30_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: a3178965bb30 +Revises: 18e37632f9f5 +Create Date: 2024-02-22 16:58:29.745938 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a3178965bb30' +down_revision = '18e37632f9f5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('api_key', sa.String(length=255), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('api_key') + + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index b4f768f..2b42e9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,15 @@ -canvasapi==0.15.0 -Flask==1.1.1 -Flask-SQLAlchemy==2.4.1 -mysqlclient --e git+https://github.com/ucfcdl/pylti.git@roles#egg=PyLTI -requests==2.22.0 -Werkzeug>=1.0.1 # Chrome 80 SameSite fix +Flask==3.0.2 +# -e git+https://github.com/ucfcdl/pylti.git@roles#egg=PyLTI +git+https://github.com/ucfopen/pylti1.3.git@master +requests==2.34.2 +gunicorn==23.0.0 +mysqlclient==2.1.0 +Flask-SQLAlchemy==3.0.2 +Flask-Caching==2.1.0 +Flask-Migrate==4.0.5 +canvasapi==2.2.0 +Werkzeug==3.0.1 +itsdangerous==2.1.2 +Jinja2==3.1.3 +MarkupSafe==2.1.5 +pycryptodome diff --git a/settings.py.template b/settings.py.template deleted file mode 100644 index ce34aac..0000000 --- a/settings.py.template +++ /dev/null @@ -1,63 +0,0 @@ -# Title of the tool. Appears in the <title> element, headers, and configuration XML -TOOL_TITLE = "Faculty Tools" - -# Which theme directory to use. Leave blank for default. -THEME_DIR = "" - -# Canvas instance URL. ex: https://example.instructure.com/ -BASE_URL = "https://example.instructure.com/" -API_URL = BASE_URL + "api/v1/" - -# Secret key to sign Flask sessions with. KEEP THIS SECRET! -secret_key = "" - -# LTI consumer key and shared secret -CONSUMER_KEY = "key" -SHARED_SECRET = "secret" - -# Configuration for pylti library. Uses the above key and secret -PYLTI_CONFIG = { - "consumers": { - CONSUMER_KEY: { - "secret": SHARED_SECRET - } - }, - # Custom configurable roles - "roles": { - "staff": [ - "urn:lti:instrole:ims/lis/Administrator", - "Instructor", - "ContentDeveloper", - "urn:lti:role:ims/lis/TeachingAssistant", - ] - }, -} - -# The "Oauth2 Redirect URI" that you provided to Instructure. -oauth2_uri = "" # ex. 'https://localhost:5000/oauthlogin' -# The Client_ID Instructure gave you -oauth2_id = "" -# The Secret Instructure gave you -oauth2_key = "" - -# Logging configuration -LOG_MAX_BYTES = 1024 * 1024 * 5 # 5 MB -LOG_BACKUP_COUNT = 2 -ERROR_LOG = "logs/faculty-tools.log" - -whitelist = "whitelist.json" - -# Google Analytics Tracking ID (optional) -GOOGLE_ANALYTICS = "" - - -def select_db(x): - return { - "DevelopmentConfig": "sqlite:///test.db", - "Config": "sqlite:///test.db", - "BaseConfig": "sqlite:///test.db", - "TestingConfig": "sqlite:///test.db", - }.get(x, "sqlite:///test2.db") - - -configClass = "config.DevelopmentConfig" diff --git a/templates/lti.json b/templates/lti.json new file mode 100644 index 0000000..a9e3dc1 --- /dev/null +++ b/templates/lti.json @@ -0,0 +1,33 @@ +{ + "title": "Faculty Tools", + "scopes": [], + "extensions": [ + { + "platform": "canvas.instructure.com", + "settings": { + "platform": "canvas.instructure.com", + "placements": [ + { + "text": "Faculty Tools", + "enabled": true, + "placement": "course_navigation", + "visbility": "admins", + "message_type": "LtiResourceLinkRequest", + "target_link_uri": "{{ url_for('launch', _scheme=url_scheme) }}" + } + ] + }, + "domain": "https://{{ domain }}", + "tool_id": "Faculty Tools", + "privacy_level": "public" + } + ], + "description": "A collection of tools and resources to make the online teaching experience great for instructors and students.", + "custom_fields": { + "canvas_user_id": "$Canvas.user.id", + "canvas_course_id": "$Canvas.course.id" + }, + "public_jwk_url": "{{ url_for('get_jwks', _external=True, _scheme=url_scheme) }}", + "target_link_uri": "{{ url_for('launch', _external=True, _scheme=url_scheme) }}", + "oidc_initiation_url": "{{ url_for('login', _external=True, _scheme=url_scheme) }}" +} diff --git a/test_requirements.txt b/test_requirements.txt index 7330fb4..3c15281 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -8,3 +8,4 @@ Flask-Testing>=0.8.0 mock oauthlib requests-mock +isort diff --git a/tests.py b/tests.py index c7c09e4..b6d989d 100644 --- a/tests.py +++ b/tests.py @@ -6,7 +6,7 @@ import canvasapi import oauthlib.oauth1 import flask -from flask import url_for +from flask import Flask, url_for import flask_testing import requests_mock from pylti.common import LTI_SESSION_KEY @@ -14,7 +14,6 @@ from mock import patch, mock_open import lti -import settings import utils @@ -34,11 +33,12 @@ def create_app(self): @classmethod def setUpClass(cls): logging.disable(logging.CRITICAL) - settings.BASE_URL = "https://example.edu/" - settings.oauth2_id = "10000000000001" - settings.oauth2_uri = "oauthlogin" - settings.GOOGLE_ANALYTICS = "123abc" - settings.THEME_DIR = "test_theme" + app = lti.app + app.config["BASE_URL"] = "https://example.edu/" + app.config["OAUTH2_ID"] = "10000000000001" + app.config["OAUTH2_URI"] = "oauthlogin" + app.config["GOOGLE_ANALYTICS"] = "123abc" + app.config["THEME_DIR"] = "test_theme" def setUp(self): with self.app.test_request_context(): @@ -94,8 +94,10 @@ def test_select_theme_dirs(self, m): self.assertEqual(theme_dirs[0], "themes/test_theme/templates") self.assertEqual(theme_dirs[1], "templates") - @patch("settings.THEME_DIR", "") + # @patch('self.app.config["BASE_URL"]', "") def test_select_theme_dirs_no_theme(self, m): + self.app.config["BASE_URL"] = "" + self.app.config["THEME_DIR"] = "" theme_dirs = lti.select_theme_dirs() self.assertIsInstance(theme_dirs, list) @@ -115,6 +117,7 @@ def test__slugify_empty(self, m): @patch("os.listdir") def test_theme_static_files_processor(self, m, mocked_listdir): + self.app.config["THEME_DIR"] = "test_theme" mocked_listdir.return_value = ["file1.css", "file2.js"] files = lti.theme_static_files_processor() @@ -144,8 +147,9 @@ def test_theme_static_files_processor_oserror(self, m, mocked_listdir): self.assertIsInstance(files["theme_static_js"], list) self.assertEqual(len(files["theme_static_js"]), 0) - @patch("settings.THEME_DIR", "") - def test_heme_static_files_processor_no_theme(self, m): + # @patch('app.config["THEME_DIR"]', "") + def test_theme_static_files_processor_no_theme(self, m): + self.app.config["THEME_DIR"] = "" files = lti.theme_static_files_processor() self.assertIsInstance(files, dict) @@ -181,7 +185,7 @@ def test_ga_utility_processor(self, m): self.assertIsInstance(ga, dict) self.assertIn("google_analytics", ga) - self.assertEqual(ga["google_analytics"], settings.GOOGLE_ANALYTICS) + self.assertEqual(ga["google_analytics"], self.app.config["GOOGLE_ANALYTICS"]) # title_utility_processor def test_title_utility_processor(self, m): @@ -189,7 +193,7 @@ def test_title_utility_processor(self, m): self.assertIsInstance(title, dict) self.assertIn("title", title) - self.assertEqual(title["title"], settings.TOOL_TITLE) + self.assertEqual(title["title"], self.app.config["TOOL_TITLE"]) # return_error def test_return_error(self, m): @@ -260,7 +264,9 @@ def test_index_api_key_expired(self, m): self.assert_redirects( response, redirect_url.format( - settings.BASE_URL, settings.oauth2_id, settings.oauth2_uri + self.app.config["BASE_URL"], + self.app.config["OAUTH2_ID"], + self.app.config["OAUTH2_URI"], ), ) @@ -300,7 +306,9 @@ def test_index_api_key_404(self, m): self.assert_redirects( response, redirect_url.format( - settings.BASE_URL, settings.oauth2_id, settings.oauth2_uri + self.app.config["BASE_URL"], + self.app.config["OAUTH2_ID"], + self.app.config["OAUTH2_URI"], ), ) @@ -349,7 +357,7 @@ def test_index_whitelist_error(self, m, filter_tool_list): ], headers={ "Link": '<{}api/v1/courses/1/external_tools?page=2>; rel="next"'.format( - settings.BASE_URL + self.app.config["BASE_URL"] ) }, status_code=200, @@ -394,7 +402,7 @@ def test_index_canvas_error(self, m, filter_tool_list): ], headers={ "Link": '<{}api/v1/courses/1/external_tools?page=2>; rel="next"'.format( - settings.BASE_URL + self.app.config["BASE_URL"] ) }, status_code=200, @@ -432,7 +440,7 @@ def test_index(self, m): ], headers={ "Link": '<{}api/v1/courses/1/external_tools?page=2>; rel="next"'.format( - settings.BASE_URL + self.app.config["BASE_URL"] ) }, status_code=200, @@ -452,8 +460,13 @@ def test_index(self, m): # status def test_status_healthy(self, m): + self.app.config["BASE_URL"] = "https://example.edu/" + m.register_uri( - "GET", "http://localhost/", status_code=200, text=settings.TOOL_TITLE + "GET", + "http://localhost/", + status_code=200, + text=self.app.config["TOOL_TITLE"], ) m.register_uri( "GET", @@ -908,7 +921,9 @@ def test_auth_no_user(self, m): self.assert_redirects( response, redirect_url.format( - settings.BASE_URL, settings.oauth2_id, settings.oauth2_uri + self.app.config["BASE_URL"], + self.app.config["OAUTH2_ID"], + self.app.config["OAUTH2_URI"], ), ) @@ -990,7 +1005,9 @@ def test_auth_no_api_key_refresh_fail(self, m, mock_refresh_access_token): self.assert_redirects( response, redirect_url.format( - settings.BASE_URL, settings.oauth2_id, settings.oauth2_uri + self.app.config["BASE_URL"], + self.app.config["OAUTH2_ID"], + self.app.config["OAUTH2_URI"], ), ) @@ -1088,7 +1105,9 @@ def test_auth_invalid_api_key_refresh_fail(self, m, mock_refresh_access_token): self.assert_redirects( response, redirect_url.format( - settings.BASE_URL, settings.oauth2_id, settings.oauth2_uri + self.app.config["BASE_URL"], + self.app.config["OAUTH2_ID"], + self.app.config["OAUTH2_URI"], ), ) @@ -1238,20 +1257,28 @@ def test_get_sessionless_url_not_course_nav_succeed(self, m): class UtilsTests(unittest.TestCase): + app = Flask("test") + app.config["WHITELIST"] = "whitelist.json" + app.config["BASE_URL"] = "https://example.edu/" + @classmethod def setUpClass(cls): - settings.BASE_URL = "https://example.edu/" - settings.whitelist = "whitelist.json" + app = lti.app + app.config["BASE_URL"] = "https://example.edu/" + app.config["WHITELIST"] = "whitelist.json" + return app def test_filter_tool_list_empty_file(self): - with self.assertRaises(JSONDecodeError): - with patch("builtins.open", mock_open(read_data="")): - utils.filter_tool_list(1, "password") + with self.app.app_context(): + with self.assertRaises(JSONDecodeError): + with patch("builtins.open", mock_open(read_data="")): + utils.filter_tool_list(1, "password") def test_filter_tool_list_empty_data(self): - with self.assertRaisesRegex(ValueError, r"whitelist\.json is empty"): - with patch("builtins.open", mock_open(read_data="{}")): - utils.filter_tool_list(1, "password") + with self.app.app_context(): + with self.assertRaisesRegex(ValueError, r"whitelist\.json is empty"): + with patch("builtins.open", mock_open(read_data="{}")): + utils.filter_tool_list(1, "password") @patch("canvasapi.canvas.Canvas.get_course") @patch("canvasapi.course.Course.get_external_tools") diff --git a/utils.py b/utils.py index aca6e28..f6adfac 100644 --- a/utils.py +++ b/utils.py @@ -4,7 +4,7 @@ from canvasapi import Canvas -import settings +from flask import current_app def get_tool_info(whitelist, tool_name): @@ -37,13 +37,13 @@ def filter_tool_list(course_id, access_token): The values are a list of all installed external tools that are in that category and on the whitelist. """ - with open(settings.whitelist, "r") as wl_file: + with open(current_app.config["WHITELIST"], "r") as wl_file: whitelist = json.loads(wl_file.read()) if not whitelist: raise ValueError("whitelist.json is empty") - canvas = Canvas(settings.BASE_URL, access_token) + canvas = Canvas(current_app.config["BASE_URL"], access_token) course = canvas.get_course(course_id) installed_tools = course.get_external_tools(include_parents=True)