├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── RELEASE.rst ├── docs ├── Makefile └── source │ ├── conf.py │ ├── flask.rst │ ├── index.rst │ ├── pylti_common.rst │ └── pylti_flask.rst ├── pylintrc ├── pylti ├── __init__.py ├── chalice.py ├── common.py ├── flask.py └── tests │ ├── __init__.py │ ├── data │ └── certs │ │ └── snakeoil.pem │ ├── test_chalice.py │ ├── test_chalice_app.py │ ├── test_common.py │ ├── test_flask.py │ ├── test_flask_app.py │ └── util.py ├── pytest.ini ├── requirements.txt ├── setup.cfg ├── setup.py └── test_requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .*.sw* 2 | *.pyc 3 | *.pyo 4 | *.pkl 5 | *~ 6 | .DS_Store 7 | MANIFEST 8 | build 9 | dist 10 | docs/sphinx/_build 11 | docs/epydoc/apidocs 12 | docs/apidocs 13 | docs/html 14 | docs/pdf 15 | src 16 | 17 | # Byte-compiled / optimized / DLL files 18 | __pycache__/ 19 | *.py[cod] 20 | 21 | # C extensions 22 | *.so 23 | 24 | # Distribution / packaging 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | .tox/ 42 | .coverage 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | 47 | # Translations 48 | *.mo 49 | 50 | #PyCharm 51 | .idea 52 | 53 | #Coverage 54 | htmlcov/* 55 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # http://travis-ci.org/#!/mitxlti/pylti 2 | language: python 3 | 4 | python: 5 | - 2.7 6 | - 3.4 7 | - 3.5 8 | - 3.6 9 | install: 10 | - python setup.py install 11 | - pip install -r test_requirements.txt 12 | - pip install coveralls 13 | script: 14 | - python setup.py test --coverage --pep8 --flakes 15 | - coverage run --source=pylti setup.py test 16 | after_success: 17 | - coveralls 18 | 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Massachusetts Institute of Technology 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 11 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include pytest.ini 4 | include requirements.txt 5 | recursive-include pylti/tests/data * 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | PyLTI - LTI done right 2 | ========================= 3 | :PyLTI: Python implementation of LTI 4 | :Author: MIT Office of Digital Learning 5 | :Homepage: http://odl.mit.edu 6 | :License: BSD 7 | 8 | .. image:: https://secure.travis-ci.org/mitodl/pylti.png?branch=develop 9 | :target: https://secure.travis-ci.org/mitodl/pylti 10 | .. image:: https://coveralls.io/repos/mitodl/pylti/badge.png?branch=develop 11 | :target: https://coveralls.io/r/mitodl/pylti?branch=develop 12 | 13 | .. _Documentation: http://pylti.readthedocs.org/en/latest/ 14 | 15 | PyLTI is a Python implementation of the LTI specification [#f1]_. It supports 16 | LTI 1.1.1 and LTI 2.0. While it was written with edX [#f2]_ as its LTI consumer, it 17 | is a complete implementation of the LTI specification and can be used with any 18 | learning management system that supports LTI. 19 | 20 | A feature of PyLTI is the way it is used in the creation of an LTI tool. PyLTI 21 | is written as a library that exposes an API. This separation of concerns 22 | enables a developer to focus on the business logic of their tool and support of 23 | their framework of choice. 24 | 25 | To demonstrate this usage, there are also a collection of example LTI tools 26 | written to support different Python web frameworks. 27 | 28 | ========= ============ 29 | Framework Example 30 | ========= ============ 31 | Flask `mit_lti_flask_sample 32 | `_ 33 | A skeleton example for the Flask framework that consumes the PyLTI library 34 | ========= ============ 35 | 36 | Dependencies: 37 | ============= 38 | * Python 2.7+ or Python 3.4+ 39 | * oauth2 1.9.0+ 40 | * httplib2 0.9+ 41 | * six 1.10.0+ 42 | 43 | Development dependencies: 44 | ========================= 45 | * Flask 0.10.1 46 | * httpretty 0.8.3 47 | * oauthlib 0.6.3 48 | * pyflakes 1.2.3 49 | * pytest 2.9.2 50 | * pytest-cache 1.0 51 | * pytest-cov 2.3.0 52 | * pytest-flakes 1.0.1 53 | * pytest-pep8 1.0.6 54 | * sphinx 1.2.3 55 | 56 | Documentation_ is available on readthedocs. 57 | 58 | Licensing 59 | ========= 60 | PyLTI is licensed under the BSD license, version January 9, 2008. See 61 | license.rst for the full text of the license. 62 | 63 | .. rubric:: Footnotes 64 | 65 | .. [#f1] The Learning Tools Interoperability (LTI) specification is an 66 | initiative of IMS. Their site `http://developers.imsglobal.org/ 67 | `_ contains a description of LTI as well as 68 | the current LTI specification. 69 | .. [#f2] EdX offers interactive online classes and MOOCs from the world’s best 70 | universities. Online courses from MITx, HarvardX, BerkeleyX, UTx and many 71 | other universities. EdX is a non-profit online initiative created by 72 | founding partners Harvard and MIT. `code.edx.org `_ 73 | -------------------------------------------------------------------------------- /RELEASE.rst: -------------------------------------------------------------------------------- 1 | Release Notes 2 | ============= 3 | 4 | Version 0.6.0 5 | ------------- 6 | 7 | - Session variables no longer trump new authentication (#83) 8 | - Update oauthlib to 2.0.6 (#79) 9 | 10 | Version 0.5.1 11 | ------------- 12 | 13 | - Add setup.cfg to allow universal wheel building (py2 + py3) 14 | - Changed the version variable in __init__ 15 | 16 | Version 0.5.0 17 | ------------- 18 | 19 | - Changed Travis configuration to test also python 3 20 | - Python 3 compatibility (keeping Python 2 compat) 21 | - Other fix to readme 22 | - Fixed Readme 23 | - document correct exception thrown by flask _check_role() 24 | - fix typos in flask post_grade docs 25 | 26 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PyLTI.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PyLTI.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/PyLTI" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PyLTI" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # PyLTI documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Nov 3 11:17:08 2014. 5 | # 6 | 7 | import sys 8 | import os 9 | 10 | extensions = [ 11 | 'sphinx.ext.autodoc', 12 | 'sphinx.ext.doctest', 13 | 'sphinx.ext.coverage', 14 | ] 15 | 16 | templates_path = ['_templates'] 17 | 18 | source_suffix = '.rst' 19 | 20 | # The master toctree document. 21 | master_doc = 'index' 22 | 23 | # General information about the project. 24 | project = u'PyLTI' 25 | copyright = u'2014, Massachusetts Institute of Technology' 26 | 27 | static_mod = os.path.join('..','..', 'pylti', '__init__.py') 28 | execfile(static_mod) 29 | version = VERSION 30 | release = VERSION 31 | 32 | exclude_patterns = [] 33 | 34 | pygments_style = 'sphinx' 35 | 36 | html_theme = 'sphinxdoc' 37 | 38 | html_static_path = ['_static'] 39 | 40 | htmlhelp_basename = 'PyLTIdoc' 41 | 42 | 43 | latex_elements = { 44 | } 45 | 46 | latex_documents = [ 47 | ('index', 'PyLTI.tex', u'PyLTI Documentation', 48 | u'Ivica Ceraj (ODL Engineering)', 'manual'), 49 | ] 50 | 51 | man_pages = [ 52 | ('index', 'pylti', u'PyLTI Documentation', 53 | [u'Ivica Ceraj (ODL Engineering)'], 1) 54 | ] 55 | 56 | texinfo_documents = [ 57 | ('index', 'PyLTI', u'PyLTI Documentation', 58 | u'Ivica Ceraj (ODL Engineering)', 'PyLTI', 'Python LTI decorators.', 59 | 'Miscellaneous'), 60 | ] 61 | 62 | -------------------------------------------------------------------------------- /docs/source/flask.rst: -------------------------------------------------------------------------------- 1 | Getting started with PyLTI using Flask 2 | ====================================== 3 | 4 | PyLTI provides an authorization decorator for Flask requests. 5 | 6 | To use ``pylti`` you will need to import the ``pylti`` Flask decorator, and create a Flask application. 7 | 8 | .. code-block:: python 9 | 10 | from pylti.flask import lti 11 | 12 | app = Flask(__name__) 13 | 14 | Next let's look at how we can protect the landing page using the ``pylti`` decorator. Note two things: 15 | * The *@lti* decorator protects the route 16 | * The route takes a named argument *lti* which interacts with an LTI consumer 17 | *lti* object is an instance of :py:class:`pylti.flask.LTI` . 18 | 19 | .. code-block:: python 20 | 21 | @app.route("/any") 22 | @lti(error=error, request='any', app=app) 23 | def any_route(lti): 24 | """ 25 | In this example route /any is protected and initial or subsequent calls 26 | to the URL will succeed. As you can see lti passed one keyword parameter 27 | lti object that can be used to inspect LTI session. 28 | 29 | :param: lti: `lti` object 30 | :return: string "html to return" 31 | """ 32 | return "Landing page" 33 | 34 | 35 | You may have different needs; maybe you want your landing page available only for the initial request. 36 | For access on the initial requests only, use *request='initial'* as an argument to the decorator. 37 | 38 | .. code-block:: python 39 | 40 | @app.route("/initial") 41 | @lti(error=error, request='initial', app=app) 42 | def initial_route(lti): 43 | """ 44 | access route with 'initial' request only, subsequent requests are not allowed. 45 | 46 | :param: lti: `lti` object 47 | :return: string "Initial request" 48 | """ 49 | return "Initial request" 50 | 51 | You may want some pages available only after the initial page is visited. 52 | To allow only subsequent requests to be accessible use *request='session'* as an argument to the decorator. 53 | 54 | .. code-block:: python 55 | 56 | @app.route("/session") 57 | @lti(error=error, request='session', app=app) 58 | def session_route(lti): 59 | """ 60 | access route with 'session' request 61 | 62 | :param: lti: `lti` object 63 | :return: string "Session request" 64 | """ 65 | return "Session request" 66 | 67 | 68 | Often times in your LTI Tool Provider, some pages are accessible only by administrators. 69 | To protect those pages you can use *role* attribute. 70 | 71 | .. code-block:: python 72 | 73 | @app.route("/initial_staff", methods=['GET', 'POST']) 74 | @lti(error=error, request='initial', role='staff', app=app) 75 | def initial_staff_route(lti): 76 | """ 77 | access route with 'initial' request and 'staff' role 78 | 79 | :param: lti: `lti` object 80 | :return: string "hi" 81 | """ 82 | return "Staff page" 83 | 84 | *@lti* has a number of arguments. The required arguments 85 | are *app*, *error* and *request*. The *role* argument is optional. 86 | Argument *app* is the Flask application. Argument *error* is the function that gets called 87 | if access is denied, or decorator fails for any other reason. *request* has already been 88 | explained, and determines which type of LTI requests are allowed. 89 | The *role* argument is optional. Mapping between pylti roles and the roles defined in the LTI 90 | standard is described by *pylti.common.LTI_ROLES*. 91 | 92 | .. code-block:: python 93 | 94 | def error(exception): 95 | """ 96 | Error receives one argument - exception 97 | exception is a dictionary with the following keys: 98 | exception['exception'] = lti_exception 99 | exception['kwargs'] = kwargs - keyword arguments passed to the route 100 | exception['args'] = args - positional arguments passed to teh route 101 | 102 | :param: exception: `exception` object 103 | :return: string "HTML in case of exception" 104 | """ 105 | app_exception.set(exception) 106 | return "HTML to return" 107 | 108 | 109 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. PyLTI documentation master file, created by 2 | sphinx-quickstart on Mon Nov 3 11:17:08 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to PyLTI's documentation! 7 | ================================= 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | flask.rst 13 | pylti_common.rst 14 | pylti_flask.rst 15 | 16 | Indices and tables 17 | ================== 18 | 19 | * :ref:`genindex` 20 | * :ref:`modindex` 21 | * :ref:`search` 22 | 23 | Getting Started 24 | ================== 25 | 26 | 27 | Changes 28 | ======= 29 | 30 | v0.4.1 31 | ~~~~~~ 32 | 33 | - Pin mock to allow ``python setup.py test`` without setuptools upgrade 34 | 35 | v0.4.0 36 | ~~~~~~ 37 | 38 | - Support for role lists (Sakai 10+) thanks to `@rickyrem `_ 39 | - Added several files to distribution (including test data) thanks 40 | to @jtriley and `@layus `_ 41 | - Flask app is no longer required to be passed around as it uses 42 | ``current_app`` 43 | - Decorators no longer require parameters, i.e. ``@lti`` can be used, 44 | which defaults to allowing any role access 45 | - Coursera support was added via the ``Learner`` role thanks to 46 | `@caioteixeira `_ 47 | - Choosing to allow ``any`` role, actually works with any role now. 48 | It used to require the role to be in a set of known roles thanks 49 | to `@velochy `_ 50 | - Proper URL checking for https was fixed thanks to `@velochy `_ 51 | -------------------------------------------------------------------------------- /docs/source/pylti_common.rst: -------------------------------------------------------------------------------- 1 | pylti.common package 2 | ===================================== 3 | 4 | .. autodata:: pylti.common.LTI_REQUEST_TYPE 5 | 6 | .. automodule:: pylti.common 7 | :members: 8 | 9 | -------------------------------------------------------------------------------- /docs/source/pylti_flask.rst: -------------------------------------------------------------------------------- 1 | pylti.flask package 2 | ===================================== 3 | 4 | .. automodule:: pylti.flask 5 | :members: 6 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | 3 | disable = 4 | locally-disabled, 5 | duplicate-code 6 | 7 | [BASIC] 8 | 9 | function-rgx=[a-z_][a-z0-9_]{2,60}$ 10 | 11 | # Regular expression which should only match correct method names 12 | method-rgx=[a-z_][a-z0-9_]{2,70}$ 13 | -------------------------------------------------------------------------------- /pylti/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | PyLTI is module that implements IMS LTI in python 4 | The API uses decorators to wrap function with LTI functionality. 5 | """ 6 | __version__ = '0.6.0' 7 | -------------------------------------------------------------------------------- /pylti/chalice.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | PyLTI decorator implementation for chalice framework 4 | """ 5 | from __future__ import absolute_import 6 | from functools import wraps 7 | import logging 8 | import os 9 | from chalice import Chalice 10 | 11 | try: 12 | from urllib.parse import parse_qs 13 | except ImportError: 14 | from urlparse import parse_qs 15 | 16 | try: 17 | from urllib.parse import urlunparse 18 | except ImportError: 19 | from urlparse import urlunparse 20 | 21 | from .common import ( 22 | LTI_SESSION_KEY, 23 | LTI_PROPERTY_LIST, 24 | verify_request_common, 25 | default_error, 26 | LTIException, 27 | LTIBase 28 | ) 29 | 30 | logging.basicConfig() 31 | log = logging.getLogger('pylti.chalice') # pylint: disable=invalid-name 32 | 33 | 34 | class LTI(LTIBase): 35 | """ 36 | LTI Object represents abstraction of current LTI session. It provides 37 | callback methods and methods that allow developer to inspect 38 | LTI basic-launch-request. 39 | 40 | This object is instantiated by @lti wrapper. 41 | """ 42 | 43 | def __init__(self, lti_args, lti_kwargs): 44 | # Chalice does not support sessions. Yet, we want the experiance 45 | # to be the same as Flask. Therefore, use a simple dictionary 46 | # to keep session variables for the length of this request. 47 | self.session = {} 48 | LTIBase.__init__(self, lti_args, lti_kwargs) 49 | 50 | def _consumers(self): 51 | """ 52 | Gets consumers from Lambda environment variables prefixed with 53 | CONSUMER_KEY_SECRET_. For example, given a consumer key of foo 54 | and a shared secret of bar, you should have an environment 55 | variable CONSUMER_KEY_SECRET_foo=bar. 56 | 57 | :return: consumers map 58 | :raises: LTIException if environment variables are not found 59 | """ 60 | consumers = {} 61 | for env in os.environ: 62 | if env.startswith('CONSUMER_KEY_SECRET_'): 63 | key = env[20:] # Strip off the CONSUMER_KEY_SECRET_ prefix 64 | # TODO: remove below after live test 65 | # consumers[key] = {"secret": os.environ[env], "cert": 'NA'} 66 | consumers[key] = {"secret": os.environ[env], "cert": None} 67 | if not consumers: 68 | raise LTIException("No consumers found. Chalice stores " 69 | "consumers in Lambda environment variables. " 70 | "Have you created the environment variables?") 71 | return consumers 72 | 73 | def verify_request(self): 74 | """ 75 | Verify LTI request 76 | 77 | :raises: LTIException if request validation failed 78 | """ 79 | request = self.lti_kwargs['app'].current_request 80 | if request.method == 'POST': 81 | # Chalice expects JSON and does not nativly support forms data in 82 | # a post body. The below is copied from the parsing of query 83 | # strings as implimented in match_route of Chalice local.py 84 | parsed_url = request.raw_body.decode() 85 | parsed_qs = parse_qs(parsed_url, keep_blank_values=True) 86 | params = {k: v[0] for k, v in parsed_qs .items()} 87 | else: 88 | params = request.query_params 89 | log.debug(params) 90 | log.debug('verify_request?') 91 | try: 92 | # Chalice does not have a url property therefore building it. 93 | protocol = request.headers.get('x-forwarded-proto', 'http') 94 | hostname = request.headers['host'] 95 | path = request.context['path'] 96 | url = urlunparse((protocol, hostname, path, "", "", "")) 97 | verify_request_common(self._consumers(), url, 98 | request.method, request.headers, 99 | params) 100 | log.debug('verify_request success') 101 | 102 | # All good to go, store all of the LTI params into a 103 | # session dict for use in views 104 | for prop in LTI_PROPERTY_LIST: 105 | if params.get(prop, None): 106 | log.debug("params %s=%s", prop, params.get(prop, None)) 107 | self.session[prop] = params[prop] 108 | 109 | # Set logged in session key 110 | self.session[LTI_SESSION_KEY] = True 111 | return True 112 | except LTIException: 113 | log.debug('verify_request failed') 114 | for prop in LTI_PROPERTY_LIST: 115 | if self.session.get(prop, None): 116 | del self.session[prop] 117 | 118 | self.session[LTI_SESSION_KEY] = False 119 | raise 120 | 121 | @property 122 | def response_url(self): 123 | """ 124 | Returns remapped lis_outcome_service_url 125 | uses PYLTI_URL_FIX map to support edX dev-stack 126 | 127 | :return: remapped lis_outcome_service_url 128 | """ 129 | url = "" 130 | url = self.session['lis_outcome_service_url'] 131 | # TODO: Remove this section if not needed 132 | # app_config = self.config 133 | # urls = app_config.get('PYLTI_URL_FIX', dict()) 134 | # # url remapping is useful for using devstack 135 | # # devstack reports httpS://localhost:8000/ and listens on HTTP 136 | # for prefix, mapping in urls.items(): 137 | # if url.startswith(prefix): 138 | # for _from, _to in mapping.items(): 139 | # url = url.replace(_from, _to) 140 | return url 141 | 142 | def _verify_any(self): 143 | """ 144 | Verify that request is in session or initial request 145 | 146 | :raises: LTIException 147 | """ 148 | raise LTIException("The Request Type any is not " 149 | "supported because Chalice does not support " 150 | "session state. Change the Request Type to " 151 | "initial or omit it from the declaration.") 152 | 153 | @staticmethod 154 | def _verify_session(): 155 | """ 156 | Verify that session was already created 157 | 158 | :raises: LTIException 159 | """ 160 | raise LTIException("The Request Type session is not " 161 | "supported because Chalice does not support " 162 | "session state. Change the Request Type to " 163 | "initial or omit it from the declaration.") 164 | 165 | @staticmethod 166 | def close_session(): 167 | """ 168 | Invalidates session 169 | :raises: LTIException 170 | """ 171 | raise LTIException("Can not close session. Chalice does " 172 | "not support session state.") 173 | 174 | 175 | def lti(app, request='initial', error=default_error, role='any', 176 | *lti_args, **lti_kwargs): 177 | """ 178 | LTI decorator 179 | :param: app - Chalice App object. 180 | :param: error - Callback if LTI throws exception (optional). 181 | :param: request - Request type from 182 | :py:attr:`pylti.common.LTI_REQUEST_TYPE`. (default: any) 183 | :param: roles - LTI Role (default: any) 184 | :return: wrapper 185 | """ 186 | def _lti(function): 187 | """ 188 | Inner LTI decorator 189 | :param: function: 190 | :return: 191 | """ 192 | 193 | @wraps(function) 194 | def wrapper(*args, **kwargs): 195 | """ 196 | Pass LTI reference to function or return error. 197 | """ 198 | try: 199 | the_lti = LTI(lti_args, lti_kwargs) 200 | the_lti.verify() 201 | the_lti._check_role() # pylint: disable=protected-access 202 | kwargs['lti'] = the_lti 203 | return function(*args, **kwargs) 204 | except LTIException as lti_exception: 205 | error = lti_kwargs.get('error') 206 | exception = dict() 207 | exception['exception'] = lti_exception 208 | exception['kwargs'] = kwargs 209 | exception['args'] = args 210 | return error(exception=exception) 211 | 212 | return wrapper 213 | 214 | lti_kwargs['request'] = request 215 | lti_kwargs['error'] = error 216 | lti_kwargs['role'] = role 217 | 218 | if (not app) or isinstance(app, Chalice): 219 | lti_kwargs['app'] = app 220 | return _lti 221 | else: 222 | # We are wrapping without arguments 223 | lti_kwargs['app'] = None 224 | return _lti(app) 225 | -------------------------------------------------------------------------------- /pylti/common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Common classes and methods for PyLTI module 4 | """ 5 | 6 | from __future__ import absolute_import 7 | 8 | import logging 9 | import json 10 | import oauth2 11 | from xml.etree import ElementTree as etree 12 | 13 | from oauth2 import STRING_TYPES 14 | from six.moves.urllib.parse import urlparse, urlencode 15 | 16 | log = logging.getLogger('pylti.common') # pylint: disable=invalid-name 17 | 18 | LTI_PROPERTY_LIST = [ 19 | 'oauth_consumer_key', 20 | 'launch_presentation_return_url', 21 | 'user_id', 22 | 'oauth_nonce', 23 | 'context_label', 24 | 'context_id', 25 | 'resource_link_title', 26 | 'resource_link_id', 27 | 'lis_person_contact_email_primary', 28 | 'lis_person_contact_emailprimary', 29 | 'lis_person_name_full', 30 | 'lis_person_name_family', 31 | 'lis_person_name_given', 32 | 'lis_result_sourcedid', 33 | 'lis_person_sourcedid', 34 | 'launch_type', 35 | 'lti_message', 36 | 'lti_version', 37 | 'roles', 38 | 'lis_outcome_service_url' 39 | ] 40 | 41 | 42 | LTI_ROLES = { 43 | u'staff': [u'Administrator', u'Instructor', ], 44 | u'instructor': [u'Instructor', ], 45 | u'administrator': [u'Administrator', ], 46 | u'student': [u'Student', u'Learner', ] 47 | # There is also a special role u'any' that ignores role check 48 | } 49 | 50 | LTI_SESSION_KEY = u'lti_authenticated' 51 | 52 | LTI_REQUEST_TYPE = [u'any', u'initial', u'session'] 53 | 54 | 55 | def default_error(exception=None): 56 | """Render simple error page. This should be overidden in applications.""" 57 | # pylint: disable=unused-argument 58 | log.exception("There was an LTI communication error") 59 | return "There was an LTI communication error", 500 60 | 61 | 62 | class LTIOAuthServer(oauth2.Server): 63 | """ 64 | Largely taken from reference implementation 65 | for app engine at https://code.google.com/p/ims-dev/ 66 | """ 67 | 68 | def __init__(self, consumers, signature_methods=None): 69 | """ 70 | Create OAuth server 71 | """ 72 | super(LTIOAuthServer, self).__init__(signature_methods) 73 | self.consumers = consumers 74 | 75 | def lookup_consumer(self, key): 76 | """ 77 | Search through keys 78 | """ 79 | if not self.consumers: 80 | log.critical(("No consumers defined in settings." 81 | "Have you created a configuration file?")) 82 | return None 83 | 84 | consumer = self.consumers.get(key) 85 | if not consumer: 86 | log.info("Did not find consumer, using key: %s ", key) 87 | return None 88 | 89 | secret = consumer.get('secret', None) 90 | if not secret: 91 | log.critical(('Consumer %s, is missing secret' 92 | 'in settings file, and needs correction.'), key) 93 | return None 94 | return oauth2.Consumer(key, secret) 95 | 96 | def lookup_cert(self, key): 97 | """ 98 | Search through keys 99 | """ 100 | if not self.consumers: 101 | log.critical(("No consumers defined in settings." 102 | "Have you created a configuration file?")) 103 | return None 104 | 105 | consumer = self.consumers.get(key) 106 | if not consumer: 107 | log.info("Did not find consumer, using key: %s ", key) 108 | return None 109 | cert = consumer.get('cert', None) 110 | return cert 111 | 112 | 113 | class LTIException(Exception): 114 | """ 115 | Custom LTI exception for proper handling 116 | of LTI specific errors 117 | """ 118 | pass 119 | 120 | 121 | class LTINotInSessionException(LTIException): 122 | """ 123 | Custom LTI exception for proper handling 124 | of LTI specific errors 125 | """ 126 | pass 127 | 128 | 129 | class LTIRoleException(LTIException): 130 | """ 131 | Exception class for when LTI user doesn't have the 132 | right role. 133 | """ 134 | pass 135 | 136 | 137 | class LTIPostMessageException(LTIException): 138 | """ 139 | Exception class for when LTI user doesn't have the 140 | right role. 141 | """ 142 | pass 143 | 144 | 145 | def _post_patched_request(consumers, lti_key, body, 146 | url, method, content_type): 147 | """ 148 | Authorization header needs to be capitalized for some LTI clients 149 | this function ensures that header is capitalized 150 | 151 | :param body: body of the call 152 | :param client: OAuth Client 153 | :param url: outcome url 154 | :return: response 155 | """ 156 | # pylint: disable=too-many-locals, too-many-arguments 157 | oauth_server = LTIOAuthServer(consumers) 158 | oauth_server.add_signature_method(SignatureMethod_HMAC_SHA1_Unicode()) 159 | lti_consumer = oauth_server.lookup_consumer(lti_key) 160 | lti_cert = oauth_server.lookup_cert(lti_key) 161 | secret = lti_consumer.secret 162 | 163 | consumer = oauth2.Consumer(key=lti_key, secret=secret) 164 | client = oauth2.Client(consumer) 165 | 166 | if lti_cert: 167 | client.add_certificate(key=lti_cert, cert=lti_cert, domain='') 168 | log.debug("cert %s", lti_cert) 169 | 170 | import httplib2 171 | 172 | http = httplib2.Http 173 | # pylint: disable=protected-access 174 | normalize = http._normalize_headers 175 | 176 | def my_normalize(self, headers): 177 | """ This function patches Authorization header """ 178 | ret = normalize(self, headers) 179 | if 'authorization' in ret: 180 | ret['Authorization'] = ret.pop('authorization') 181 | log.debug("headers") 182 | log.debug(headers) 183 | return ret 184 | 185 | http._normalize_headers = my_normalize 186 | monkey_patch_function = normalize 187 | response, content = client.request( 188 | url, 189 | method, 190 | body=body.encode('utf-8'), 191 | headers={'Content-Type': content_type}) 192 | 193 | http = httplib2.Http 194 | # pylint: disable=protected-access 195 | http._normalize_headers = monkey_patch_function 196 | 197 | log.debug("key %s", lti_key) 198 | log.debug("secret %s", secret) 199 | log.debug("url %s", url) 200 | log.debug("response %s", response) 201 | log.debug("content %s", format(content)) 202 | 203 | return response, content 204 | 205 | 206 | def post_message(consumers, lti_key, url, body): 207 | """ 208 | Posts a signed message to LTI consumer 209 | 210 | :param consumers: consumers from config 211 | :param lti_key: key to find appropriate consumer 212 | :param url: post url 213 | :param body: xml body 214 | :return: success 215 | """ 216 | content_type = 'application/xml' 217 | method = 'POST' 218 | (_, content) = _post_patched_request( 219 | consumers, 220 | lti_key, 221 | body, 222 | url, 223 | method, 224 | content_type, 225 | ) 226 | 227 | is_success = b"success" in content 228 | log.debug("is success %s", is_success) 229 | return is_success 230 | 231 | 232 | def post_message2(consumers, lti_key, url, body, 233 | method='POST', content_type='application/xml'): 234 | """ 235 | Posts a signed message to LTI consumer using LTI 2.0 format 236 | 237 | :param: consumers: consumers from config 238 | :param: lti_key: key to find appropriate consumer 239 | :param: url: post url 240 | :param: body: xml body 241 | :return: success 242 | """ 243 | # pylint: disable=too-many-arguments 244 | (response, _) = _post_patched_request( 245 | consumers, 246 | lti_key, 247 | body, 248 | url, 249 | method, 250 | content_type, 251 | ) 252 | 253 | is_success = response.status == 200 254 | log.debug("is success %s", is_success) 255 | 256 | return is_success 257 | 258 | 259 | def verify_request_common(consumers, url, method, headers, params): 260 | """ 261 | Verifies that request is valid 262 | 263 | :param consumers: consumers from config file 264 | :param url: request url 265 | :param method: request method 266 | :param headers: request headers 267 | :param params: request params 268 | :return: is request valid 269 | """ 270 | log.debug("consumers %s", consumers) 271 | log.debug("url %s", url) 272 | log.debug("method %s", method) 273 | log.debug("headers %s", headers) 274 | log.debug("params %s", params) 275 | 276 | oauth_server = LTIOAuthServer(consumers) 277 | oauth_server.add_signature_method( 278 | SignatureMethod_PLAINTEXT_Unicode()) 279 | oauth_server.add_signature_method( 280 | SignatureMethod_HMAC_SHA1_Unicode()) 281 | 282 | # Check header for SSL before selecting the url 283 | if ( 284 | headers.get( 285 | "X-Forwarded-Proto", 286 | headers.get("HTTP_X_FORWARDED_PROTO", "http"), 287 | ) == "https" 288 | ): 289 | url = url.replace('http:', 'https:', 1) 290 | 291 | oauth_request = Request_Fix_Duplicate.from_request( 292 | method, 293 | url, 294 | headers=dict(headers), 295 | parameters=params 296 | ) 297 | if not oauth_request: 298 | log.info('Received non oauth request on oauth protected page') 299 | raise LTIException('This page requires a valid oauth session ' 300 | 'or request') 301 | try: 302 | # pylint: disable=protected-access 303 | oauth_consumer_key = oauth_request.get_parameter('oauth_consumer_key') 304 | consumer = oauth_server.lookup_consumer(oauth_consumer_key) 305 | if not consumer: 306 | raise oauth2.Error('Invalid consumer.') 307 | oauth_server.verify_request(oauth_request, consumer, None) 308 | except oauth2.Error: 309 | # Rethrow our own for nice error handling (don't print 310 | # error message as it will contain the key 311 | raise LTIException("OAuth error: Please check your key and secret") 312 | 313 | return True 314 | 315 | 316 | def generate_request_xml(message_identifier_id, operation, 317 | lis_result_sourcedid, score): 318 | # pylint: disable=too-many-locals 319 | """ 320 | Generates LTI 1.1 XML for posting result to LTI consumer. 321 | 322 | :param message_identifier_id: 323 | :param operation: 324 | :param lis_result_sourcedid: 325 | :param score: 326 | :return: XML string 327 | """ 328 | root = etree.Element(u'imsx_POXEnvelopeRequest', 329 | xmlns=u'http://www.imsglobal.org/services/' 330 | u'ltiv1p1/xsd/imsoms_v1p0') 331 | 332 | header = etree.SubElement(root, 'imsx_POXHeader') 333 | header_info = etree.SubElement(header, 'imsx_POXRequestHeaderInfo') 334 | version = etree.SubElement(header_info, 'imsx_version') 335 | version.text = 'V1.0' 336 | message_identifier = etree.SubElement(header_info, 337 | 'imsx_messageIdentifier') 338 | message_identifier.text = message_identifier_id 339 | body = etree.SubElement(root, 'imsx_POXBody') 340 | xml_request = etree.SubElement(body, '%s%s' % (operation, 'Request')) 341 | record = etree.SubElement(xml_request, 'resultRecord') 342 | 343 | guid = etree.SubElement(record, 'sourcedGUID') 344 | 345 | sourcedid = etree.SubElement(guid, 'sourcedId') 346 | sourcedid.text = lis_result_sourcedid 347 | if score is not None: 348 | result = etree.SubElement(record, 'result') 349 | result_score = etree.SubElement(result, 'resultScore') 350 | language = etree.SubElement(result_score, 'language') 351 | language.text = 'en' 352 | text_string = etree.SubElement(result_score, 'textString') 353 | text_string.text = score.__str__() 354 | ret = "\n{}".format( 355 | etree.tostring(root, encoding='utf-8').decode('utf-8')) 356 | 357 | log.debug("XML Response: \n%s", ret) 358 | return ret 359 | 360 | 361 | class SignatureMethod_HMAC_SHA1_Unicode(oauth2.SignatureMethod_HMAC_SHA1): 362 | """ 363 | Temporary workaround for 364 | https://github.com/joestump/python-oauth2/issues/207 365 | 366 | Original code is Copyright (c) 2007 Leah Culver, MIT license. 367 | """ 368 | 369 | def check(self, request, consumer, token, signature): 370 | """ 371 | Returns whether the given signature is the correct signature for 372 | the given consumer and token signing the given request. 373 | """ 374 | built = self.sign(request, consumer, token) 375 | if isinstance(signature, STRING_TYPES): 376 | signature = signature.encode("utf8") 377 | return built == signature 378 | 379 | 380 | class SignatureMethod_PLAINTEXT_Unicode(oauth2.SignatureMethod_PLAINTEXT): 381 | """ 382 | Temporary workaround for 383 | https://github.com/joestump/python-oauth2/issues/207 384 | 385 | Original code is Copyright (c) 2007 Leah Culver, MIT license. 386 | """ 387 | 388 | def check(self, request, consumer, token, signature): 389 | """ 390 | Returns whether the given signature is the correct signature for 391 | the given consumer and token signing the given request. 392 | """ 393 | built = self.sign(request, consumer, token) 394 | if isinstance(signature, STRING_TYPES): 395 | signature = signature.encode("utf8") 396 | return built == signature 397 | 398 | 399 | class Request_Fix_Duplicate(oauth2.Request): 400 | """ 401 | Temporary workaround for 402 | https://github.com/joestump/python-oauth2/pull/197 403 | 404 | Original code is Copyright (c) 2007 Leah Culver, MIT license. 405 | """ 406 | 407 | def get_normalized_parameters(self): 408 | """ 409 | Return a string that contains the parameters that must be signed. 410 | """ 411 | items = [] 412 | for key, value in self.items(): 413 | if key == 'oauth_signature': 414 | continue 415 | # 1.0a/9.1.1 states that kvp must be sorted by key, then by value, 416 | # so we unpack sequence values into multiple items for sorting. 417 | if isinstance(value, STRING_TYPES): 418 | items.append( 419 | (oauth2.to_utf8_if_string(key), oauth2.to_utf8(value)) 420 | ) 421 | else: 422 | try: 423 | value = list(value) 424 | except TypeError as e: 425 | assert 'is not iterable' in str(e) 426 | items.append( 427 | (oauth2.to_utf8_if_string(key), 428 | oauth2.to_utf8_if_string(value)) 429 | ) 430 | else: 431 | items.extend( 432 | (oauth2.to_utf8_if_string(key), 433 | oauth2.to_utf8_if_string(item)) 434 | for item in value 435 | ) 436 | 437 | # Include any query string parameters from the provided URL 438 | query = urlparse(self.url)[4] 439 | url_items = self._split_url_string(query).items() 440 | url_items = [ 441 | (oauth2.to_utf8(k), oauth2.to_utf8_optional_iterator(v)) 442 | for k, v in url_items if k != 'oauth_signature' 443 | ] 444 | 445 | # Merge together URL and POST parameters. 446 | # Eliminates parameters duplicated between URL and POST. 447 | items_dict = {} 448 | for k, v in items: 449 | items_dict.setdefault(k, []).append(v) 450 | for k, v in url_items: 451 | if not (k in items_dict and v in items_dict[k]): 452 | items.append((k, v)) 453 | 454 | items.sort() 455 | 456 | encoded_str = urlencode(items, True) 457 | # Encode signature parameters per Oauth Core 1.0 protocol 458 | # spec draft 7, section 3.6 459 | # (http://tools.ietf.org/html/draft-hammer-oauth-07#section-3.6) 460 | # Spaces must be encoded with "%20" instead of "+" 461 | return encoded_str.replace('+', '%20').replace('%7E', '~') 462 | 463 | 464 | class LTIBase(object): 465 | """ 466 | LTI Object represents abstraction of current LTI session. It provides 467 | callback methods and methods that allow developer to inspect 468 | LTI basic-launch-request. 469 | 470 | This object is instantiated by @lti wrapper. 471 | """ 472 | def __init__(self, lti_args, lti_kwargs): 473 | self.lti_args = lti_args 474 | self.lti_kwargs = lti_kwargs 475 | self.nickname = self.name 476 | 477 | @property 478 | def name(self): # pylint: disable=no-self-use 479 | """ 480 | Name returns user's name or user's email or user_id 481 | :return: best guess of name to use to greet user 482 | """ 483 | if 'lis_person_sourcedid' in self.session: 484 | return self.session['lis_person_sourcedid'] 485 | elif 'lis_person_contact_email_primary' in self.session: 486 | return self.session['lis_person_contact_email_primary'] 487 | elif 'user_id' in self.session: 488 | return self.session['user_id'] 489 | else: 490 | return '' 491 | 492 | def verify(self): 493 | """ 494 | Verify if LTI request is valid, validation 495 | depends on @lti wrapper arguments 496 | 497 | :raises: LTIException 498 | """ 499 | log.debug('verify request=%s', self.lti_kwargs.get('request')) 500 | if self.lti_kwargs.get('request') == 'session': 501 | self._verify_session() 502 | elif self.lti_kwargs.get('request') == 'initial': 503 | self.verify_request() 504 | elif self.lti_kwargs.get('request') == 'any': 505 | self._verify_any() 506 | else: 507 | raise LTIException("Unknown request type") 508 | return True 509 | 510 | @property 511 | def user_id(self): # pylint: disable=no-self-use 512 | """ 513 | Returns user_id as provided by LTI 514 | 515 | :return: user_id 516 | """ 517 | return self.session['user_id'] 518 | 519 | @property 520 | def key(self): # pylint: disable=no-self-use 521 | """ 522 | OAuth Consumer Key 523 | :return: key 524 | """ 525 | return self.session['oauth_consumer_key'] 526 | 527 | @staticmethod 528 | def message_identifier_id(): 529 | """ 530 | Message identifier to use for XML callback 531 | 532 | :return: non-empty string 533 | """ 534 | return "edX_fix" 535 | 536 | @property 537 | def lis_result_sourcedid(self): # pylint: disable=no-self-use 538 | """ 539 | lis_result_sourcedid to use for XML callback 540 | 541 | :return: LTI lis_result_sourcedid 542 | """ 543 | return self.session['lis_result_sourcedid'] 544 | 545 | @property 546 | def role(self): # pylint: disable=no-self-use 547 | """ 548 | LTI roles 549 | 550 | :return: roles 551 | """ 552 | return self.session.get('roles') 553 | 554 | @staticmethod 555 | def is_role(self, role): 556 | """ 557 | Verify if user is in role 558 | 559 | :param: role: role to verify against 560 | :return: if user is in role 561 | :exception: LTIException if role is unknown 562 | """ 563 | log.debug("is_role %s", role) 564 | roles = self.session['roles'].split(',') 565 | if role in LTI_ROLES: 566 | role_list = LTI_ROLES[role] 567 | # find the intersection of the roles 568 | roles = set(role_list) & set(roles) 569 | is_user_role_there = len(roles) >= 1 570 | log.debug( 571 | "is_role roles_list=%s role=%s in list=%s", role_list, 572 | roles, is_user_role_there 573 | ) 574 | return is_user_role_there 575 | else: 576 | raise LTIException("Unknown role {}.".format(role)) 577 | 578 | def _check_role(self): 579 | """ 580 | Check that user is in role specified as wrapper attribute 581 | 582 | :exception: LTIRoleException if user is not in roles 583 | """ 584 | role = u'any' 585 | if 'role' in self.lti_kwargs: 586 | role = self.lti_kwargs['role'] 587 | log.debug( 588 | "check_role lti_role=%s decorator_role=%s", self.role, role 589 | ) 590 | if not (role == u'any' or self.is_role(self, role)): 591 | raise LTIRoleException('Not authorized.') 592 | 593 | def post_grade(self, grade): 594 | """ 595 | Post grade to LTI consumer using XML 596 | 597 | :param: grade: 0 <= grade <= 1 598 | :return: True if post successful and grade valid 599 | :exception: LTIPostMessageException if call failed 600 | """ 601 | message_identifier_id = self.message_identifier_id() 602 | operation = 'replaceResult' 603 | lis_result_sourcedid = self.lis_result_sourcedid 604 | # # edX devbox fix 605 | score = float(grade) 606 | if 0 <= score <= 1.0: 607 | xml = generate_request_xml( 608 | message_identifier_id, operation, lis_result_sourcedid, 609 | score) 610 | ret = post_message(self._consumers(), self.key, 611 | self.response_url, xml) 612 | if not ret: 613 | raise LTIPostMessageException("Post Message Failed") 614 | return True 615 | 616 | return False 617 | 618 | def post_grade2(self, grade, user=None, comment=''): 619 | """ 620 | Post grade to LTI consumer using REST/JSON 621 | URL munging will is related to: 622 | https://openedx.atlassian.net/browse/PLAT-281 623 | 624 | :param: grade: 0 <= grade <= 1 625 | :return: True if post successful and grade valid 626 | :exception: LTIPostMessageException if call failed 627 | """ 628 | content_type = 'application/vnd.ims.lis.v2.result+json' 629 | if user is None: 630 | user = self.user_id 631 | lti2_url = self.response_url.replace( 632 | "/grade_handler", 633 | "/lti_2_0_result_rest_handler/user/{}".format(user)) 634 | score = float(grade) 635 | if 0 <= score <= 1.0: 636 | body = json.dumps({ 637 | "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", 638 | "@type": "Result", 639 | "resultScore": score, 640 | "comment": comment 641 | }) 642 | ret = post_message2(self._consumers(), self.key, lti2_url, body, 643 | method='PUT', 644 | content_type=content_type) 645 | if not ret: 646 | raise LTIPostMessageException("Post Message Failed") 647 | return True 648 | 649 | return False 650 | -------------------------------------------------------------------------------- /pylti/flask.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | PyLTI decorator implementation for flask framework 4 | """ 5 | from __future__ import absolute_import 6 | from functools import wraps 7 | import logging 8 | 9 | from flask import session, current_app, Flask 10 | from flask import request as flask_request 11 | 12 | from .common import ( 13 | LTI_SESSION_KEY, 14 | LTI_PROPERTY_LIST, 15 | verify_request_common, 16 | default_error, 17 | LTIException, 18 | LTINotInSessionException, 19 | LTIBase 20 | ) 21 | 22 | 23 | log = logging.getLogger('pylti.flask') # pylint: disable=invalid-name 24 | 25 | 26 | class LTI(LTIBase): 27 | """ 28 | LTI Object represents abstraction of current LTI session. It provides 29 | callback methods and methods that allow developer to inspect 30 | LTI basic-launch-request. 31 | 32 | This object is instantiated by @lti wrapper. 33 | """ 34 | 35 | def __init__(self, lti_args, lti_kwargs): 36 | self.session = session 37 | LTIBase.__init__(self, lti_args, lti_kwargs) 38 | # Set app to current_app if not specified 39 | if not self.lti_kwargs['app']: 40 | self.lti_kwargs['app'] = current_app 41 | 42 | def _consumers(self): 43 | """ 44 | Gets consumer's map from app config 45 | 46 | :return: consumers map 47 | """ 48 | app_config = self.lti_kwargs['app'].config 49 | config = app_config.get('PYLTI_CONFIG', dict()) 50 | consumers = config.get('consumers', dict()) 51 | return consumers 52 | 53 | def verify_request(self): 54 | """ 55 | Verify LTI request 56 | :raises: LTIException is request validation failed 57 | """ 58 | if flask_request.method == 'POST': 59 | params = flask_request.form.to_dict() 60 | else: 61 | params = flask_request.args.to_dict() 62 | log.debug(params) 63 | log.debug('verify_request?') 64 | try: 65 | verify_request_common(self._consumers(), flask_request.url, 66 | flask_request.method, flask_request.headers, 67 | params) 68 | log.debug('verify_request success') 69 | 70 | # All good to go, store all of the LTI params into a 71 | # session dict for use in views 72 | for prop in LTI_PROPERTY_LIST: 73 | if params.get(prop, None): 74 | log.debug("params %s=%s", prop, params.get(prop, None)) 75 | session[prop] = params[prop] 76 | 77 | # Set logged in session key 78 | session[LTI_SESSION_KEY] = True 79 | return True 80 | except LTIException: 81 | log.debug('verify_request failed') 82 | for prop in LTI_PROPERTY_LIST: 83 | if session.get(prop, None): 84 | del session[prop] 85 | 86 | session[LTI_SESSION_KEY] = False 87 | raise 88 | 89 | @property 90 | def response_url(self): 91 | """ 92 | Returns remapped lis_outcome_service_url 93 | uses PYLTI_URL_FIX map to support edX dev-stack 94 | 95 | :return: remapped lis_outcome_service_url 96 | """ 97 | url = "" 98 | url = self.session['lis_outcome_service_url'] 99 | app_config = self.lti_kwargs['app'].config 100 | urls = app_config.get('PYLTI_URL_FIX', dict()) 101 | # url remapping is useful for using devstack 102 | # devstack reports httpS://localhost:8000/ and listens on HTTP 103 | for prefix, mapping in urls.items(): 104 | if url.startswith(prefix): 105 | for _from, _to in mapping.items(): 106 | url = url.replace(_from, _to) 107 | return url 108 | 109 | def _verify_any(self): 110 | """ 111 | Verify that an initial request has been made, or failing that, that 112 | the request is in the session 113 | :raises: LTIException 114 | """ 115 | log.debug('verify_any enter') 116 | 117 | # Check to see if there is a new LTI launch request incoming 118 | newrequest = False 119 | if flask_request.method == 'POST': 120 | params = flask_request.form.to_dict() 121 | initiation = "basic-lti-launch-request" 122 | if params.get("lti_message_type", None) == initiation: 123 | newrequest = True 124 | # Scrub the session of the old authentication 125 | for prop in LTI_PROPERTY_LIST: 126 | if session.get(prop, None): 127 | del session[prop] 128 | session[LTI_SESSION_KEY] = False 129 | 130 | # Attempt the appropriate validation 131 | # Both of these methods raise LTIException as necessary 132 | if newrequest: 133 | self.verify_request() 134 | else: 135 | self._verify_session() 136 | 137 | @staticmethod 138 | def _verify_session(): 139 | """ 140 | Verify that session was already created 141 | 142 | :raises: LTIException 143 | """ 144 | if not session.get(LTI_SESSION_KEY, False): 145 | log.debug('verify_session failed') 146 | raise LTINotInSessionException('Session expired or unavailable') 147 | 148 | @staticmethod 149 | def close_session(): 150 | """ 151 | Invalidates session 152 | """ 153 | for prop in LTI_PROPERTY_LIST: 154 | if session.get(prop, None): 155 | del session[prop] 156 | session[LTI_SESSION_KEY] = False 157 | 158 | 159 | def lti(app=None, request='any', error=default_error, role='any', 160 | *lti_args, **lti_kwargs): 161 | """ 162 | LTI decorator 163 | 164 | :param: app - Flask App object (optional). 165 | :py:attr:`flask.current_app` is used if no object is passed in. 166 | :param: error - Callback if LTI throws exception (optional). 167 | :py:attr:`pylti.flask.default_error` is the default. 168 | :param: request - Request type from 169 | :py:attr:`pylti.common.LTI_REQUEST_TYPE`. (default: any) 170 | :param: roles - LTI Role (default: any) 171 | :return: wrapper 172 | """ 173 | 174 | def _lti(function): 175 | """ 176 | Inner LTI decorator 177 | 178 | :param: function: 179 | :return: 180 | """ 181 | 182 | @wraps(function) 183 | def wrapper(*args, **kwargs): 184 | """ 185 | Pass LTI reference to function or return error. 186 | """ 187 | try: 188 | the_lti = LTI(lti_args, lti_kwargs) 189 | the_lti.verify() 190 | the_lti._check_role() # pylint: disable=protected-access 191 | kwargs['lti'] = the_lti 192 | return function(*args, **kwargs) 193 | except LTIException as lti_exception: 194 | error = lti_kwargs.get('error') 195 | exception = dict() 196 | exception['exception'] = lti_exception 197 | exception['kwargs'] = kwargs 198 | exception['args'] = args 199 | return error(exception=exception) 200 | 201 | return wrapper 202 | 203 | lti_kwargs['request'] = request 204 | lti_kwargs['error'] = error 205 | lti_kwargs['role'] = role 206 | 207 | if (not app) or isinstance(app, Flask): 208 | lti_kwargs['app'] = app 209 | return _lti 210 | else: 211 | # We are wrapping without arguments 212 | lti_kwargs['app'] = None 213 | return _lti(app) 214 | -------------------------------------------------------------------------------- /pylti/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | PyLTI is module that implements IMS LTI in python 4 | The API uses decorators to wrap function with LTI functionality. 5 | """ 6 | VERSION = "0.1.0" 7 | -------------------------------------------------------------------------------- /pylti/tests/data/certs/snakeoil.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICXTCCAcYCCQDRykEqQc08tzANBgkqhkiG9w0BAQUFADByMQswCQYDVQQGEwJV 3 | UzEQMA4GA1UECAwHRXhhbXBsZTEQMA4GA1UEBwwHRXhhbXBsZTEQMA4GA1UECgwH 4 | RXhhbXBsZTEQMA4GA1UECwwHRXhhbXBsZTEbMBkGA1UEAwwScGVyc29uQGV4YW1w 5 | bGUuY29tMCAXDTE0MTIxNjIxMzIxMFoYDzIxMTQxMTIyMjEzMjEwWjByMQswCQYD 6 | VQQGEwJVUzEQMA4GA1UECAwHRXhhbXBsZTEQMA4GA1UEBwwHRXhhbXBsZTEQMA4G 7 | A1UECgwHRXhhbXBsZTEQMA4GA1UECwwHRXhhbXBsZTEbMBkGA1UEAwwScGVyc29u 8 | QGV4YW1wbGUuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC+fMA1J4Wg 9 | O6PPbx/m/fH1whw53oIN99wU86dPGguJ4BSgNmY3HOysuJvWpP5PCRIR65CoGmTO 10 | 5+xUPoAKNkHTlOZE3dsV/48/22IaxBHREPWnMH0+hKmKq2YSgpx0vOUpLgIpaTu5 11 | eYgN9755475LrruAp5iD+zihlRzbVnjj/QIDAQABMA0GCSqGSIb3DQEBBQUAA4GB 12 | AEMBrxMTFV2Mbg/WIGadVGe7/n+sqyEBQ8ikpj4WSIWUqpgeRvA6ZRE8K+NJ+xTV 13 | Nu1ppF7e8VWyCjvNtPhevMdwO91Lc6PTXi40k9zJYGz1DRdD8kmw8LQGcOd3fIQ7 14 | CfTLjGyGrdWuM5w9Y3YuKQqnachUq/F68uOEVqLXFoaP 15 | -----END CERTIFICATE----- 16 | -----BEGIN RSA PRIVATE KEY----- 17 | MIICXAIBAAKBgQC+fMA1J4WgO6PPbx/m/fH1whw53oIN99wU86dPGguJ4BSgNmY3 18 | HOysuJvWpP5PCRIR65CoGmTO5+xUPoAKNkHTlOZE3dsV/48/22IaxBHREPWnMH0+ 19 | hKmKq2YSgpx0vOUpLgIpaTu5eYgN9755475LrruAp5iD+zihlRzbVnjj/QIDAQAB 20 | AoGAEjHwWiNwTCHmP8YpkfLnzcXA1HZAjf0C9K1hadjfCUhyL+uCT/lfUhBAMnyI 21 | HhyLsVKC+suqnWjh1hoyOMd9+gFcXPFVLMDhkP+88C8b7OBG2qRQKcjFkEJLrI1G 22 | f6O8Lc6ukgBoJAh1Nuq0pf8tYKwS9nIndK2jK/0DCUBP6bECQQD5LC7b/uqeaiqk 23 | YH9+bR6FABjwLOh78WKl/ZwEaston7QJAAcJVK5BerBqmy3Mr584+jc2/XylLMiH 24 | s/kEQmg3AkEAw7TrvBbPCYkdqudv6/975z81yhh4WOY+RgYzD1Ol913EhtANJrng 25 | 0+QrVgefr4mw0TXQrGW/+KYzA07IVr7TawJBAOEZJhf+OWv1EyK+Pk8zOsACL4VB 26 | vKDDl0/HRVvEMpAIvnbm/HRUeLuUn60fFQf1nAy4FotqAmGhjGLzlkFf0I8CQGSj 27 | U5ncTMkFhokNDGPadDe9LIbpQHHOrHVL2NPn2u+ye04sDKc+bJvpuFM8BmS5NIDQ 28 | 4KbWh/pwVMk9qQ3agVMCQClReiBbizvqYnDXIizMhWHfwajul/ileMLyW0RO95Se 29 | 685fYoyrttDLfVtQQ9yFrghAhM3BdR07DwxQRYEgJQE= 30 | -----END RSA PRIVATE KEY----- 31 | -------------------------------------------------------------------------------- /pylti/tests/test_chalice.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Test pylti/test_chalice.py module 4 | """ 5 | from __future__ import absolute_import 6 | import unittest 7 | 8 | import httpretty 9 | import oauthlib.oauth1 10 | import os 11 | 12 | from six.moves.urllib.parse import urlencode 13 | 14 | from pylti.common import LTIException 15 | # from pylti.chalice import LTI 16 | from pylti.tests.test_chalice_app import app_exception, app 17 | 18 | from chalice.config import Config 19 | from chalice.local import LocalGateway 20 | 21 | TYPE_FORM = 'application/x-www-form-urlencoded' 22 | 23 | 24 | class TestChalice(unittest.TestCase): 25 | """ 26 | Consumers. 27 | """ 28 | # pylint: disable=too-many-public-methods 29 | consumers = { 30 | "__consumer_key__": {"secret": "__lti_secret__"} 31 | } 32 | # Chalice implementation stores consumers in environment variables 33 | os.environ['CONSUMER_KEY_SECRET___consumer_key__'] = '__lti_secret__' 34 | 35 | # Valid XML response from LTI 1.0 consumer 36 | expected_response = """ 37 | 39 | 40 | 41 | V1.0 42 | edX_fix 43 | 44 | success 45 | status 46 | Score for StarX/StarX_DEMO/201X_StarX:\ 47 | edge.edx.org-i4x-StarX-StarX_DEMO-lti-40559041895b4065b2818c23b9cd9da8\ 48 | :18b71d3c46cb4dbe66a7c950d88e78ec is now 0.0 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | """ 57 | 58 | def setUp(self): 59 | """ 60 | Setting up app config. 61 | """ 62 | # self.config = {} 63 | # self.config['TESTING'] = True 64 | # self.config['SERVER_NAME'] = 'localhost' 65 | # self.config['WTF_CSRF_ENABLED'] = False 66 | # self.config['SECRET_KEY'] = 'you-will-never-guess' 67 | # self.config['PYLTI_CONFIG'] = {'consumers': self.consumers} 68 | # self.config['PYLTI_URL_FIX'] = { 69 | # "https://localhost:8000/": { 70 | # "https://localhost:8000/": "https://localhost:8000/" 71 | # } 72 | # } 73 | # self.app = app.test_client() 74 | self.localGateway = LocalGateway(app, Config()) 75 | app_exception.reset() 76 | 77 | @staticmethod 78 | def generate_launch_request(consumers, url, 79 | lit_outcome_service_url=None, 80 | roles=u'Instructor', 81 | add_params=None): 82 | """ 83 | Generate valid basic-lti-launch-request request with options. 84 | :param consumers: consumer map 85 | :param url: URL to sign 86 | :param lit_outcome_service_url: LTI callback 87 | :param roles: LTI role 88 | :return: signed request 89 | """ 90 | # pylint: disable=unused-argument, too-many-arguments 91 | params = {'resource_link_id': u'edge.edx.org-i4x-MITx-ODL_ENG-lti-' 92 | u'94173d3e79d145fd8ec2e83f15836ac8', 93 | 'user_id': u'008437924c9852377e8994829aaac7a1', 94 | 'lis_result_sourcedid': u'MITx/ODL_ENG/2014_T1:' 95 | u'edge.edx.org-i4x-MITx-ODL_ENG-lti-' 96 | u'94173d3e79d145fd8ec2e83f15836ac8:' 97 | u'008437924c9852377e8994829aaac7a1', 98 | 'context_id': u'MITx/ODL_ENG/2014_T1', 99 | 'lti_version': u'LTI-1p0', 100 | 'launch_presentation_return_url': u'', 101 | 'lis_outcome_service_url': (lit_outcome_service_url or 102 | u'https://example.edu/' 103 | u'courses/MITx/ODL_ENG/' 104 | u'2014_T1/xblock/i4x:;_;' 105 | u'_MITx;_ODL_ENG;_lti;' 106 | u'_94173d3e79d145fd8ec2e' 107 | u'83f15836ac8' 108 | u'/handler_noauth/' 109 | u'grade_handler'), 110 | 'lti_message_type': u'basic-lti-launch-request'} 111 | 112 | if roles is not None: 113 | params['roles'] = roles 114 | 115 | if add_params is not None: 116 | params.update(add_params) 117 | 118 | urlparams = urlencode(params) 119 | 120 | client = oauthlib.oauth1.Client('__consumer_key__', 121 | client_secret='__lti_secret__', 122 | signature_method=oauthlib.oauth1. 123 | SIGNATURE_HMAC, 124 | signature_type=oauthlib.oauth1. 125 | SIGNATURE_TYPE_QUERY) 126 | signature = client.sign("{}{}".format(url, urlparams)) 127 | signed_url = signature[0] 128 | new_url = signed_url[len('https://localhost'):] 129 | return new_url 130 | 131 | @staticmethod 132 | def get_exception(): 133 | """ 134 | Returns exception raised by PyLTI. 135 | :return: exception 136 | """ 137 | return app_exception.get() 138 | 139 | @staticmethod 140 | def has_exception(): 141 | """ 142 | Check if PyLTI raised exception. 143 | :return: is exception raised 144 | """ 145 | return app_exception.get() is not None 146 | 147 | @staticmethod 148 | def get_exception_as_string(): 149 | """ 150 | Return text of the exception raised by LTI. 151 | :return: text 152 | """ 153 | return "{}".format(TestChalice.get_exception()) 154 | 155 | def request_callback(self, request, cburi, headers): 156 | # pylint: disable=unused-argument 157 | """ 158 | Mock expected response. 159 | """ 160 | return 200, headers, self.expected_response 161 | 162 | def test_access_to_oauth_resource_unknown_protection(self): 163 | """ 164 | Invalid LTI request scope. 165 | """ 166 | self.localGateway.handle_request(method='GET', 167 | path='/unknown_protection', 168 | headers={ 169 | 'host': 'localhost', 170 | 'x-forwarded-proto': 'https' 171 | }, 172 | body='') 173 | self.assertTrue(self.has_exception()) 174 | self.assertIsInstance(self.get_exception(), LTIException) 175 | self.assertEqual(self.get_exception_as_string(), 176 | 'Unknown request type') 177 | 178 | def test_access_to_oauth_resource_without_authorization_any(self): 179 | """ 180 | Accessing LTI without establishing session. 181 | """ 182 | self.localGateway.handle_request(method='GET', 183 | path='/any', 184 | headers={ 185 | 'host': 'localhost', 186 | 'x-forwarded-proto': 'https' 187 | }, 188 | body='') 189 | self.assertTrue(self.has_exception()) 190 | self.assertIsInstance(self.get_exception(), LTIException) 191 | self.assertEqual(self.get_exception_as_string(), 192 | 'The Request Type any is not supported because ' 193 | 'Chalice does not support session state. Change ' 194 | 'the Request Type to initial or omit it from the ' 195 | 'declaration.') 196 | 197 | def test_access_to_oauth_resource_without_authorization_session(self): 198 | """ 199 | Accessing LTI session scope before session established. 200 | """ 201 | self.localGateway.handle_request(method='GET', 202 | path='/session', 203 | headers={ 204 | 'host': 'localhost', 205 | 'x-forwarded-proto': 'https' 206 | }, 207 | body='') 208 | self.assertTrue(self.has_exception()) 209 | self.assertIsInstance(self.get_exception(), LTIException) 210 | self.assertEqual(self.get_exception_as_string(), 211 | 'The Request Type session is not supported because ' 212 | 'Chalice does not support session state. Change ' 213 | 'the Request Type to initial or omit it from the ' 214 | 'declaration.') 215 | 216 | def test_access_to_oauth_resource_without_authorization_initial_get(self): 217 | """ 218 | Accessing LTI without basic-lti-launch-request parameters as GET. 219 | """ 220 | self.localGateway.handle_request(method='GET', 221 | path='/initial', 222 | headers={ 223 | 'host': 'localhost', 224 | 'x-forwarded-proto': 'https' 225 | }, 226 | body='') 227 | self.assertTrue(self.has_exception()) 228 | self.assertIsInstance(self.get_exception(), LTIException) 229 | self.assertEqual(self.get_exception_as_string(), 230 | 'This page requires a valid oauth session or request') 231 | 232 | def test_access_without_authorization_post_form(self): 233 | """ 234 | Accessing LTI without basic-lti-launch-request parameters as POST. 235 | """ 236 | self.localGateway.handle_request(method='POST', 237 | path='/initial', 238 | headers={ 239 | 'host': 'localhost', 240 | 'Content-Type': TYPE_FORM 241 | }, 242 | body='') 243 | self.assertTrue(self.has_exception()) 244 | self.assertIsInstance(self.get_exception(), LTIException) 245 | self.assertEqual(self.get_exception_as_string(), 246 | 'This page requires a valid oauth session or request') 247 | 248 | # DELETE: No sessions in Chalice 249 | # def test_access_to_oauth_resource_in_session(self): 250 | # """ 251 | # Accessing LTI after session established. 252 | # """ 253 | # self.app.get('/setup_session') 254 | # self.app.get('/session') 255 | # self.assertFalse(self.has_exception()) 256 | 257 | # DELETE: No sessions in Chalice 258 | # def test_access_to_oauth_resource_in_session_with_close(self): 259 | # """ 260 | # Accessing LTI after session closed. 261 | # """ 262 | # self.app.get('/setup_session') 263 | # self.app.get('/session') 264 | # self.assertFalse(self.has_exception()) 265 | # self.app.get('/close_session') 266 | # self.app.get('/session') 267 | # self.assertTrue(self.has_exception()) 268 | 269 | def test_access_to_oauth_resource_get(self): 270 | """ 271 | Accessing oauth_resource. 272 | """ 273 | consumers = self.consumers 274 | url = 'https://localhost/initial?' 275 | new_url = self.generate_launch_request(consumers, url) 276 | self.localGateway.handle_request(method='GET', 277 | path=new_url, 278 | headers={ 279 | 'host': 'localhost', 280 | 'x-forwarded-proto': 'https' 281 | }, 282 | body='') 283 | self.assertFalse(self.has_exception()) 284 | 285 | def test_access_to_oauth_resource_post(self): 286 | """ 287 | Accessing oauth_resource. 288 | """ 289 | consumers = self.consumers 290 | url = 'https://localhost/initial?' 291 | new_url = self.generate_launch_request(consumers, url) 292 | (path, body) = new_url.split("?") 293 | self.localGateway.handle_request(method='POST', 294 | path=path, 295 | headers={ 296 | 'host': 'localhost', 297 | 'x-forwarded-proto': 'https' 298 | }, 299 | body=body) 300 | self.assertFalse(self.has_exception()) 301 | 302 | def test_access_to_oauth_resource_name_passed(self): 303 | """ 304 | Check that name is returned if passed via initial request. 305 | """ 306 | # pylint: disable=maybe-no-member 307 | consumers = self.consumers 308 | url = 'https://localhost/name?' 309 | add_params = {u'lis_person_sourcedid': u'person'} 310 | new_url = self.generate_launch_request( 311 | consumers, url, add_params=add_params 312 | ) 313 | ret = self.localGateway.handle_request(method='GET', 314 | path=new_url, 315 | headers={ 316 | 'host': 'localhost', 317 | 'x-forwarded-proto': 'https' 318 | }, 319 | body='') 320 | self.assertFalse(self.has_exception()) 321 | self.assertEqual(ret['body'], u'person') 322 | 323 | def test_access_to_oauth_resource_email_passed(self): 324 | """ 325 | Check that email is returned if passed via initial request. 326 | """ 327 | # pylint: disable=maybe-no-member 328 | consumers = self.consumers 329 | url = 'https://localhost/name?' 330 | add_params = {u'lis_person_contact_email_primary': u'email@email.com'} 331 | new_url = self.generate_launch_request( 332 | consumers, url, add_params=add_params 333 | ) 334 | ret = self.localGateway.handle_request(method='GET', 335 | path=new_url, 336 | headers={ 337 | 'host': 'localhost', 338 | 'x-forwarded-proto': 'https' 339 | }, 340 | body='') 341 | self.assertFalse(self.has_exception()) 342 | self.assertEqual(ret['body'], u'email@email.com') 343 | 344 | def test_access_to_oauth_resource_name_and_email_passed(self): 345 | """ 346 | Check that name is returned if both email and name passed. 347 | """ 348 | # pylint: disable=maybe-no-member 349 | consumers = self.consumers 350 | url = 'https://localhost/name?' 351 | add_params = {u'lis_person_sourcedid': u'person', 352 | u'lis_person_contact_email_primary': u'email@email.com'} 353 | new_url = self.generate_launch_request( 354 | consumers, url, add_params=add_params 355 | ) 356 | ret = self.localGateway.handle_request(method='GET', 357 | path=new_url, 358 | headers={ 359 | 'host': 'localhost', 360 | 'x-forwarded-proto': 'https' 361 | }, 362 | body='') 363 | self.assertFalse(self.has_exception()) 364 | self.assertEqual(ret['body'], u'person') 365 | 366 | def test_access_to_oauth_resource_staff_only_as_student(self): 367 | """ 368 | Deny access if user not in role. 369 | """ 370 | consumers = self.consumers 371 | url = 'https://localhost/initial_staff?' 372 | student_url = self.generate_launch_request( 373 | consumers, url, roles='Student' 374 | ) 375 | self.localGateway.handle_request(method='GET', 376 | path=student_url, 377 | headers={ 378 | 'host': 'localhost', 379 | 'x-forwarded-proto': 'https' 380 | }, 381 | body='') 382 | self.assertTrue(self.has_exception()) 383 | 384 | learner_url = self.generate_launch_request( 385 | consumers, url, roles='Learner' 386 | ) 387 | self.localGateway.handle_request(method='GET', 388 | path=learner_url, 389 | headers={ 390 | 'host': 'localhost', 391 | 'x-forwarded-proto': 'https' 392 | }, 393 | body='') 394 | self.assertTrue(self.has_exception()) 395 | 396 | def test_access_to_oauth_resource_staff_only_as_administrator(self): 397 | """ 398 | Allow access if user in role. 399 | """ 400 | consumers = self.consumers 401 | url = 'https://localhost/initial_staff?' 402 | new_url = self.generate_launch_request( 403 | consumers, url, roles='Administrator' 404 | ) 405 | self.localGateway.handle_request(method='GET', 406 | path=new_url, 407 | headers={ 408 | 'host': 'localhost', 409 | 'x-forwarded-proto': 'https' 410 | }, 411 | body='') 412 | self.assertFalse(self.has_exception()) 413 | 414 | def test_access_to_oauth_resource_staff_only_as_unknown_role(self): 415 | """ 416 | Deny access if role not defined. 417 | """ 418 | consumers = self.consumers 419 | url = 'https://localhost/initial_unknown?' 420 | admin_url = self.generate_launch_request( 421 | consumers, url, roles='Administrator' 422 | ) 423 | self.localGateway.handle_request(method='GET', 424 | path=admin_url, 425 | headers={ 426 | 'host': 'localhost', 427 | 'x-forwarded-proto': 'https' 428 | }, 429 | body='') 430 | self.assertTrue(self.has_exception()) 431 | 432 | def test_access_to_oauth_resource_student_as_student(self): 433 | """ 434 | Verify that the various roles we consider as students are students. 435 | """ 436 | consumers = self.consumers 437 | url = 'https://localhost/initial_student?' 438 | 439 | # Learner Role 440 | learner_url = self.generate_launch_request( 441 | consumers, url, roles='Learner' 442 | ) 443 | self.localGateway.handle_request(method='GET', 444 | path=learner_url, 445 | headers={ 446 | 'host': 'localhost', 447 | 'x-forwarded-proto': 'https' 448 | }, 449 | body='') 450 | self.assertFalse(self.has_exception()) 451 | 452 | student_url = self.generate_launch_request( 453 | consumers, url, roles='Student' 454 | ) 455 | self.localGateway.handle_request(method='GET', 456 | path=student_url, 457 | headers={ 458 | 'host': 'localhost', 459 | 'x-forwarded-proto': 'https' 460 | }, 461 | body='') 462 | self.assertFalse(self.has_exception()) 463 | 464 | def test_access_to_oauth_resource_student_as_staff(self): 465 | """Verify staff doesn't have access to student only.""" 466 | consumers = self.consumers 467 | url = 'https://localhost/initial_student?' 468 | staff_url = self.generate_launch_request( 469 | consumers, url, roles='Staff' 470 | ) 471 | self.localGateway.handle_request(method='GET', 472 | path=staff_url, 473 | headers={ 474 | 'host': 'localhost', 475 | 'x-forwarded-proto': 'https' 476 | }, 477 | body='') 478 | self.assertTrue(self.has_exception()) 479 | 480 | def test_access_to_oauth_resource_student_as_unknown(self): 481 | """Verify unknown role doesn't have access to student only.""" 482 | consumers = self.consumers 483 | url = 'https://localhost/initial_student?' 484 | unknown_url = self.generate_launch_request( 485 | consumers, url, roles='FooBar' 486 | ) 487 | self.localGateway.handle_request(method='GET', 488 | path=unknown_url, 489 | headers={ 490 | 'host': 'localhost', 491 | 'x-forwarded-proto': 'https' 492 | }, 493 | body='') 494 | self.assertTrue(self.has_exception()) 495 | 496 | # DELETE: Chalice does not support any. Otherwise this is a duplicate of 497 | # prior test test_access_to_oauth_resource 498 | # def test_access_to_oauth_resource_any(self): 499 | # """ 500 | # Test access to LTI protected resources. 501 | # """ 502 | # url = 'https://localhost/any?' 503 | # new_url = self.generate_launch_request(self.consumers, url) 504 | # self.localGateway.handle_request(method='GET', 505 | # path=new_url, 506 | # headers={ 507 | # 'host': 'localhost' 508 | # }, 509 | # body='') 510 | # self.assertFalse(self.has_exception()) 511 | 512 | # UPDATED: Changed this test to use inital endpoint as it was not covered 513 | def test_access_to_oauth_resource_initial_norole(self): 514 | """ 515 | Test access to LTI protected resources. 516 | """ 517 | url = 'https://localhost/initial?' 518 | new_url = self.generate_launch_request(self.consumers, url, roles=None) 519 | self.localGateway.handle_request(method='GET', 520 | path=new_url, 521 | headers={ 522 | 'host': 'localhost', 523 | 'x-forwarded-proto': 'https' 524 | }, 525 | body='') 526 | self.assertFalse(self.has_exception()) 527 | 528 | # UPDATED: Changed this test to use inital endpoint as it was not covered 529 | def test_access_to_oauth_resource_any_nonstandard_role(self): 530 | """ 531 | Test access to LTI protected resources. 532 | """ 533 | url = 'https://localhost/initial?' 534 | new_url = self.generate_launch_request(self.consumers, url, 535 | roles=u'ThisIsNotAStandardRole') 536 | self.localGateway.handle_request(method='GET', 537 | path=new_url, 538 | headers={ 539 | 'host': 'localhost', 540 | 'x-forwarded-proto': 'https' 541 | }, 542 | body='') 543 | self.assertFalse(self.has_exception()) 544 | 545 | def test_access_to_oauth_resource_invalid(self): 546 | """ 547 | Deny access to LTI protected resources 548 | on man in the middle attack. 549 | """ 550 | url = 'https://localhost/initial?' 551 | new_url = self.generate_launch_request(self.consumers, url) 552 | bad_url = "{}&FAIL=TRUE".format(new_url) 553 | self.localGateway.handle_request(method='GET', 554 | path=bad_url, 555 | headers={ 556 | 'host': 'localhost', 557 | 'x-forwarded-proto': 'https' 558 | }, 559 | body='') 560 | self.assertTrue(self.has_exception()) 561 | self.assertIsInstance(self.get_exception(), LTIException) 562 | self.assertEqual(self.get_exception_as_string(), 563 | 'OAuth error: Please check your key and secret') 564 | 565 | # DELETE: Chalice does not support sessions 566 | # def test_access_to_oauth_resource_invalid_after_session_setup(self): 567 | # """ 568 | # Remove browser session on man in the middle attach. 569 | # """ 570 | # self.app.get('/setup_session') 571 | # self.app.get('/session') 572 | # self.assertFalse(self.has_exception()) 573 | 574 | # url = 'https://localhost/initial?' 575 | # new_url = self.generate_launch_request(self.consumers, url) 576 | 577 | # self.app.get("{}&FAIL=TRUE".format(new_url)) 578 | # self.assertTrue(self.has_exception()) 579 | # self.assertIsInstance(self.get_exception(), LTIException) 580 | # self.assertEqual(self.get_exception_as_string(), 581 | # 'OAuth error: Please check your key and secret') 582 | 583 | # UPDATE: Original established a session and then called post_grade 584 | # Chalice does nto support sessions so initiate and post in one call 585 | @httpretty.activate 586 | def test_access_to_oauth_resource_post_grade(self): 587 | """ 588 | Check post_grade functionality. 589 | """ 590 | # pylint: disable=maybe-no-member 591 | uri = (u'https://example.edu/courses/MITx/ODL_ENG/2014_T1/xblock/' 592 | u'i4x:;_;_MITx;_ODL_ENG;_lti;' 593 | u'_94173d3e79d145fd8ec2e83f15836ac8/handler_noauth' 594 | u'/grade_handler') 595 | 596 | httpretty.register_uri(httpretty.POST, uri, body=self.request_callback) 597 | 598 | consumers = self.consumers 599 | url = 'https://localhost/post_grade/1.0?' 600 | new_url = self.generate_launch_request(consumers, url) 601 | ret = self.localGateway.handle_request(method='GET', 602 | path=new_url, 603 | headers={ 604 | 'host': 'localhost', 605 | 'x-forwarded-proto': 'https' 606 | }, 607 | body='') 608 | self.assertFalse(self.has_exception()) 609 | self.assertEqual(ret['body'], "grade=True") 610 | 611 | url = 'https://localhost/post_grade/2.0?' 612 | new_url = self.generate_launch_request(consumers, url) 613 | ret = self.localGateway.handle_request(method='GET', 614 | path=new_url, 615 | headers={ 616 | 'host': 'localhost', 617 | 'x-forwarded-proto': 'https' 618 | }, 619 | body='') 620 | self.assertFalse(self.has_exception()) 621 | self.assertEqual(ret['body'], "grade=False") 622 | 623 | # UPDATE: Original established a session and then called post_grade 624 | # Chalice does nto support sessions so initiate and post in one call 625 | @httpretty.activate 626 | def test_access_to_oauth_resource_post_grade_fail(self): 627 | """ 628 | Check post_grade functionality fails on invalid response. 629 | """ 630 | # pylint: disable=maybe-no-member 631 | uri = (u'https://example.edu/courses/MITx/ODL_ENG/2014_T1/xblock/' 632 | u'i4x:;_;_MITx;_ODL_ENG;_lti;' 633 | u'_94173d3e79d145fd8ec2e83f15836ac8/handler_noauth' 634 | u'/grade_handler') 635 | 636 | def request_callback(request, cburi, headers): 637 | # pylint: disable=unused-argument 638 | """ 639 | Mock error response callback. 640 | """ 641 | return 200, headers, "wrong_response" 642 | 643 | httpretty.register_uri(httpretty.POST, uri, body=request_callback) 644 | 645 | consumers = self.consumers 646 | url = 'https://localhost/post_grade/1.0?' 647 | new_url = self.generate_launch_request(consumers, url) 648 | ret = self.localGateway.handle_request(method='GET', 649 | path=new_url, 650 | headers={ 651 | 'host': 'localhost', 652 | 'x-forwarded-proto': 'https' 653 | }, 654 | body='') 655 | self.assertTrue(self.has_exception()) 656 | self.assertEqual(ret['body'], "error") 657 | 658 | # DELETED: Not implemented. Sounds like an EdX edge case 659 | # @httpretty.activate 660 | # def test_access_to_oauth_resource_post_grade_fix_url(self): 661 | # """ 662 | # Make sure URL remap works for edX vagrant stack. 663 | # """ 664 | # # pylint: disable=maybe-no-member 665 | # uri = 'https://localhost:8000/dev_stack' 666 | 667 | # httpretty.register_uri(httpretty.POST, uri, 668 | # body=self.request_callback) 669 | 670 | # url = 'https://localhost/initial?' 671 | # new_url = self.generate_launch_request( 672 | # self.consumers, url, lit_outcome_service_url=uri 673 | # ) 674 | # ret = self.app.get(new_url) 675 | # self.assertFalse(self.has_exception()) 676 | 677 | # ret = self.app.get("/post_grade/1.0") 678 | # self.assertFalse(self.has_exception()) 679 | # self.assertEqual(ret.data.decode('utf-8'), "grade=True") 680 | 681 | # ret = self.app.get("/post_grade/2.0") 682 | # self.assertFalse(self.has_exception()) 683 | # self.assertEqual(ret.data.decode('utf-8'), "grade=False") 684 | 685 | @httpretty.activate 686 | def test_access_to_oauth_resource_post_grade2(self): 687 | """ 688 | Check post_grade edX LTI2 functionality. 689 | """ 690 | uri = (u'https://example.edu/courses/MITx/ODL_ENG/2014_T1/xblock/' 691 | u'i4x:;_;_MITx;_ODL_ENG;_lti;' 692 | u'_94173d3e79d145fd8ec2e83f15836ac8/handler_noauth' 693 | u'/lti_2_0_result_rest_handler/user/' 694 | u'008437924c9852377e8994829aaac7a1') 695 | 696 | httpretty.register_uri(httpretty.PUT, uri, body=self.request_callback) 697 | 698 | consumers = self.consumers 699 | url = 'https://localhost/post_grade2/1.0?' 700 | new_url = self.generate_launch_request(consumers, url) 701 | ret = self.localGateway.handle_request(method='GET', 702 | path=new_url, 703 | headers={ 704 | 'host': 'localhost', 705 | 'x-forwarded-proto': 'https' 706 | }, 707 | body='') 708 | self.assertFalse(self.has_exception()) 709 | self.assertEqual(ret['body'], "grade=True") 710 | 711 | url = 'https://localhost/post_grade2/2.0?' 712 | new_url = self.generate_launch_request(consumers, url) 713 | ret = self.localGateway.handle_request(method='GET', 714 | path=new_url, 715 | headers={ 716 | 'host': 'localhost', 717 | 'x-forwarded-proto': 'https' 718 | }, 719 | body='') 720 | self.assertFalse(self.has_exception()) 721 | self.assertEqual(ret['body'], "grade=False") 722 | 723 | @httpretty.activate 724 | def test_access_to_oauth_resource_post_grade2_fail(self): 725 | """ 726 | Check post_grade edX LTI2 functionality 727 | """ 728 | uri = (u'https://example.edu/courses/MITx/ODL_ENG/2014_T1/xblock/' 729 | u'i4x:;_;_MITx;_ODL_ENG;_lti;' 730 | u'_94173d3e79d145fd8ec2e83f15836ac8/handler_noauth' 731 | u'/lti_2_0_result_rest_handler/user/' 732 | u'008437924c9852377e8994829aaac7a1') 733 | 734 | def request_callback(request, cburi, headers): 735 | # pylint: disable=unused-argument 736 | """ 737 | Mock expected response. 738 | """ 739 | return 400, headers, self.expected_response 740 | 741 | httpretty.register_uri(httpretty.PUT, uri, body=request_callback) 742 | 743 | consumers = self.consumers 744 | url = 'https://localhost/post_grade2/1.0?' 745 | new_url = self.generate_launch_request(consumers, url) 746 | ret = self.localGateway.handle_request(method='GET', 747 | path=new_url, 748 | headers={ 749 | 'host': 'localhost', 750 | 'x-forwarded-proto': 'https' 751 | }, 752 | body='') 753 | self.assertTrue(self.has_exception()) 754 | self.assertEqual(ret['body'], "error") 755 | 756 | def test_default_decorator(self): 757 | """ 758 | Verify default decorator works. 759 | """ 760 | url = 'https://localhost/default_lti?' 761 | new_url = self.generate_launch_request(self.consumers, url) 762 | self.localGateway.handle_request(method='GET', 763 | path=new_url, 764 | headers={ 765 | 'host': 'localhost', 766 | 'x-forwarded-proto': 'https' 767 | }, 768 | body='') 769 | self.assertFalse(self.has_exception()) 770 | 771 | def test_default_decorator_bad(self): 772 | """ 773 | Verify error handling works. 774 | """ 775 | # Validate we get our error page when there is a bad LTI 776 | # request 777 | # pylint: disable=maybe-no-member 778 | ret = self.localGateway.handle_request(method='GET', 779 | path='/default_lti', 780 | headers={ 781 | 'host': 'localhost', 782 | 'x-forwarded-proto': 'https' 783 | }, 784 | body='') 785 | print(ret) 786 | self.assertEqual(ret['statusCode'], 500) 787 | -------------------------------------------------------------------------------- /pylti/tests/test_chalice_app.py: -------------------------------------------------------------------------------- 1 | from chalice import Chalice 2 | from pylti.chalice import lti as lti_chalice 3 | from pylti.tests.test_common import ExceptionHandler 4 | 5 | app = Chalice(__name__) 6 | app_exception = ExceptionHandler() # pylint: disable=invalid-name 7 | 8 | 9 | def error(exception): 10 | """ 11 | Set exception to exception handler and returns error string. 12 | """ 13 | app_exception.set(exception) 14 | return "error" 15 | 16 | 17 | @app.route("/unknown_protection") 18 | @lti_chalice(error=error, app=app, request='notreal') 19 | def unknown_protection(lti): 20 | # pylint: disable=unused-argument, 21 | """ 22 | Access route with unknown protection. 23 | 24 | :param lti: `lti` object 25 | :return: string "hi" 26 | """ 27 | return "hi" # pragma: no cover 28 | 29 | 30 | @app.route("/any") 31 | @lti_chalice(error=error, request='any', app=app) 32 | def any_route(lti): 33 | # pylint: disable=unused-argument, 34 | """ 35 | Access route with 'any' request. 36 | 37 | :param lti: `lti` object 38 | :return: string "hi" 39 | """ 40 | return "hi" 41 | 42 | 43 | @app.route("/session") 44 | @lti_chalice(error=error, request='session', app=app) 45 | def session_route(lti): 46 | # pylint: disable=unused-argument, 47 | """ 48 | Access route with 'session' request. 49 | 50 | :param lti: `lti` object 51 | :return: string "hi" 52 | """ 53 | return "hi" 54 | 55 | 56 | @app.route("/initial", methods=['GET']) 57 | @lti_chalice(error=error, request='initial', app=app) 58 | def initial_route(lti): 59 | # pylint: disable=unused-argument, 60 | """ 61 | Access route with 'initial' request. 62 | 63 | :param lti: `lti` object 64 | :return: string "hi" 65 | """ 66 | return "hi" 67 | 68 | 69 | @app.route("/initial", methods=['POST'], 70 | content_types=['application/x-www-form-urlencoded']) 71 | @lti_chalice(error=error, request='initial', app=app) 72 | def post_form(lti): 73 | # pylint: disable=unused-argument, 74 | """ 75 | Access route with 'initial' request. 76 | 77 | :param lti: `lti` object 78 | :return: string "hi" 79 | """ 80 | return "hi" 81 | 82 | 83 | @app.route("/name", methods=['GET', 'POST']) 84 | @lti_chalice(error=error, request='initial', app=app) 85 | def name(lti): 86 | """ 87 | Access route with 'initial' request. 88 | 89 | :param lti: `lti` object 90 | :return: string "hi" 91 | """ 92 | return lti.name 93 | 94 | 95 | @app.route("/initial_staff", methods=['GET']) 96 | @lti_chalice(error=error, request='initial', role='staff', app=app) 97 | def initial_staff_route(lti): 98 | # pylint: disable=unused-argument, 99 | """ 100 | Access route with 'initial' request and 'staff' role. 101 | 102 | :param lti: `lti` object 103 | :return: string "hi" 104 | """ 105 | return "hi" 106 | 107 | 108 | @app.route("/initial_student", methods=['GET', 'POST']) 109 | @lti_chalice(error=error, request='initial', role='student', app=app) 110 | def initial_student_route(lti): 111 | # pylint: disable=unused-argument, 112 | """ 113 | Access route with 'initial' request and 'student' role. 114 | 115 | :param lti: `lti` object 116 | :return: string "hi" 117 | """ 118 | return "hi" 119 | 120 | 121 | @app.route("/initial_unknown", methods=['GET', 'POST']) 122 | @lti_chalice(error=error, request='initial', role='unknown', app=app) 123 | def initial_unknown_route(lti): 124 | # pylint: disable=unused-argument, 125 | """ 126 | Access route with 'initial' request and 'unknown' role. 127 | 128 | :param lti: `lti` object 129 | :return: string "hi" 130 | """ 131 | return "hi" # pragma: no cover 132 | 133 | 134 | # @app.route("/close_session") 135 | # @lti_chalice(error=error, request='session', app=app) 136 | # def logout_route(lti): 137 | # """ 138 | # Access 'close_session' route. 139 | 140 | # :param lti: `lti` object 141 | # :return: string "session closed" 142 | # """ 143 | # lti.close_session() 144 | # return "session closed" 145 | 146 | 147 | @app.route("/post_grade/{grade}") 148 | @lti_chalice(error=error, request='initial', app=app) 149 | def post_grade(grade, lti): 150 | """ 151 | Access route with 'session' request. 152 | 153 | :param lti: `lti` object 154 | :return: string "grade={}" 155 | """ 156 | ret = lti.post_grade(grade) 157 | return "grade={}".format(ret) 158 | 159 | 160 | @app.route("/post_grade2/{grade}") 161 | @lti_chalice(error=error, request='initial', app=app) 162 | def post_grade2(grade, lti): 163 | """ 164 | Access route with 'session' request. 165 | 166 | :param lti: `lti` object 167 | :return: string "grade={}" 168 | """ 169 | ret = lti.post_grade2(grade) 170 | return "grade={}".format(ret) 171 | 172 | 173 | @app.route("/default_lti") 174 | @lti_chalice 175 | def default_lti(lti=lti_chalice): 176 | # pylint: disable=unused-argument, 177 | """ 178 | Make sure default LTI decorator works. 179 | """ 180 | return 'hi' # pragma: no cover 181 | -------------------------------------------------------------------------------- /pylti/tests/test_common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Test pylti/test_common.py module 4 | """ 5 | import unittest 6 | import semantic_version 7 | 8 | import httpretty 9 | import oauthlib.oauth1 10 | 11 | from six.moves.urllib.parse import urlencode, urlparse, parse_qs 12 | 13 | import pylti 14 | from pylti.common import ( 15 | LTIOAuthServer, 16 | verify_request_common, 17 | LTIException, 18 | post_message, 19 | post_message2, 20 | generate_request_xml 21 | ) 22 | from pylti.tests.util import TEST_CLIENT_CERT 23 | 24 | 25 | class ExceptionHandler(object): 26 | """ 27 | Custom exception handler. 28 | """ 29 | exception = None 30 | 31 | def set(self, exception): 32 | """ 33 | Setter: set class variable exception. 34 | """ 35 | self.exception = exception 36 | 37 | def get(self): 38 | """ 39 | Return exception if not None otherwise returns None. 40 | """ 41 | if self.exception is None: 42 | return None 43 | else: 44 | return self.exception['exception'] 45 | 46 | def reset(self): 47 | """ 48 | Reset variable exception 49 | """ 50 | self.exception = None 51 | 52 | 53 | class TestCommon(unittest.TestCase): 54 | """ 55 | Tests for common.py 56 | """ 57 | # pylint: disable=too-many-public-methods 58 | 59 | # Valid XML response from LTI 1.0 consumer 60 | expected_response = """ 61 | 63 | 64 | 65 | V1.0 66 | edX_fix 67 | 68 | success 69 | status 70 | Score for StarX/StarX_DEMO/201X_StarX:\ 71 | edge.edx.org-i4x-StarX-StarX_DEMO-lti-40559041895b4065b2818c23b9cd9da8\ 72 | :18b71d3c46cb4dbe66a7c950d88e78ec is now 0.0 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | """ 81 | 82 | @staticmethod 83 | def test_version(): 84 | """ 85 | Will raise ValueError if not a semantic version 86 | """ 87 | semantic_version.Version(pylti.__version__) 88 | 89 | def test_lti_oauth_server(self): 90 | """ 91 | Tests that LTIOAuthServer works 92 | """ 93 | consumers = { 94 | "key1": {"secret": "secret1"}, 95 | "key2": {"secret": "secret2"}, 96 | "key3": {"secret": "secret3"}, 97 | "keyNS": {"test": "test"}, 98 | "keyWCert": {"secret": "secret", "cert": "cert"}, 99 | } 100 | store = LTIOAuthServer(consumers) 101 | self.assertEqual(store.lookup_consumer("key1").secret, "secret1") 102 | self.assertEqual(store.lookup_consumer("key2").secret, "secret2") 103 | self.assertEqual(store.lookup_consumer("key3").secret, "secret3") 104 | self.assertEqual(store.lookup_cert("keyWCert"), "cert") 105 | self.assertIsNone(store.lookup_consumer("key4")) 106 | self.assertIsNone(store.lookup_cert("key4")) 107 | self.assertIsNone(store.lookup_consumer("keyNS")) 108 | self.assertIsNone(store.lookup_cert("keyNS")) 109 | 110 | def test_lti_oauth_server_no_consumers(self): 111 | """ 112 | If consumers are not given it there are no consumer to return. 113 | """ 114 | 115 | store = LTIOAuthServer(None) 116 | self.assertIsNone(store.lookup_consumer("key1")) 117 | self.assertIsNone(store.lookup_cert("key1")) 118 | 119 | def test_verify_request_common(self): 120 | """ 121 | verify_request_common succeeds on valid request 122 | """ 123 | headers = dict() 124 | consumers, method, url, verify_params, _ = ( 125 | self.generate_oauth_request() 126 | ) 127 | ret = verify_request_common(consumers, url, method, 128 | headers, verify_params) 129 | self.assertTrue(ret) 130 | 131 | def test_verify_request_common_via_proxy(self): 132 | """ 133 | verify_request_common succeeds on valid request via proxy 134 | """ 135 | headers = dict() 136 | headers['X-Forwarded-Proto'] = 'https' 137 | orig_url = 'https://localhost:5000/?' 138 | consumers, method, url, verify_params, _ = ( 139 | self.generate_oauth_request(url_to_sign=orig_url) 140 | ) 141 | 142 | ret = verify_request_common(consumers, url, method, 143 | headers, verify_params) 144 | self.assertTrue(ret) 145 | 146 | def test_verify_request_common_via_proxy_wsgi_syntax(self): 147 | """ 148 | verify_request_common succeeds on valid request via proxy with 149 | wsgi syntax for headers 150 | """ 151 | headers = dict() 152 | headers['HTTP_X_FORWARDED_PROTO'] = 'https' 153 | orig_url = 'https://localhost:5000/?' 154 | consumers, method, url, verify_params, _ = ( 155 | self.generate_oauth_request(url_to_sign=orig_url) 156 | ) 157 | 158 | ret = verify_request_common(consumers, url, method, 159 | headers, verify_params) 160 | self.assertTrue(ret) 161 | 162 | def test_verify_request_common_no_oauth_fields(self): 163 | """ 164 | verify_request_common fails on missing authentication 165 | """ 166 | headers = dict() 167 | consumers, method, url, _, params = ( 168 | self.generate_oauth_request() 169 | ) 170 | with self.assertRaises(LTIException): 171 | verify_request_common(consumers, url, method, headers, params) 172 | 173 | def test_verify_request_common_no_params(self): 174 | """ 175 | verify_request_common fails on missing parameters 176 | """ 177 | consumers = { 178 | "__consumer_key__": {"secret": "__lti_secret__"} 179 | } 180 | url = 'http://localhost:5000/?' 181 | method = 'GET' 182 | headers = dict() 183 | params = dict() 184 | with self.assertRaises(LTIException): 185 | verify_request_common(consumers, url, method, headers, params) 186 | 187 | @httpretty.activate 188 | def test_post_response_invalid_xml(self): 189 | """ 190 | Test post message with invalid XML response 191 | """ 192 | uri = (u'https://edge.edx.org/courses/MITx/ODL_ENG/2014_T1/xblock/' 193 | u'i4x:;_;_MITx;_ODL_ENG;_lti;_94173d3e79d145fd8ec2e83f15836ac8/' 194 | u'handler_noauth/grade_handler') 195 | 196 | def request_callback(request, cburi, headers): 197 | # pylint: disable=unused-argument 198 | """ 199 | Mock success response. 200 | """ 201 | return 200, headers, "success" 202 | 203 | httpretty.register_uri(httpretty.POST, uri, body=request_callback) 204 | consumers = { 205 | "__consumer_key__": {"secret": "__lti_secret__"} 206 | } 207 | body = '' 208 | ret = post_message(consumers, "__consumer_key__", uri, body) 209 | self.assertFalse(ret) 210 | 211 | @httpretty.activate 212 | def test_post_response_valid_xml(self): 213 | """ 214 | Test post grade with valid XML response 215 | """ 216 | uri = 'https://localhost:8000/dev_stack' 217 | 218 | def request_callback(request, cburi, headers): 219 | # pylint: disable=unused-argument, 220 | """ 221 | Mock expected response. 222 | """ 223 | return 200, headers, self.expected_response 224 | 225 | httpretty.register_uri(httpretty.POST, uri, body=request_callback) 226 | consumers = { 227 | "__consumer_key__": { 228 | "secret": "__lti_secret__", 229 | "cert": TEST_CLIENT_CERT, 230 | }, 231 | } 232 | body = generate_request_xml('message_identifier_id', 'operation', 233 | 'lis_result_sourcedid', '1.0') 234 | ret = post_message(consumers, "__consumer_key__", uri, body) 235 | self.assertTrue(ret) 236 | 237 | ret = post_message2(consumers, "__consumer_key__", uri, body) 238 | self.assertTrue(ret) 239 | 240 | def test_generate_xml(self): 241 | """ 242 | Generated post XML is valid 243 | """ 244 | xml = generate_request_xml('message_identifier_id', 'operation', 245 | 'lis_result_sourcedid', 'score') 246 | self.assertEqual(xml, """ 247 | V1.0\ 249 | message_identifier_id\ 250 | \ 251 | \ 252 | lis_result_sourcedid\ 253 | enscore\ 254 | \ 255 | """) 256 | xml = generate_request_xml('message_identifier_id', 'operation', 257 | 'lis_result_sourcedid', None) 258 | self.assertEqual(xml, """ 259 | V1.0\ 261 | message_identifier_id\ 262 | \ 263 | \ 264 | lis_result_sourcedid\ 265 | """) 266 | 267 | @staticmethod 268 | def generate_oauth_request(url_to_sign=None): 269 | """ 270 | This code generated valid LTI 1.0 basic-lti-launch-request request 271 | """ 272 | consumers = { 273 | "__consumer_key__": {"secret": "__lti_secret__"} 274 | } 275 | url = url_to_sign or 'http://localhost:5000/?' 276 | method = 'GET' 277 | params = {'resource_link_id': u'edge.edx.org-i4x-MITx-ODL_ENG-' 278 | u'lti-94173d3e79d145fd8ec2e83f15836ac8', 279 | 'user_id': u'008437924c9852377e8994829aaac7a1', 280 | 'roles': u'Instructor', 281 | 'lis_result_sourcedid': u'MITx/ODL_ENG/2014_T1:edge.edx.org-' 282 | u'i4x-MITx-ODL_ENG-lti-' 283 | u'94173d3e79d145fd8ec2e83f15836ac8' 284 | u':008437924c9852377e8994829aaac7a1', 285 | 'context_id': u'MITx/ODL_ENG/2014_T1', 286 | 'lti_version': u'LTI-1p0', 287 | 'launch_presentation_return_url': u'', 288 | 'lis_outcome_service_url': u'https://edge.edx.org/courses/' 289 | u'MITx/ODL_ENG/2014_T1/xblock/' 290 | u'i4x:;_;_MITx;_ODL_ENG;_lti;_' 291 | u'94173d3e79d145fd8ec2e83f1583' 292 | u'6ac8/handler_noauth' 293 | u'/grade_handler', 294 | 'lti_message_type': u'basic-lti-launch-request'} 295 | urlparams = urlencode(params) 296 | 297 | client = oauthlib.oauth1.Client('__consumer_key__', 298 | client_secret='__lti_secret__', 299 | signature_method=oauthlib.oauth1. 300 | SIGNATURE_HMAC, 301 | signature_type=oauthlib.oauth1. 302 | SIGNATURE_TYPE_QUERY) 303 | signature = client.sign("{}{}".format(url, urlparams)) 304 | 305 | url_parts = urlparse(signature[0]) 306 | query_string = parse_qs(url_parts.query, keep_blank_values=True) 307 | verify_params = dict() 308 | for key, value in query_string.items(): 309 | verify_params[key] = value[0] 310 | return consumers, method, url, verify_params, params 311 | -------------------------------------------------------------------------------- /pylti/tests/test_flask.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Test pylti/test_flask.py module 4 | """ 5 | from __future__ import absolute_import 6 | import unittest 7 | 8 | import httpretty 9 | import mock 10 | import oauthlib.oauth1 11 | 12 | from six.moves.urllib.parse import urlencode 13 | 14 | from pylti.common import LTIException 15 | from pylti.flask import LTI 16 | from pylti.tests.test_flask_app import app_exception, app 17 | 18 | 19 | class TestFlask(unittest.TestCase): 20 | """ 21 | Consumers. 22 | """ 23 | # pylint: disable=too-many-public-methods 24 | consumers = { 25 | "__consumer_key__": {"secret": "__lti_secret__"} 26 | } 27 | 28 | # Valid XML response from LTI 1.0 consumer 29 | expected_response = """ 30 | 32 | 33 | 34 | V1.0 35 | edX_fix 36 | 37 | success 38 | status 39 | Score for StarX/StarX_DEMO/201X_StarX:\ 40 | edge.edx.org-i4x-StarX-StarX_DEMO-lti-40559041895b4065b2818c23b9cd9da8\ 41 | :18b71d3c46cb4dbe66a7c950d88e78ec is now 0.0 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | """ 50 | 51 | def setUp(self): 52 | """ 53 | Setting up app config. 54 | """ 55 | app.config['TESTING'] = True 56 | app.config['SERVER_NAME'] = 'localhost' 57 | app.config['WTF_CSRF_ENABLED'] = False 58 | app.config['SECRET_KEY'] = 'you-will-never-guess' 59 | app.config['PYLTI_CONFIG'] = {'consumers': self.consumers} 60 | app.config['PYLTI_URL_FIX'] = { 61 | "https://localhost:8000/": { 62 | "https://localhost:8000/": "http://localhost:8000/" 63 | } 64 | } 65 | self.app = app.test_client() 66 | app_exception.reset() 67 | 68 | @staticmethod 69 | def get_exception(): 70 | """ 71 | Returns exception raised by PyLTI. 72 | :return: exception 73 | """ 74 | return app_exception.get() 75 | 76 | @staticmethod 77 | def has_exception(): 78 | """ 79 | Check if PyLTI raised exception. 80 | :return: is exception raised 81 | """ 82 | return app_exception.get() is not None 83 | 84 | @staticmethod 85 | def get_exception_as_string(): 86 | """ 87 | Return text of the exception raised by LTI. 88 | :return: text 89 | """ 90 | return "{}".format(TestFlask.get_exception()) 91 | 92 | def test_access_to_oauth_resource_unknown_protection(self): 93 | """ 94 | Invalid LTI request scope. 95 | """ 96 | self.app.get('/unknown_protection') 97 | self.assertTrue(self.has_exception()) 98 | self.assertIsInstance(self.get_exception(), LTIException) 99 | self.assertEqual(self.get_exception_as_string(), 100 | 'Unknown request type') 101 | 102 | def test_access_to_oauth_resource_without_authorization_any(self): 103 | """ 104 | Accessing LTI without establishing session. 105 | """ 106 | self.app.get('/any') 107 | self.assertTrue(self.has_exception()) 108 | self.assertIsInstance(self.get_exception(), LTIException) 109 | self.assertEqual(self.get_exception_as_string(), 110 | 'Session expired or unavailable') 111 | 112 | def test_access_to_oauth_resource_without_authorization_session(self): 113 | """ 114 | Accessing LTI session scope before session established. 115 | """ 116 | self.app.get('/session') 117 | self.assertTrue(self.has_exception()) 118 | self.assertIsInstance(self.get_exception(), LTIException) 119 | self.assertEqual(self.get_exception_as_string(), 120 | 'Session expired or unavailable') 121 | 122 | def test_access_to_oauth_resource_without_authorization_initial_get(self): 123 | """ 124 | Accessing LTI without basic-lti-launch-request parameters as GET. 125 | """ 126 | self.app.get('/initial') 127 | self.assertTrue(self.has_exception()) 128 | self.assertIsInstance(self.get_exception(), LTIException) 129 | self.assertEqual(self.get_exception_as_string(), 130 | 'This page requires a valid oauth session or request') 131 | 132 | def test_access_to_oauth_resource_without_authorization_initial_post(self): 133 | """ 134 | Accessing LTI without basic-lti-launch-request parameters as POST. 135 | """ 136 | self.app.post('/initial') 137 | self.assertTrue(self.has_exception()) 138 | self.assertIsInstance(self.get_exception(), LTIException) 139 | self.assertEqual(self.get_exception_as_string(), 140 | 'This page requires a valid oauth session or request') 141 | 142 | def test_access_to_oauth_resource_in_session(self): 143 | """ 144 | Accessing LTI after session established. 145 | """ 146 | self.app.get('/setup_session') 147 | 148 | self.app.get('/session') 149 | self.assertFalse(self.has_exception()) 150 | 151 | def test_access_to_oauth_resource_in_session_with_close(self): 152 | """ 153 | Accessing LTI after session closed. 154 | """ 155 | self.app.get('/setup_session') 156 | 157 | self.app.get('/session') 158 | 159 | self.assertFalse(self.has_exception()) 160 | 161 | self.app.get('/close_session') 162 | 163 | self.app.get('/session') 164 | 165 | self.assertTrue(self.has_exception()) 166 | 167 | def test_access_to_oauth_resource(self): 168 | """ 169 | Accessing oauth_resource. 170 | """ 171 | consumers = self.consumers 172 | url = 'http://localhost/initial?' 173 | new_url = self.generate_launch_request(consumers, url) 174 | 175 | self.app.get(new_url) 176 | self.assertFalse(self.has_exception()) 177 | 178 | def test_access_to_oauth_resource_name_passed(self): 179 | """ 180 | Check that name is returned if passed via initial request. 181 | """ 182 | # pylint: disable=maybe-no-member 183 | consumers = self.consumers 184 | url = 'http://localhost/name?' 185 | add_params = {u'lis_person_sourcedid': u'person'} 186 | new_url = self.generate_launch_request( 187 | consumers, url, add_params=add_params 188 | ) 189 | 190 | ret = self.app.get(new_url) 191 | self.assertFalse(self.has_exception()) 192 | self.assertEqual(ret.data.decode('utf-8'), u'person') 193 | 194 | def test_access_to_oauth_resource_email_passed(self): 195 | """ 196 | Check that email is returned if passed via initial request. 197 | """ 198 | # pylint: disable=maybe-no-member 199 | consumers = self.consumers 200 | url = 'http://localhost/name?' 201 | add_params = {u'lis_person_contact_email_primary': u'email@email.com'} 202 | new_url = self.generate_launch_request( 203 | consumers, url, add_params=add_params 204 | ) 205 | 206 | ret = self.app.get(new_url) 207 | self.assertFalse(self.has_exception()) 208 | self.assertEqual(ret.data.decode('utf-8'), u'email@email.com') 209 | 210 | def test_access_to_oauth_resource_name_and_email_passed(self): 211 | """ 212 | Check that name is returned if both email and name passed. 213 | """ 214 | # pylint: disable=maybe-no-member 215 | consumers = self.consumers 216 | url = 'http://localhost/name?' 217 | add_params = {u'lis_person_sourcedid': u'person', 218 | u'lis_person_contact_email_primary': u'email@email.com'} 219 | new_url = self.generate_launch_request( 220 | consumers, url, add_params=add_params 221 | ) 222 | 223 | ret = self.app.get(new_url) 224 | self.assertFalse(self.has_exception()) 225 | self.assertEqual(ret.data.decode('utf-8'), u'person') 226 | 227 | def test_access_to_oauth_resource_staff_only_as_student(self): 228 | """ 229 | Deny access if user not in role. 230 | """ 231 | consumers = self.consumers 232 | url = 'http://localhost/initial_staff?' 233 | student_url = self.generate_launch_request( 234 | consumers, url, roles='Student' 235 | ) 236 | self.app.get(student_url) 237 | self.assertTrue(self.has_exception()) 238 | 239 | learner_url = self.generate_launch_request( 240 | consumers, url, roles='Learner' 241 | ) 242 | self.app.get(learner_url) 243 | self.assertTrue(self.has_exception()) 244 | 245 | def test_access_to_oauth_resource_staff_only_as_administrator(self): 246 | """ 247 | Allow access if user in role. 248 | """ 249 | consumers = self.consumers 250 | url = 'http://localhost/initial_staff?' 251 | new_url = self.generate_launch_request( 252 | consumers, url, roles='Administrator' 253 | ) 254 | 255 | self.app.get(new_url) 256 | self.assertFalse(self.has_exception()) 257 | 258 | def test_access_to_oauth_resource_staff_only_as_unknown_role(self): 259 | """ 260 | Deny access if role not defined. 261 | """ 262 | consumers = self.consumers 263 | url = 'http://localhost/initial_staff?' 264 | admin_url = self.generate_launch_request( 265 | consumers, url, roles='Foo' 266 | ) 267 | 268 | self.app.get(admin_url) 269 | self.assertTrue(self.has_exception()) 270 | 271 | def test_access_to_oauth_resource_student_as_student(self): 272 | """ 273 | Verify that the various roles we consider as students are students. 274 | """ 275 | consumers = self.consumers 276 | url = 'http://localhost/initial_student?' 277 | 278 | # Learner Role 279 | learner_url = self.generate_launch_request( 280 | consumers, url, roles='Learner' 281 | ) 282 | self.app.get(learner_url) 283 | self.assertFalse(self.has_exception()) 284 | 285 | student_url = self.generate_launch_request( 286 | consumers, url, roles='Student' 287 | ) 288 | self.app.get(student_url) 289 | self.assertFalse(self.has_exception()) 290 | 291 | def test_access_to_oauth_resource_student_as_staff(self): 292 | """Verify staff doesn't have access to student only.""" 293 | consumers = self.consumers 294 | url = 'http://localhost/initial_student?' 295 | staff_url = self.generate_launch_request( 296 | consumers, url, roles='Instructor' 297 | ) 298 | self.app.get(staff_url) 299 | self.assertTrue(self.has_exception()) 300 | 301 | def test_access_to_oauth_resource_student_as_unknown(self): 302 | """Verify staff doesn't have access to student only.""" 303 | consumers = self.consumers 304 | url = 'http://localhost/initial_student?' 305 | unknown_url = self.generate_launch_request( 306 | consumers, url, roles='FooBar' 307 | ) 308 | self.app.get(unknown_url) 309 | self.assertTrue(self.has_exception()) 310 | 311 | @staticmethod 312 | def generate_launch_request(consumers, url, 313 | lit_outcome_service_url=None, 314 | roles=u'Instructor', 315 | add_params=None, 316 | include_lti_message_type=False): 317 | """ 318 | Generate valid basic-lti-launch-request request with options. 319 | :param consumers: consumer map 320 | :param url: URL to sign 321 | :param lit_outcome_service_url: LTI callback 322 | :param roles: LTI role 323 | :return: signed request 324 | """ 325 | # pylint: disable=unused-argument, too-many-arguments 326 | params = {'resource_link_id': u'edge.edx.org-i4x-MITx-ODL_ENG-lti-' 327 | u'94173d3e79d145fd8ec2e83f15836ac8', 328 | 'user_id': u'008437924c9852377e8994829aaac7a1', 329 | 'lis_result_sourcedid': u'MITx/ODL_ENG/2014_T1:' 330 | u'edge.edx.org-i4x-MITx-ODL_ENG-lti-' 331 | u'94173d3e79d145fd8ec2e83f15836ac8:' 332 | u'008437924c9852377e8994829aaac7a1', 333 | 'context_id': u'MITx/ODL_ENG/2014_T1', 334 | 'lti_version': u'LTI-1p0', 335 | 'launch_presentation_return_url': u'', 336 | 'lis_outcome_service_url': (lit_outcome_service_url or 337 | u'https://example.edu/' 338 | u'courses/MITx/ODL_ENG/' 339 | u'2014_T1/xblock/i4x:;_;' 340 | u'_MITx;_ODL_ENG;_lti;' 341 | u'_94173d3e79d145fd8ec2e' 342 | u'83f15836ac8' 343 | u'/handler_noauth/' 344 | u'grade_handler')} 345 | 346 | if include_lti_message_type: 347 | params['lti_message_type'] = u'basic-lti-launch-request' 348 | 349 | if roles is not None: 350 | params['roles'] = roles 351 | 352 | if add_params is not None: 353 | params.update(add_params) 354 | 355 | urlparams = urlencode(params) 356 | 357 | client = oauthlib.oauth1.Client('__consumer_key__', 358 | client_secret='__lti_secret__', 359 | signature_method=oauthlib.oauth1. 360 | SIGNATURE_HMAC, 361 | signature_type=oauthlib.oauth1. 362 | SIGNATURE_TYPE_QUERY) 363 | signature = client.sign("{}{}".format(url, urlparams)) 364 | signed_url = signature[0] 365 | new_url = signed_url[len('http://localhost'):] 366 | return new_url 367 | 368 | def test_access_to_oauth_resource_any(self): 369 | """ 370 | Test access to LTI protected resources. 371 | """ 372 | url = 'http://localhost/any?' 373 | new_url = self.generate_launch_request(self.consumers, url) 374 | self.app.post(new_url) 375 | self.assertFalse(self.has_exception()) 376 | 377 | def test_access_to_oauth_resource_any_norole(self): 378 | """ 379 | Test access to LTI protected resources. 380 | """ 381 | url = 'http://localhost/any?' 382 | new_url = self.generate_launch_request(self.consumers, url, roles=None) 383 | self.app.post(new_url) 384 | self.assertFalse(self.has_exception()) 385 | 386 | def test_access_to_oauth_resource_any_nonstandard_role(self): 387 | """ 388 | Test access to LTI protected resources. 389 | """ 390 | url = 'http://localhost/any?' 391 | new_url = self.generate_launch_request(self.consumers, url, 392 | roles=u'ThisIsNotAStandardRole') 393 | self.app.post(new_url) 394 | self.assertFalse(self.has_exception()) 395 | 396 | def test_access_to_oauth_resource_invalid(self): 397 | """ 398 | Deny access to LTI protected resources 399 | on man in the middle attack. 400 | """ 401 | url = 'http://localhost/initial?' 402 | new_url = self.generate_launch_request(self.consumers, url) 403 | 404 | self.app.get("{}&FAIL=TRUE".format(new_url)) 405 | self.assertTrue(self.has_exception()) 406 | self.assertIsInstance(self.get_exception(), LTIException) 407 | self.assertEqual(self.get_exception_as_string(), 408 | 'OAuth error: Please check your key and secret') 409 | 410 | def test_access_to_oauth_resource_invalid_after_session_setup(self): 411 | """ 412 | Remove browser session on man in the middle attach. 413 | """ 414 | self.app.get('/setup_session') 415 | self.app.get('/session') 416 | self.assertFalse(self.has_exception()) 417 | 418 | url = 'http://localhost/initial?' 419 | new_url = self.generate_launch_request(self.consumers, url) 420 | 421 | self.app.get("{}&FAIL=TRUE".format(new_url)) 422 | self.assertTrue(self.has_exception()) 423 | self.assertIsInstance(self.get_exception(), LTIException) 424 | self.assertEqual(self.get_exception_as_string(), 425 | 'OAuth error: Please check your key and secret') 426 | 427 | @httpretty.activate 428 | def test_access_to_oauth_resource_post_grade(self): 429 | """ 430 | Check post_grade functionality. 431 | """ 432 | # pylint: disable=maybe-no-member 433 | uri = (u'https://example.edu/courses/MITx/ODL_ENG/2014_T1/xblock/' 434 | u'i4x:;_;_MITx;_ODL_ENG;_lti;' 435 | u'_94173d3e79d145fd8ec2e83f15836ac8/handler_noauth' 436 | u'/grade_handler') 437 | 438 | httpretty.register_uri(httpretty.POST, uri, body=self.request_callback) 439 | 440 | consumers = self.consumers 441 | url = 'http://localhost/initial?' 442 | new_url = self.generate_launch_request(consumers, url) 443 | 444 | ret = self.app.get(new_url) 445 | self.assertFalse(self.has_exception()) 446 | 447 | ret = self.app.get("/post_grade/1.0") 448 | self.assertFalse(self.has_exception()) 449 | self.assertEqual(ret.data.decode('utf-8'), "grade=True") 450 | 451 | ret = self.app.get("/post_grade/2.0") 452 | self.assertFalse(self.has_exception()) 453 | self.assertEqual(ret.data.decode('utf-8'), "grade=False") 454 | 455 | @httpretty.activate 456 | def test_access_to_oauth_resource_post_grade_fail(self): 457 | """ 458 | Check post_grade functionality fails on invalid response. 459 | """ 460 | # pylint: disable=maybe-no-member 461 | uri = (u'https://example.edu/courses/MITx/ODL_ENG/2014_T1/xblock/' 462 | u'i4x:;_;_MITx;_ODL_ENG;_lti;' 463 | u'_94173d3e79d145fd8ec2e83f15836ac8/handler_noauth' 464 | u'/grade_handler') 465 | 466 | def request_callback(request, cburi, headers): 467 | # pylint: disable=unused-argument 468 | """ 469 | Mock error response callback. 470 | """ 471 | return 200, headers, "wrong_response" 472 | 473 | httpretty.register_uri(httpretty.POST, uri, body=request_callback) 474 | 475 | consumers = self.consumers 476 | url = 'http://localhost/initial?' 477 | new_url = self.generate_launch_request(consumers, url) 478 | ret = self.app.get(new_url) 479 | self.assertFalse(self.has_exception()) 480 | self.assertFalse(self.has_exception()) 481 | 482 | ret = self.app.get("/post_grade/1.0") 483 | self.assertTrue(self.has_exception()) 484 | self.assertEqual(ret.data.decode('utf-8'), "error") 485 | 486 | @httpretty.activate 487 | def test_access_to_oauth_resource_post_grade_fix_url(self): 488 | """ 489 | Make sure URL remap works for edX vagrant stack. 490 | """ 491 | # pylint: disable=maybe-no-member 492 | uri = 'https://localhost:8000/dev_stack' 493 | 494 | httpretty.register_uri(httpretty.POST, uri, body=self.request_callback) 495 | 496 | url = 'http://localhost/initial?' 497 | new_url = self.generate_launch_request( 498 | self.consumers, url, lit_outcome_service_url=uri 499 | ) 500 | ret = self.app.get(new_url) 501 | self.assertFalse(self.has_exception()) 502 | 503 | ret = self.app.get("/post_grade/1.0") 504 | self.assertFalse(self.has_exception()) 505 | self.assertEqual(ret.data.decode('utf-8'), "grade=True") 506 | 507 | ret = self.app.get("/post_grade/2.0") 508 | self.assertFalse(self.has_exception()) 509 | self.assertEqual(ret.data.decode('utf-8'), "grade=False") 510 | 511 | @httpretty.activate 512 | def test_access_to_oauth_resource_post_grade2(self): 513 | """ 514 | Check post_grade edX LTI2 functionality. 515 | """ 516 | uri = (u'https://example.edu/courses/MITx/ODL_ENG/2014_T1/xblock/' 517 | u'i4x:;_;_MITx;_ODL_ENG;_lti;' 518 | u'_94173d3e79d145fd8ec2e83f15836ac8/handler_noauth' 519 | u'/lti_2_0_result_rest_handler/user/' 520 | u'008437924c9852377e8994829aaac7a1') 521 | 522 | httpretty.register_uri(httpretty.PUT, uri, body=self.request_callback) 523 | 524 | consumers = self.consumers 525 | url = 'http://localhost/initial?' 526 | new_url = self.generate_launch_request(consumers, url) 527 | 528 | ret = self.app.get(new_url) 529 | self.assertFalse(self.has_exception()) 530 | 531 | ret = self.app.get("/post_grade2/1.0") 532 | self.assertFalse(self.has_exception()) 533 | self.assertEqual(ret.data.decode('utf-8'), "grade=True") 534 | 535 | ret = self.app.get("/post_grade2/2.0") 536 | self.assertFalse(self.has_exception()) 537 | self.assertEqual(ret.data.decode('utf-8'), "grade=False") 538 | 539 | def request_callback(self, request, cburi, headers): 540 | # pylint: disable=unused-argument 541 | """ 542 | Mock expected response. 543 | """ 544 | return 200, headers, self.expected_response 545 | 546 | @httpretty.activate 547 | def test_access_to_oauth_resource_post_grade2_fail(self): 548 | """ 549 | Check post_grade edX LTI2 functionality 550 | """ 551 | uri = (u'https://example.edu/courses/MITx/ODL_ENG/2014_T1/xblock/' 552 | u'i4x:;_;_MITx;_ODL_ENG;_lti;' 553 | u'_94173d3e79d145fd8ec2e83f15836ac8/handler_noauth' 554 | u'/lti_2_0_result_rest_handler/user/' 555 | u'008437924c9852377e8994829aaac7a1') 556 | 557 | def request_callback(request, cburi, headers): 558 | # pylint: disable=unused-argument 559 | """ 560 | Mock expected response. 561 | """ 562 | return 400, headers, self.expected_response 563 | 564 | httpretty.register_uri(httpretty.PUT, uri, body=request_callback) 565 | 566 | consumers = self.consumers 567 | url = 'http://localhost/initial?' 568 | new_url = self.generate_launch_request(consumers, url) 569 | 570 | ret = self.app.get(new_url) 571 | self.assertFalse(self.has_exception()) 572 | 573 | ret = self.app.get("/post_grade2/1.0") 574 | self.assertTrue(self.has_exception()) 575 | self.assertEqual(ret.data.decode('utf-8'), "error") 576 | 577 | @mock.patch.object(LTI, '_check_role') 578 | @mock.patch.object(LTI, 'verify') 579 | def test_decorator_no_app(self, mock_verify, _): 580 | """Verify the decorator doesn't require the app object.""" 581 | # pylint: disable=maybe-no-member 582 | mock_verify.return_value = True 583 | response = self.app.get('/no_app') 584 | self.assertEqual(200, response.status_code) 585 | self.assertEqual('hi', response.data.decode('utf-8')) 586 | 587 | def test_default_decorator(self): 588 | """ 589 | Verify default decorator works. 590 | """ 591 | url = 'http://localhost/default_lti?' 592 | new_url = self.generate_launch_request(self.consumers, url) 593 | self.app.get(new_url) 594 | self.assertFalse(self.has_exception()) 595 | 596 | def test_default_decorator_bad(self): 597 | """ 598 | Verify error handling works. 599 | """ 600 | # Validate we get our error page when there is a bad LTI 601 | # request 602 | # pylint: disable=maybe-no-member 603 | response = self.app.get('/default_lti') 604 | self.assertEqual(500, response.status_code) 605 | self.assertEqual("There was an LTI communication error", 606 | response.data.decode('utf-8')) 607 | -------------------------------------------------------------------------------- /pylti/tests/test_flask_app.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test pylti/test_flask_app.py module 3 | """ 4 | from flask import Flask, session 5 | 6 | from pylti.flask import lti as lti_flask 7 | from pylti.common import LTI_SESSION_KEY 8 | from pylti.tests.test_common import ExceptionHandler 9 | 10 | app = Flask(__name__) # pylint: disable=invalid-name 11 | app_exception = ExceptionHandler() # pylint: disable=invalid-name 12 | 13 | 14 | def error(exception): 15 | """ 16 | Set exception to exception handler and returns error string. 17 | """ 18 | app_exception.set(exception) 19 | return "error" 20 | 21 | 22 | @app.route("/unknown_protection") 23 | @lti_flask(error=error, app=app, request='notreal') 24 | def unknown_protection(lti): 25 | # pylint: disable=unused-argument, 26 | """ 27 | Access route with unknown protection. 28 | 29 | :param lti: `lti` object 30 | :return: string "hi" 31 | """ 32 | return "hi" # pragma: no cover 33 | 34 | 35 | @app.route("/no_app") 36 | @lti_flask(error=error) 37 | def no_app(lti): 38 | # pylint: disable=unused-argument, 39 | """ 40 | Use decorator without specifying LTI, raise exception. 41 | 42 | :param lti: `lti` object 43 | """ 44 | # Check that we have the app in our lti object and raise if we 45 | # don't 46 | if not lti.lti_kwargs['app']: # pragma: no cover 47 | raise Exception( 48 | 'The app is null and is not properly getting current_app' 49 | ) 50 | return 'hi' 51 | 52 | 53 | @app.route("/any") 54 | @lti_flask(error=error, request='any', app=app) 55 | def any_route(lti): 56 | # pylint: disable=unused-argument, 57 | """ 58 | Access route with 'any' request. 59 | 60 | :param lti: `lti` object 61 | :return: string "hi" 62 | """ 63 | return "hi" 64 | 65 | 66 | @app.route("/session") 67 | @lti_flask(error=error, request='session', app=app) 68 | def session_route(lti): 69 | # pylint: disable=unused-argument, 70 | """ 71 | Access route with 'session' request. 72 | 73 | :param lti: `lti` object 74 | :return: string "hi" 75 | """ 76 | return "hi" 77 | 78 | 79 | @app.route("/initial", methods=['GET', 'POST']) 80 | @lti_flask(error=error, request='initial', app=app) 81 | def initial_route(lti): 82 | # pylint: disable=unused-argument, 83 | """ 84 | Access route with 'initial' request. 85 | 86 | :param lti: `lti` object 87 | :return: string "hi" 88 | """ 89 | return "hi" 90 | 91 | 92 | @app.route("/name", methods=['GET', 'POST']) 93 | @lti_flask(error=error, request='initial', app=app) 94 | def name(lti): 95 | """ 96 | Access route with 'initial' request. 97 | 98 | :param lti: `lti` object 99 | :return: string "hi" 100 | """ 101 | return lti.name 102 | 103 | 104 | @app.route("/initial_staff", methods=['GET', 'POST']) 105 | @lti_flask(error=error, request='initial', role='staff', app=app) 106 | def initial_staff_route(lti): 107 | # pylint: disable=unused-argument, 108 | """ 109 | Access route with 'initial' request and 'staff' role. 110 | 111 | :param lti: `lti` object 112 | :return: string "hi" 113 | """ 114 | return "hi" 115 | 116 | 117 | @app.route("/initial_student", methods=['GET', 'POST']) 118 | @lti_flask(error=error, request='initial', role='student', app=app) 119 | def initial_student_route(lti): 120 | # pylint: disable=unused-argument, 121 | """ 122 | Access route with 'initial' request and 'student' role. 123 | 124 | :param lti: `lti` object 125 | :return: string "hi" 126 | """ 127 | return "hi" 128 | 129 | 130 | @app.route("/initial_unknown", methods=['GET', 'POST']) 131 | @lti_flask(error=error, request='initial', role='unknown', app=app) 132 | def initial_unknown_route(lti): 133 | # pylint: disable=unused-argument, 134 | """ 135 | Access route with 'initial' request and 'unknown' role. 136 | 137 | :param lti: `lti` object 138 | :return: string "hi" 139 | """ 140 | return "hi" # pragma: no cover 141 | 142 | 143 | @app.route("/setup_session") 144 | def setup_session(): 145 | """ 146 | Access 'setup_session' route with 'Student' role and oauth_consumer_key. 147 | 148 | :return: string "session set" 149 | """ 150 | session[LTI_SESSION_KEY] = True 151 | session['oauth_consumer_key'] = '__consumer_key__' 152 | session['roles'] = 'Student' 153 | return "session set" 154 | 155 | 156 | @app.route("/close_session") 157 | @lti_flask(error=error, request='session', app=app) 158 | def logout_route(lti): 159 | """ 160 | Access 'close_session' route. 161 | 162 | :param lti: `lti` object 163 | :return: string "session closed" 164 | """ 165 | lti.close_session() 166 | return "session closed" 167 | 168 | 169 | @app.route("/post_grade/") 170 | @lti_flask(error=error, request='session', app=app) 171 | def post_grade(grade, lti): 172 | """ 173 | Access route with 'session' request. 174 | 175 | :param lti: `lti` object 176 | :return: string "grade={}" 177 | """ 178 | ret = lti.post_grade(grade) 179 | return "grade={}".format(ret) 180 | 181 | 182 | @app.route("/post_grade2/") 183 | @lti_flask(error=error, request='session', app=app) 184 | def post_grade2(grade, lti): 185 | """ 186 | Access route with 'session' request. 187 | 188 | :param lti: `lti` object 189 | :return: string "grade={}" 190 | """ 191 | ret = lti.post_grade2(grade) 192 | return "grade={}".format(ret) 193 | 194 | 195 | @app.route("/default_lti") 196 | @lti_flask 197 | def default_lti(lti=lti_flask): 198 | # pylint: disable=unused-argument, 199 | """ 200 | Make sure default LTI decorator works. 201 | """ 202 | return 'hi' # pragma: no cover 203 | -------------------------------------------------------------------------------- /pylti/tests/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Test pylti/test_common.py module 4 | """ 5 | 6 | 7 | import os 8 | 9 | 10 | TEST_DATA_ROOT = os.path.join( 11 | os.path.dirname(os.path.realpath(__file__)), 12 | 'data' 13 | ) 14 | 15 | TEST_CLIENT_CERT = os.path.join(TEST_DATA_ROOT, 'certs', 'snakeoil.pem') 16 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = pylti -xvs -rs --pep8 --flakes 3 | # addopts = pylti -vs -rs --flakes 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==0.10.1 2 | Chalice==1.3.0 3 | httpretty==0.8.3 4 | oauth2==1.9.0.post1 5 | oauthlib==2.0.6 6 | pyflakes==1.2.3 7 | pytest==2.9.2 8 | pytest-cache==1.0 9 | pytest-cov==2.3.0 10 | pytest-flakes==1.0.1 11 | pytest-pep8==1.0.6 12 | httplib2==0.9.2 13 | six==1.11.0 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2009-2014 MIT ODL Engineering 3 | # 4 | # This file is part of PyLTI. 5 | # 6 | 7 | from __future__ import print_function 8 | 9 | import os 10 | import sys 11 | 12 | if sys.version_info < (2, 7): 13 | error = "ERROR: PyLTI requires Python 2.7+ ... exiting." 14 | print(error, file=sys.stderr) 15 | sys.exit(1) 16 | 17 | try: 18 | from setuptools import setup, find_packages 19 | from setuptools.command.test import test as testcommand 20 | 21 | class PyTest(testcommand): 22 | user_options = testcommand.user_options[:] 23 | user_options += [ 24 | ('coverage', 'C', 'Produce a coverage report for PyLTI'), 25 | ('pep8', 'P', 'Produce a pep8 report for PyLTI'), 26 | ('flakes', 'F', 'Produce a flakes report for PyLTI'), 27 | 28 | ] 29 | coverage = None 30 | pep8 = None 31 | flakes = None 32 | test_suite = False 33 | test_args = [] 34 | 35 | def initialize_options(self): 36 | testcommand.initialize_options(self) 37 | 38 | def finalize_options(self): 39 | testcommand.finalize_options(self) 40 | self.test_suite = True 41 | self.test_args = [] 42 | if self.coverage: 43 | self.test_args.append('--cov') 44 | self.test_args.append('pylti') 45 | if self.pep8: 46 | self.test_args.append('--pep8') 47 | if self.flakes: 48 | self.test_args.append('--flakes') 49 | 50 | def run_tests(self): 51 | # import here, cause outside the eggs aren't loaded 52 | import pytest 53 | errno = pytest.main(self.test_args) 54 | sys.exit(errno) 55 | 56 | extra = dict(test_suite="pylti.tests", 57 | tests_require=["pytest-cov>=2.3.0", "pytest-pep8>=1.0.6", 58 | "pytest-flakes>=1.0.1", "pytest>=2.9.2", 59 | "httpretty>=0.8.3", "flask>=0.10.1", 60 | "oauthlib>=0.6.3", "semantic_version>=2.3.1", 61 | "mock==1.0.1"], 62 | cmdclass={"test": PyTest}, 63 | install_requires=["oauth2>=1.9.0.post1", "httplib2>=0.9", "six>=1.10.0"], 64 | include_package_data=True, 65 | zip_safe=False) 66 | except ImportError as err: 67 | import string 68 | from distutils.core import setup 69 | 70 | def convert_path(pathname): 71 | """ 72 | Local copy of setuptools.convert_path used by find_packages (only used 73 | with distutils which is missing the find_packages feature) 74 | """ 75 | if os.sep == '/': 76 | return pathname 77 | if not pathname: 78 | return pathname 79 | if pathname[0] == '/': 80 | raise ValueError("path '%s' cannot be absolute" % pathname) 81 | if pathname[-1] == '/': 82 | raise ValueError("path '%s' cannot end with '/'" % pathname) 83 | paths = string.split(pathname, '/') 84 | while '.' in paths: 85 | paths.remove('.') 86 | if not paths: 87 | return os.curdir 88 | return os.path.join(*paths) 89 | 90 | def find_packages(where='.', exclude=()): 91 | """ 92 | Local copy of setuptools.find_packages (only used with distutils which 93 | is missing the find_packages feature) 94 | """ 95 | out = [] 96 | stack = [(convert_path(where), '')] 97 | while stack: 98 | where, prefix = stack.pop(0) 99 | for name in os.listdir(where): 100 | fn = os.path.join(where, name) 101 | isdir = os.path.isdir(fn) 102 | has_init = os.path.isfile(os.path.join(fn, '__init__.py')) 103 | if '.' not in name and isdir and has_init: 104 | out.append(prefix + name) 105 | stack.append((fn, prefix + name + '.')) 106 | for pat in list(exclude) + ['ez_setup', 'distribute_setup']: 107 | from fnmatch import fnmatchcase 108 | 109 | out = [item for item in out if not fnmatchcase(item, pat)] 110 | return out 111 | 112 | print("Non-Fatal Error:", err, "\n") 113 | print("Setup encountered an error while importing setuptools (see above).") 114 | print("Proceeding anyway with manual replacements for setuptools.find_packages.") 115 | print("Try installing setuptools if you continue to have problems.\n\n") 116 | 117 | extra = dict() 118 | 119 | VERSION = __import__('pylti').__version__ 120 | 121 | README = open('README.rst').read() 122 | 123 | setup( 124 | name='PyLTI', 125 | version=VERSION, 126 | packages=find_packages(), 127 | package_data={'pylti.templates': ['web/*.*', 'web/css/*', 'web/js/*']}, 128 | license='BSD', 129 | author='MIT ODL Engineering', 130 | author_email='odl-engineering@mit.edu', 131 | url="http://github.com/mitodl/pylti", 132 | description="PyLTI provides Python Implementation of IMS" 133 | " LTI interface that works with edX", 134 | long_description=README, 135 | classifiers=[ 136 | 'Development Status :: 2 - Pre-Alpha', 137 | 'Intended Audience :: Developers', 138 | 'Intended Audience :: Education', 139 | ], 140 | **extra 141 | ) 142 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest-cov>=2.3.0 2 | pytest-pep8>=1.0.6 3 | pytest-flakes>=1.0.1 4 | pytest>=2.9.2 5 | httpretty>=0.8.3 6 | flask>=0.10.1 7 | chalice>=1.3.0 8 | oauthlib>=0.6.3 9 | semantic_version>=2.3.1 10 | mock>=1.0.1 11 | oauth2>=1.9.0.post1 12 | six>=1.11.0 13 | --------------------------------------------------------------------------------