├── .editorconfig ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── add-depr-ticket-to-depr-board.yml │ ├── add-remove-label-on-comment.yml │ ├── ci.yml │ ├── commitlint.yml │ ├── pypi-publish.yml │ ├── self-assign-issue.yml │ └── upgrade-python-requirements.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.rst ├── LICENSE.txt ├── MANIFEST.in ├── Makefile ├── README.rst ├── catalog-info.yaml ├── codecov.yml ├── csrf ├── __init__.py ├── api │ ├── __init__.py │ ├── urls.py │ └── v1 │ │ ├── __init__.py │ │ ├── urls.py │ │ └── views.py ├── apps.py ├── tests │ ├── __init__.py │ └── test_api.py └── urls.py ├── docs ├── Makefile ├── __init__.py ├── _static │ └── theme_overrides.css ├── authentication.rst ├── changelog.rst ├── conf.py ├── decisions │ ├── 0001-use-changelog.rst │ ├── 0002-remove-use-jwt-cookie-header.rst │ └── index.rst ├── index.rst ├── middleware.rst ├── permissions.rst ├── settings.rst └── utils.rst ├── docs_settings.py ├── edx_rest_framework_extensions ├── __init__.py ├── auth │ ├── README.rst │ ├── __init__.py │ ├── bearer │ │ ├── __init__.py │ │ ├── authentication.py │ │ └── tests │ │ │ ├── __init__.py │ │ │ └── test_authentication.py │ ├── jwt │ │ ├── README.rst │ │ ├── __init__.py │ │ ├── authentication.py │ │ ├── constants.py │ │ ├── cookies.py │ │ ├── decoder.py │ │ ├── middleware.py │ │ └── tests │ │ │ ├── __init__.py │ │ │ ├── test_authentication.py │ │ │ ├── test_cookies.py │ │ │ ├── test_decoder.py │ │ │ ├── test_middleware.py │ │ │ └── utils.py │ └── session │ │ ├── __init__.py │ │ ├── authentication.py │ │ └── tests │ │ ├── __init__.py │ │ └── test_authentication.py ├── config.py ├── exceptions.py ├── middleware.py ├── paginators.py ├── permissions.py ├── settings.py ├── tests │ ├── __init__.py │ ├── factories.py │ ├── test_middleware.py │ ├── test_paginators.py │ ├── test_permissions.py │ └── test_settings.py └── utils.py ├── manage.py ├── openedx.yaml ├── pylintrc ├── pylintrc_tweaks ├── requirements ├── base.in ├── base.txt ├── common_constraints.txt ├── constraints.txt ├── dev.in ├── dev.txt ├── docs.in ├── docs.txt ├── pip-tools.in ├── pip-tools.txt ├── pip.in ├── pip.txt ├── test.in └── test.txt ├── setup.cfg ├── setup.py ├── test_settings.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # *************************** 2 | # ** DO NOT EDIT THIS FILE ** 3 | # *************************** 4 | # 5 | # This file was generated by edx-lint: https://github.com/openedx/edx-lint 6 | # 7 | # If you want to change this file, you have two choices, depending on whether 8 | # you want to make a local change that applies only to this repo, or whether 9 | # you want to make a central change that applies to all repos using edx-lint. 10 | # 11 | # Note: If your .editorconfig file is simply out-of-date relative to the latest 12 | # .editorconfig in edx-lint, ensure you have the latest edx-lint installed 13 | # and then follow the steps for a "LOCAL CHANGE". 14 | # 15 | # LOCAL CHANGE: 16 | # 17 | # 1. Edit the local .editorconfig_tweaks file to add changes just to this 18 | # repo's file. 19 | # 20 | # 2. Run: 21 | # 22 | # $ edx_lint write .editorconfig 23 | # 24 | # 3. This will modify the local file. Submit a pull request to get it 25 | # checked in so that others will benefit. 26 | # 27 | # 28 | # CENTRAL CHANGE: 29 | # 30 | # 1. Edit the .editorconfig file in the edx-lint repo at 31 | # https://github.com/openedx/edx-lint/blob/master/edx_lint/files/.editorconfig 32 | # 33 | # 2. install the updated version of edx-lint (in edx-lint): 34 | # 35 | # $ pip install . 36 | # 37 | # 3. Run (in edx-lint): 38 | # 39 | # $ edx_lint write .editorconfig 40 | # 41 | # 4. Make a new version of edx_lint, submit and review a pull request with the 42 | # .editorconfig update, and after merging, update the edx-lint version and 43 | # publish the new version. 44 | # 45 | # 5. In your local repo, install the newer version of edx-lint. 46 | # 47 | # 6. Run: 48 | # 49 | # $ edx_lint write .editorconfig 50 | # 51 | # 7. This will modify the local file. Submit a pull request to get it 52 | # checked in so that others will benefit. 53 | # 54 | # 55 | # 56 | # 57 | # 58 | # STAY AWAY FROM THIS FILE! 59 | # 60 | # 61 | # 62 | # 63 | # 64 | # SERIOUSLY. 65 | # 66 | # ------------------------------ 67 | # Generated by edx-lint version: 5.3.4 68 | # ------------------------------ 69 | [*] 70 | end_of_line = lf 71 | insert_final_newline = true 72 | charset = utf-8 73 | indent_style = space 74 | indent_size = 4 75 | max_line_length = 120 76 | trim_trailing_whitespace = true 77 | 78 | [{Makefile, *.mk}] 79 | indent_style = tab 80 | indent_size = 8 81 | 82 | [*.{yml,yaml,json}] 83 | indent_size = 2 84 | 85 | [*.js] 86 | indent_size = 2 87 | 88 | [*.diff] 89 | trim_trailing_whitespace = false 90 | 91 | [.git/*] 92 | trim_trailing_whitespace = false 93 | 94 | [COMMIT_EDITMSG] 95 | max_line_length = 72 96 | 97 | [*.rst] 98 | max_line_length = 79 99 | 100 | # bbcbced841ed335dd8abb7456a6b13485d701b40 101 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Description:** 2 | 3 | Describe in a couple of sentences what this PR adds 4 | 5 | **JIRA:** 6 | 7 | [XXX-XXXX](https://openedx.atlassian.net/browse/XXX-XXXX) 8 | 9 | **Additional Details** 10 | 11 | * **Dependencies:**: List dependencies on other outstanding PRs, issues, etc. 12 | * **Merge deadline:** List merge deadline (if any) 13 | * **Testing instructions:** Provide non-trivial testing instructions 14 | * **Author concerns:** List any concerns about this PR 15 | 16 | **Reviewers:** 17 | - [ ] tag reviewer 18 | 19 | **Merge checklist:** 20 | - [ ] All reviewers approved 21 | - [ ] CI build is green 22 | - [ ] Version bump if needed 23 | - [ ] Changelog record added 24 | - [ ] Documentation updated (not only docstrings) 25 | - [ ] Commits are squashed 26 | 27 | **Post merge:** 28 | - [ ] Create a tag 29 | - [ ] Check new version is pushed to PyPi after tag-triggered build is 30 | finished. 31 | - [ ] Delete working branch (if not needed anymore) 32 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Adding new check for github-actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | reviewers: 9 | - "openedx/arbi-bom" 10 | -------------------------------------------------------------------------------- /.github/workflows/add-depr-ticket-to-depr-board.yml: -------------------------------------------------------------------------------- 1 | # Run the workflow that adds new tickets that are either: 2 | # - labelled "DEPR" 3 | # - title starts with "[DEPR]" 4 | # - body starts with "Proposal Date" (this is the first template field) 5 | # to the org-wide DEPR project board 6 | 7 | name: Add newly created DEPR issues to the DEPR project board 8 | 9 | on: 10 | issues: 11 | types: [opened] 12 | 13 | jobs: 14 | routeissue: 15 | uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master 16 | secrets: 17 | GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }} 18 | GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }} 19 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/add-remove-label-on-comment.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs when a comment is made on the ticket 2 | # If the comment starts with "label: " it tries to apply 3 | # the label indicated in rest of comment. 4 | # If the comment starts with "remove label: ", it tries 5 | # to remove the indicated label. 6 | # Note: Labels are allowed to have spaces and this script does 7 | # not parse spaces (as often a space is legitimate), so the command 8 | # "label: really long lots of words label" will apply the 9 | # label "really long lots of words label" 10 | 11 | name: Allows for the adding and removing of labels via comment 12 | 13 | on: 14 | issue_comment: 15 | types: [created] 16 | 17 | jobs: 18 | add_remove_labels: 19 | uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Python CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: 8 | - '**' 9 | 10 | jobs: 11 | tests: 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest] 16 | python-version: ['3.12'] 17 | toxenv: [quality, docs, django42-drflatest, django52-drflatest] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: setup python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install pip 26 | run: pip install -r requirements/pip.txt 27 | 28 | - name: Install Dependencies 29 | run: pip install -r requirements/test.txt 30 | 31 | - name: Run Tests 32 | env: 33 | TOXENV: ${{ matrix.toxenv }} 34 | run: tox 35 | 36 | - name: Run coverage 37 | if: matrix.python-version == '3.12' && matrix.toxenv == 'django42-drflatest' 38 | uses: codecov/codecov-action@v4 39 | with: 40 | token: ${{ secrets.CODECOV_TOKEN }} 41 | flags: unittests 42 | fail_ci_if_error: true 43 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | # Run commitlint on the commit messages in a pull request. 2 | 3 | name: Lint Commit Messages 4 | 5 | on: 6 | - pull_request 7 | 8 | jobs: 9 | commitlint: 10 | uses: openedx/.github/.github/workflows/commitlint.yml@master 11 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to PyPi 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | 10 | push: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: setup python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: 3.12 20 | 21 | - name: Install pip 22 | run: pip install -r requirements/pip.txt 23 | 24 | - name: Build package 25 | run: python setup.py sdist bdist_wheel 26 | 27 | - name: Publish to PyPi 28 | uses: pypa/gh-action-pypi-publish@release/v1 29 | with: 30 | password: ${{ secrets.PYPI_UPLOAD_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/self-assign-issue.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs when a comment is made on the ticket 2 | # If the comment starts with "assign me" it assigns the author to the 3 | # ticket (case insensitive) 4 | 5 | name: Assign comment author to ticket if they say "assign me" 6 | on: 7 | issue_comment: 8 | types: [created] 9 | 10 | jobs: 11 | self_assign_by_comment: 12 | uses: openedx/.github/.github/workflows/self-assign-issue.yml@master 13 | -------------------------------------------------------------------------------- /.github/workflows/upgrade-python-requirements.yml: -------------------------------------------------------------------------------- 1 | name: Upgrade Requirements 2 | 3 | on: 4 | schedule: 5 | - cron: "0 4 * * 5" 6 | workflow_dispatch: 7 | inputs: 8 | branch: 9 | description: 'Target branch to create requirements PR against' 10 | required: true 11 | default: 'master' 12 | jobs: 13 | call-upgrade-python-requirements-workflow: 14 | with: 15 | branch: ${{ github.event.inputs.branch }} 16 | team_reviewers: "arbi-bom" 17 | email_address: arbi-bom@edx.org 18 | send_success_notification: false 19 | secrets: 20 | requirements_bot_github_token: ${{ secrets.REQUIREMENTS_BOT_GITHUB_TOKEN }} 21 | requirements_bot_github_email: ${{ secrets.REQUIREMENTS_BOT_GITHUB_EMAIL }} 22 | edx_smtp_username: ${{ secrets.EDX_SMTP_USERNAME }} 23 | edx_smtp_password: ${{ secrets.EDX_SMTP_PASSWORD }} 24 | uses: openedx/.github/.github/workflows/upgrade-python-requirements.yml@master 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | 64 | .idea/ 65 | 66 | # A common place to put virtual envs 67 | .venv 68 | .venv3 69 | 70 | # Temp file used by `make upgrade` 71 | test.tmp 72 | 73 | # vscode 74 | .vscode 75 | venv 76 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | build: 9 | os: "ubuntu-22.04" 10 | tools: 11 | python: "3.11" 12 | 13 | # Build documentation in the docs/ directory with Sphinx 14 | sphinx: 15 | configuration: docs/conf.py 16 | fail_on_warning: true 17 | 18 | python: 19 | install: 20 | - requirements: "requirements/docs.txt" 21 | - method: pip 22 | path: . 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements/base.in 2 | include requirements/test.in 3 | include requirements/docs.in 4 | include CHANGELOG.rst 5 | include LICENSE 6 | include README.rst 7 | test_settings.py 8 | include requirements/constraints.txt 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ROOT = $(shell echo "$$PWD") 2 | COVERAGE = $(ROOT)/build/coverage 3 | PACKAGE = edx_rest_framework_extensions 4 | 5 | .PHONY: clean help isort isort_check linting piptools quality requirements \ 6 | style test upgrade upgrade upgrade-piptools 7 | 8 | help: ## display this help message 9 | @echo "Please use \`make ' where is one of" 10 | @grep '^[a-zA-Z]' $(MAKEFILE_LIST) | sort | awk -F ':.*?## ' 'NF==2 {printf "\033[36m %-25s\033[0m %s\n", $$1, $$2}' 11 | 12 | clean: ## remove intermediate files 13 | find . -name '*.pyc' -delete 14 | coverage erase 15 | rm -rf build 16 | 17 | piptools: ## install pip-compile and pip-sync. 18 | pip install -qr requirements/pip.txt 19 | pip install -r requirements/pip-tools.txt 20 | 21 | upgrade-piptools: piptools # upgrade pip-tools using pip-tools. 22 | pip-compile requirements/pip-tools.in --rebuild --upgrade -o requirements/pip-tools.txt 23 | 24 | COMMON_CONSTRAINTS_TXT=requirements/common_constraints.txt 25 | .PHONY: $(COMMON_CONSTRAINTS_TXT) 26 | $(COMMON_CONSTRAINTS_TXT): 27 | wget -O "$(@)" https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt || touch "$(@)" 28 | 29 | upgrade: export CUSTOM_COMPILE_COMMAND=make upgrade 30 | upgrade: $(COMMON_CONSTRAINTS_TXT) 31 | upgrade: 32 | pip install -qr requirements/pip-tools.txt 33 | ## upgrade requirement pins. 34 | sed 's/pyjwt\[crypto\]<2.0.0//g' requirements/common_constraints.txt > requirements/common_constraints.tmp 35 | mv requirements/common_constraints.tmp requirements/common_constraints.txt 36 | pip-compile --allow-unsafe --rebuild --upgrade -o requirements/pip.txt requirements/pip.in 37 | pip-compile --upgrade -o requirements/pip-tools.txt requirements/pip-tools.in 38 | pip install -qr requirements/pip.txt 39 | pip install -qr requirements/pip-tools.txt 40 | pip-compile requirements/base.in --upgrade -o requirements/base.txt 41 | pip-compile requirements/test.in --upgrade -o requirements/test.txt 42 | pip-compile requirements/docs.in --upgrade -o requirements/docs.txt 43 | pip-compile requirements/dev.in --upgrade -o requirements/dev.txt 44 | 45 | # Delete django, drf pins from test.txt so that tox can control 46 | # Django version. 47 | sed -i.tmp '/^[dD]jango==/d' requirements/test.txt 48 | sed -i.tmp '/^djangorestframework==/d' requirements/test.txt 49 | rm requirements/test.txt.tmp 50 | 51 | requirements: piptools ## install dev requirements into current env 52 | pip-sync requirements/dev.txt 53 | 54 | test: ## run unit tests in all supported environments using tox 55 | tox 56 | 57 | CHECK_DIRS=csrf edx_rest_framework_extensions 58 | 59 | style: ## check that code is PEP-8 compliant. 60 | pycodestyle *.py $(CHECK_DIRS) 61 | 62 | isort: ## sort imports 63 | isort $(CHECK_DIRS) 64 | 65 | isort_check: ## check that imports are correctly sorted 66 | isort $(CHECK_DIRS) --check-only --diff 67 | 68 | linting: ## check code quality with pylint 69 | pylint csrf 70 | # Disable "C" (convention) messages in `edx_rest_framework_extensions` 71 | # because there are so many violations (TODO: fix them). 72 | pylint --disable=C edx_rest_framework_extensions 73 | 74 | quality: style isort_check linting ## run all code quality checks in current env 75 | @echo "Quality checking complete!" 76 | 77 | test-python: ## run unit tests within this environment only 78 | python -Wd -m pytest 79 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | edX Django REST Framework Extensions |CI|_ |Codecov|_ 2 | ========================================================== 3 | .. |CI| image:: https://github.com/openedx/edx-drf-extensions/workflows/Python%20CI/badge.svg?branch=master 4 | .. _CI: https://github.com/openedx/edx-drf-extensions/actions?query=workflow%3A%22Python+CI%22 5 | 6 | .. |Codecov| image:: https://codecov.io/github/edx/edx-drf-extensions/coverage.svg?branch=master 7 | .. _Codecov: https://codecov.io/github/edx/edx-drf-extensions?branch=master 8 | 9 | .. |doc-badge| image:: https://readthedocs.org/projects/edx-drf-extensions/badge/?version=latest 10 | .. _doc-badge: http://edx-drf-extensions.readthedocs.io/en/latest/ 11 | 12 | This library includes various cross-cutting concerns related to APIs. API functionality added to this library must be required for multiple Open edX applications or multiple repositories. 13 | 14 | Some of these concerns include extensions of `Django REST Framework `_ (DRF), which is how the repository initially got its name. 15 | 16 | Publishing a Release 17 | -------------------- 18 | 19 | After a PR merges, a new version of the package will automatically be released by Travis when the commit is tagged. Use:: 20 | 21 | git tag -a X.Y.Z -m "Releasing version X.Y.Z" 22 | git push origin X.Y.Z 23 | 24 | Do **not** create a Github Release, or ensure its message points to the CHANGELOG.rst and ADR 0001-use-changelog.rst. 25 | 26 | JWT Authentication and REST API Endpoints 27 | ----------------------------------------- 28 | 29 | JWT Authentication is the preferred method of authentication for Open edX API endpoints. See `JWT Authentication README`_ for more details. 30 | 31 | .. _JWT Authentication README: ./edx_rest_framework_extensions/auth/jwt/README.rst 32 | 33 | CSRF API 34 | -------- 35 | 36 | One feature of this library is a ``csrf`` app containing an API endpoint for retrieving CSRF tokens from the Django service in which it is installed. This is useful for frontend apps attempting to make POST, PUT, and DELETE requests to a Django service with Django's CSRF middleware enabled. 37 | 38 | To make use of this API endpoint: 39 | 40 | #. Install edx-drf-extensions in your Django project. 41 | #. Add ``csrf.apps.CsrfAppConfig`` to ``INSTALLED_APPS``. 42 | #. Add ``'edx_rest_framework_extensions.auth.jwt.middleware.JwtAuthCookieMiddleware'`` to ``MIDDLEWARE``. 43 | #. Add ``csrf.urls`` to urls.py. 44 | 45 | Documentation 46 | ------------- 47 | 48 | The latest documentation for this repository can be found on `Read the Docs `_ 49 | 50 | 51 | License 52 | ------- 53 | 54 | The code in this repository is licensed under Apache 2.0 unless otherwise noted. 55 | 56 | Please see ``LICENSE.txt`` for details. 57 | 58 | How To Contribute 59 | ----------------- 60 | 61 | Contributions are very welcome. 62 | 63 | Please read `How To Contribute `_ for details. 64 | 65 | 66 | Reporting Security Issues 67 | ------------------------- 68 | 69 | Please do not report security issues in public. Please email security@openedx.org. 70 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | # This file records information about this repo. Its use is described in OEP-55: 2 | # https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html 3 | 4 | apiVersion: backstage.io/v1alpha1 5 | kind: Component 6 | metadata: 7 | name: 'edx-drf-extensions' 8 | description: "This library includes various cross-cutting concerns for providing APIs." 9 | links: 10 | - url: "https://github.com/openedx/edx-drf-extensions" 11 | title: "edX Django REST Framework Extensions" 12 | icon: "Web" 13 | annotations: 14 | openedx.org/arch-interest-groups: "" 15 | spec: 16 | owner: group:2u-arch-bom 17 | type: 'library' 18 | lifecycle: 'production' 19 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | coverage: 3 | status: 4 | patch: 5 | default: 6 | target: 90 7 | project: 8 | default: 9 | target: 90 10 | -------------------------------------------------------------------------------- /csrf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-drf-extensions/1311a63c3e0d1e93be8bf46a05ec01fb98e29cd4/csrf/__init__.py -------------------------------------------------------------------------------- /csrf/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-drf-extensions/1311a63c3e0d1e93be8bf46a05ec01fb98e29cd4/csrf/api/__init__.py -------------------------------------------------------------------------------- /csrf/api/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL definitions for the CSRF API endpoints. 3 | """ 4 | 5 | from django.urls import include, path 6 | 7 | 8 | urlpatterns = [ 9 | path('v1/', include('csrf.api.v1.urls'), name='csrf_api_v1'), 10 | ] 11 | -------------------------------------------------------------------------------- /csrf/api/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-drf-extensions/1311a63c3e0d1e93be8bf46a05ec01fb98e29cd4/csrf/api/v1/__init__.py -------------------------------------------------------------------------------- /csrf/api/v1/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL definitions for version 1 of the CSRF API. 3 | """ 4 | 5 | from django.urls import path 6 | 7 | from .views import CsrfTokenView 8 | 9 | 10 | urlpatterns = [ 11 | path('token', CsrfTokenView.as_view(), name='csrf_token'), 12 | ] 13 | -------------------------------------------------------------------------------- /csrf/api/v1/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | API for CSRF application. 3 | """ 4 | 5 | from django.middleware.csrf import get_token 6 | from rest_framework.permissions import AllowAny 7 | from rest_framework.response import Response 8 | from rest_framework.views import APIView 9 | 10 | 11 | class CsrfTokenView(APIView): 12 | """ 13 | **Use Case** 14 | 15 | Allows frontend apps to obtain a CSRF token from the Django 16 | service in order to make POST, PUT, and DELETE requests to 17 | API endpoints hosted on the service. 18 | 19 | **Behavior** 20 | 21 | GET /csrf/api/v1/token 22 | >>> { 23 | >>> "csrfToken": "abcdefg1234567" 24 | >>> } 25 | """ 26 | # AllowAny keeps possible default of DjangoModelPermissions from being used. 27 | permission_classes = (AllowAny,) 28 | 29 | def get(self, request): 30 | """ 31 | GET /csrf/api/v1/token 32 | """ 33 | return Response({'csrfToken': get_token(request)}) 34 | -------------------------------------------------------------------------------- /csrf/apps.py: -------------------------------------------------------------------------------- 1 | """ 2 | App for creating and distributing CSRF tokens to frontend applications. 3 | """ 4 | 5 | from django.apps import AppConfig 6 | 7 | 8 | class CsrfAppConfig(AppConfig): 9 | """Configuration for the csrf application.""" 10 | 11 | name = 'csrf' 12 | verbose_name = 'CSRF' 13 | -------------------------------------------------------------------------------- /csrf/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-drf-extensions/1311a63c3e0d1e93be8bf46a05ec01fb98e29cd4/csrf/tests/__init__.py -------------------------------------------------------------------------------- /csrf/tests/test_api.py: -------------------------------------------------------------------------------- 1 | """ Tests for the CSRF API """ 2 | 3 | from django.test.utils import override_settings 4 | from django.urls import reverse 5 | from rest_framework import status 6 | from rest_framework.test import APITestCase 7 | 8 | 9 | class CsrfTokenTests(APITestCase): 10 | """ Tests for the CSRF token endpoint. """ 11 | 12 | @override_settings(REST_FRAMEWORK={ 13 | 'DEFAULT_PERMISSION_CLASSES': ( 14 | # Ensure this default permission does not interfere with the CSRF endpoint. 15 | 'rest_framework.permissions.DjangoModelPermissions', 16 | ), 17 | }) 18 | def test_get_token(self): 19 | """ 20 | Ensure we can get a CSRF token for an anonymous user. 21 | """ 22 | url = reverse('csrf_token') 23 | response = self.client.get(url, format='json') 24 | self.assertEqual(response.status_code, status.HTTP_200_OK) 25 | self.assertIn('csrfToken', response.data) 26 | self.assertIsNotNone(response.data['csrfToken']) 27 | -------------------------------------------------------------------------------- /csrf/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URLs for the CSRF application. 3 | """ 4 | 5 | from django.urls import include, path 6 | 7 | 8 | urlpatterns = [ 9 | path('csrf/api/', include('csrf.api.urls'), name='csrf_api'), 10 | ] 11 | -------------------------------------------------------------------------------- /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) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage 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 " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/edx_drf_extensions.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/edx_drf_extensions.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/edx_drf_extensions" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/edx_drf_extensions" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/__init__.py: -------------------------------------------------------------------------------- 1 | # Included so Django's startproject command runs against the docs directory 2 | -------------------------------------------------------------------------------- /docs/_static/theme_overrides.css: -------------------------------------------------------------------------------- 1 | /* override table width restrictions */ 2 | .wy-table-responsive table td, .wy-table-responsive table th { 3 | /* !important prevents the common CSS stylesheets from 4 | overriding this as on RTD they are loaded after this stylesheet */ 5 | white-space: normal !important; 6 | } 7 | 8 | .wy-table-responsive { 9 | overflow: visible !important; 10 | } 11 | -------------------------------------------------------------------------------- /docs/authentication.rst: -------------------------------------------------------------------------------- 1 | Authentication 2 | ============== 3 | 4 | Authentication classes are used to associate a request with a user. Unless otherwise noted, all of the classes below adhere to the Django `REST Framework's API for authentication classes `_. 5 | 6 | .. automodule:: edx_rest_framework_extensions.auth 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # edX REST Framework Extensions documentation build configuration file, created by 3 | # sphinx-quickstart on Sun Feb 17 11:46:20 2013. 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | import os 14 | import sys 15 | from datetime import datetime 16 | 17 | 18 | # on_rtd is whether we are on readthedocs.org 19 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 20 | 21 | # If extensions (or modules to document with autodoc) are in another directory, 22 | # add these directories to sys.path here. If the directory is relative to the 23 | # documentation root, use os.path.abspath to make it absolute, like shown here. 24 | # sys.path.insert(0, os.path.abspath('.')) 25 | REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 26 | sys.path.append(REPO_ROOT) 27 | 28 | # Specify settings module (which will be picked up from the sandbox) 29 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'docs_settings') 30 | 31 | import django 32 | 33 | 34 | django.setup() 35 | 36 | # -- General configuration ----------------------------------------------------- 37 | 38 | # If your documentation needs a minimal Sphinx version, state it here. 39 | # needs_sphinx = '1.0' 40 | 41 | # Add any Sphinx extension module names here, as strings. They can be extensions 42 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 43 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon'] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # The suffix of source filenames. 49 | source_suffix = '.rst' 50 | 51 | # The encoding of source files. 52 | # source_encoding = 'utf-8-sig' 53 | 54 | # The master toctree document. 55 | master_doc = 'index' 56 | 57 | # General information about the project. 58 | project = 'edX REST Framework Extensions' 59 | copyright = f'{datetime.now().year}, edX' 60 | 61 | # The version info for the project you're documenting, acts as replacement for 62 | # |version| and |release|, also used in various other places throughout the 63 | # built documents. 64 | # 65 | import edx_rest_framework_extensions 66 | 67 | 68 | # The short X.Y version. 69 | version = edx_rest_framework_extensions.__version__ 70 | 71 | # The full version, including alpha/beta/rc tags. 72 | release = edx_rest_framework_extensions.__version__ 73 | 74 | # The language for content autogenerated by Sphinx. Refer to documentation 75 | # for a list of supported languages. 76 | # language = None 77 | 78 | # There are two options for replacing |today|: either, you set today to some 79 | # non-false value, then it is used: 80 | # today = '' 81 | # Else, today_fmt is used as the format for a strftime call. 82 | # today_fmt = '%B %d, %Y' 83 | 84 | # List of patterns, relative to source directory, that match files and 85 | # directories to ignore when looking for source files. 86 | exclude_patterns = ['_build'] 87 | 88 | # The reST default role (used for this markup: `text`) to use for all documents. 89 | # default_role = None 90 | 91 | # If true, '()' will be appended to :func: etc. cross-reference text. 92 | # add_function_parentheses = True 93 | 94 | # If true, the current module name will be prepended to all description 95 | # unit titles (such as .. function::). 96 | add_module_names = False 97 | 98 | # If true, sectionauthor and moduleauthor directives will be shown in the 99 | # output. They are ignored by default. 100 | # show_authors = False 101 | 102 | # The name of the Pygments (syntax highlighting) style to use. 103 | pygments_style = 'sphinx' 104 | 105 | # A list of ignored prefixes for module index sorting. 106 | # modindex_common_prefix = [] 107 | 108 | 109 | # -- Options for HTML output --------------------------------------------------- 110 | 111 | # The theme to use for HTML and HTML Help pages. See the documentation for 112 | # a list of builtin themes. 113 | html_theme = 'sphinx_book_theme' 114 | 115 | # Theme options are theme-specific and customize the look and feel of a theme 116 | # further. For a list of options available for each theme, see the 117 | # documentation. 118 | html_theme_options = { 119 | "repository_url": "https://github.com/openedx/edx-drf-extensions", 120 | "repository_branch": "master", 121 | "path_to_docs": "docs/", 122 | "home_page_in_toc": True, 123 | "use_repository_button": True, 124 | "use_issues_button": True, 125 | "use_edit_page_button": True, 126 | # Please don't change unless you know what you're doing. 127 | "extra_footer": """ 128 | 129 | Creative Commons License 133 | 134 |
135 | These works by 136 | Axim Collaborative, Inc 142 | are licensed under a 143 | Creative Commons Attribution-ShareAlike 4.0 International License. 147 | """ 148 | } 149 | 150 | # Add any paths that contain custom themes here, relative to this directory. 151 | # html_theme_path = [] 152 | 153 | # The name for this set of Sphinx documents. If None, it defaults to 154 | # " v documentation". 155 | # html_title = None 156 | 157 | # A shorter title for the navigation bar. Default is the same as html_title. 158 | # html_short_title = None 159 | 160 | # The name of an image file (relative to this directory) to place at the top 161 | # of the sidebar. 162 | html_logo = "https://logos.openedx.org/open-edx-logo-color.png" 163 | 164 | # The name of an image file (within the static path) to use as favicon of the 165 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 166 | # pixels large. 167 | html_favicon = "https://logos.openedx.org/open-edx-favicon.ico" 168 | 169 | # Add any paths that contain custom static files (such as style sheets) here, 170 | # relative to this directory. They are copied after the builtin static files, 171 | # so a file named "default.css" will overwrite the builtin "default.css". 172 | html_static_path = ['_static'] 173 | 174 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 175 | # using the given strftime format. 176 | # html_last_updated_fmt = '%b %d, %Y' 177 | 178 | # If true, SmartyPants will be used to convert quotes and dashes to 179 | # typographically correct entities. 180 | # html_use_smartypants = True 181 | 182 | # Custom sidebar templates, maps document names to template names. 183 | # html_sidebars = {} 184 | 185 | # Additional templates that should be rendered to pages, maps page names to 186 | # template names. 187 | # html_additional_pages = {} 188 | 189 | # If false, no module index is generated. 190 | # html_domain_indices = True 191 | 192 | # If false, no index is generated. 193 | # html_use_index = True 194 | 195 | # If true, the index is split into individual pages for each letter. 196 | # html_split_index = False 197 | 198 | # If true, links to the reST sources are added to the pages. 199 | # html_show_sourcelink = True 200 | 201 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 202 | # html_show_sphinx = True 203 | 204 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 205 | # html_show_copyright = True 206 | 207 | # If true, an OpenSearch description file will be output, and all pages will 208 | # contain a tag referring to it. The value of this option must be the 209 | # base URL from which the finished HTML is served. 210 | # html_use_opensearch = '' 211 | 212 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 213 | # html_file_suffix = None 214 | 215 | # Output file base name for HTML help builder. 216 | htmlhelp_basename = 'edx_drf_extensionsdoc' 217 | 218 | 219 | # -- Options for LaTeX output -------------------------------------------------- 220 | 221 | latex_elements = { 222 | # The paper size ('letterpaper' or 'a4paper'). 223 | # 'papersize': 'letterpaper', 224 | 225 | # The font size ('10pt', '11pt' or '12pt'). 226 | # 'pointsize': '10pt', 227 | 228 | # Additional stuff for the LaTeX preamble. 229 | # 'preamble': '', 230 | } 231 | 232 | # Grouping the document tree into LaTeX files. List of tuples 233 | # (source start file, target name, title, author, documentclass [howto/manual]). 234 | latex_documents = [ 235 | ('index', 'edx_drf_extensions.tex', 'edX Django REST Framework Extensions Documentation', 236 | 'edX', 'manual'), 237 | ] 238 | 239 | # The name of an image file (relative to this directory) to place at the top of 240 | # the title page. 241 | # latex_logo = None 242 | 243 | # For "manual" documents, if this is true, then toplevel headings are parts, 244 | # not chapters. 245 | # latex_use_parts = False 246 | 247 | # If true, show page references after internal links. 248 | # latex_show_pagerefs = False 249 | 250 | # If true, show URL addresses after external links. 251 | # latex_show_urls = False 252 | 253 | # Documents to append as an appendix to all manuals. 254 | # latex_appendices = [] 255 | 256 | # If false, no module index is generated. 257 | # latex_domain_indices = True 258 | 259 | 260 | # -- Options for manual page output -------------------------------------------- 261 | 262 | # One entry per manual page. List of tuples 263 | # (source start file, name, description, authors, manual section). 264 | man_pages = [ 265 | ('index', 'edX REST Framework Extensions', 'edX REST Framework Extensions Documentation', 266 | ['edX'], 1) 267 | ] 268 | 269 | # If true, show URL addresses after external links. 270 | # man_show_urls = False 271 | 272 | 273 | # -- Options for Texinfo output ------------------------------------------------ 274 | 275 | # Grouping the document tree into Texinfo files. List of tuples 276 | # (source start file, target name, title, author, 277 | # dir menu entry, description, category) 278 | texinfo_documents = [ 279 | ('index', 'edX REST Framework Extensions', 'edX REST Framework Extensions Documentation', 280 | 'edX', 'edX REST Framework Extensions', 'edX REST Framework Extensions', 281 | 'Miscellaneous' 282 | ), 283 | ] 284 | 285 | 286 | # Documents to append as an appendix to all manuals. 287 | # texinfo_appendices = [] 288 | 289 | # If false, no module index is generated. 290 | # texinfo_domain_indices = True 291 | 292 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 293 | # texinfo_show_urls = 'footnote' 294 | 295 | 296 | def skip_modules_docstring(app, what, name, obj, options, lines): 297 | if what == 'module': 298 | del lines[:] 299 | 300 | 301 | def setup(app): 302 | app.connect('autodoc-process-docstring', skip_modules_docstring) 303 | app.add_css_file('theme_overrides.css') 304 | -------------------------------------------------------------------------------- /docs/decisions/0001-use-changelog.rst: -------------------------------------------------------------------------------- 1 | 1. Use CHANGELOG.rst 2 | ==================== 3 | 4 | Status 5 | ------ 6 | 7 | Accepted 8 | 9 | Context 10 | ------- 11 | 12 | This repository was using Github Releases only to capture changelog details, which has the following issues: 13 | 14 | * Additions and updates to the changelog don't go through PR review. 15 | * The changelog is not versioned with the repository, is not available with the repo documentation, cannot be seen in a single file, and is not available offline. 16 | 17 | Additionally, there was no guidance for formatting entries. 18 | 19 | Decision 20 | -------- 21 | 22 | * Add a CHANGELOG.rst as the primary source of tracking changes. 23 | * The changelog will be formatted according to `keepachangelog.com`_. 24 | * Avoid redundancy in Github Releases. 25 | 26 | This resolves all issues noted under this ADR's `Context`_. 27 | 28 | Commit Message vs Changelog Entry 29 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 30 | 31 | Since changelog messages are for developers and consumers of your code, good changelog messages will often not match commit messages. Here are some examples: 32 | 33 | .. list-table:: 34 | :header-rows: 1 35 | :widths: 50 50 36 | 37 | * - Commit Message 38 | - Changelog Entry 39 | * - deps: update dependency some-dependency to 2.3.0 40 | - No functional change 41 | 42 | Note: This example changelog entry assumes there were no other changes for this version. 43 | * - Fix SyntaxError in UserLogout class 44 | - Fix 500 error during logout 45 | * - Rename dry_run parameter 46 | - **BREAKING CHANGE** Remove the dry_run parameter in the public foobarize API method. This parameter is deprecated in favour of the no_apply parameter. See docs for details. 47 | * - Add set_foobarizer method to api.Foo 48 | - Add a set_foobarizer method to Foo's public API. This is particularly useful for developers trying to foobarize their users. See docs for details. 49 | 50 | Consequences 51 | ------------ 52 | 53 | Regarding the discontinuation of using Github Releases: 54 | 55 | * Writing the changelog entry in the CHANGELOG.rst should be as simple as it was to write it in Github Releases, so there should be no additional work. 56 | * The README.rst should be updated regarding the proper way to release to avoid Github Release redundancy. 57 | * Older Github Release messages could one day be relocated to the CHANGELOG.rst. For now, the latest release message should clarify the change in policy and point to the CHANELOG.rst. 58 | 59 | Additional tools: 60 | 61 | * A Pull Request template will be added to provide a reminder. 62 | 63 | References 64 | ---------- 65 | 66 | * `keepachangelog.com`_ 67 | * `OEP-47: Semantic Versioning`_ (Coming Soon) 68 | 69 | .. _keepachangelog.com: https://keepachangelog.com/en/1.0.0/ 70 | .. _`OEP-47: Semantic Versioning`: https://open-edx-proposals.readthedocs.io/en/latest/oep-0047-bp-semantic-versioning.rst 71 | -------------------------------------------------------------------------------- /docs/decisions/0002-remove-use-jwt-cookie-header.rst: -------------------------------------------------------------------------------- 1 | 2. Replace HTTP_USE_JWT_COOKIE Header 2 | ===================================== 3 | 4 | Status 5 | ------ 6 | 7 | Accepted 8 | 9 | Context 10 | ------- 11 | 12 | This ADR explains `why the request header HTTP_USE_JWT_COOKIE`_ was added. 13 | 14 | Use of this header has several problems: 15 | 16 | * The purpose and need for the ``HTTP_USE_JWT_COOKIE`` header is confusing. It has led to developer confusion when trying to test API calls using JWT cookies, but having the auth fail because they didn't realize this special header was also required. 17 | * In some cases, the JWT cookies are sent to services, but go unused because of this header. Additional oauth redirects then become required in circumstances where they otherwise wouldn't be needed. 18 | * Some features have been added, like `JwtRedirectToLoginIfUnauthenticatedMiddleware`_, that can be greatly simplified or possibly removed altogether if the ``HTTP_USE_JWT_COOKIE`` header were retired. 19 | 20 | 21 | Decision 22 | -------- 23 | 24 | Replace the ``HTTP_USE_JWT_COOKIE`` header with forgiving authentication when using JWT cookies. By "forgiving", we mean that JWT authentication would no longer raise exceptions for failed authentication when using JWT cookies, but instead would simply return None. 25 | 26 | By returning None from JwtAuthentication, rather than raising an authentication failure, we enable services to move on to other classes, like SessionAuthentication, rather than aborting the authentication process. Failure messages could still be surfaced using ``set_custom_attribute`` for debugging purposes. 27 | 28 | Rather than checking for the ``HTTP_USE_JWT_COOKIE``, the `JwtAuthCookieMiddleware`_ would always reconstitute the JWT cookie if the parts were available. 29 | 30 | The proposal includes protecting all changes with a temporary rollout feature toggle ``ENABLE_FORGIVING_JWT_COOKIES``. This can be used to ensure no harm is done for each service before cleaning up the old header. 31 | 32 | **Update:** As of Nov-2023, the ``ENABLE_FORGIVING_JWT_COOKIES`` toggle and ``HTTP_USE_JWT_COOKIE`` have been fully removed, and forgiving JWTs is the default and only implementation remaining. 33 | 34 | Unfortunately, there are certain rare cases where the user inside the JWT and the session user do not match: 35 | 36 | - If the JWT cookie succeeds authentication, and: 37 | 38 | - If ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE is enabled to make the JWT user available to middleware, then we also enforce the that the JWT user and session user match. If they do not, we will fail authentication instead. 39 | - If ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE is disabled, we allow the successful JWT cookie authentication to proceed, even though the session user does not match. We will monitor this situation and may choose to enforce the match and fail instead. 40 | 41 | - If the JWT cookie fails authentication, but the failed JWT contains a user that does not match the session user, authentication will be failed, rather than moving on to SessionAuthentication which would have resulted in authentication for a different user. 42 | 43 | .. _JwtAuthCookieMiddleware: https://github.com/edx/edx-drf-extensions/blob/270cf521a72b506d7df595c4c479c7ca232b4bec/edx_rest_framework_extensions/auth/jwt/middleware.py#L164 44 | 45 | Consequences 46 | ------------ 47 | 48 | * Makes authentication simpler, more clear, and more predictable. 49 | 50 | * For example, local testing of endpoints outside of MFEs will use JWT cookies rather than failing, which has been misleading for engineers. 51 | 52 | * Simplifies features like `JwtRedirectToLoginIfUnauthenticatedMiddleware`_. 53 | * Service authentication can take advantage of JWT cookies more often. 54 | * Services can more consistently take advantage of the JWT payload of the JWT cookie. 55 | * Additional clean-up when retiring the ``HTTP_USE_JWT_COOKIE`` header will be needed: 56 | 57 | * ``HTTP_USE_JWT_COOKIE`` should be removed from frontend-platform auth code when ready. 58 | * ADR that explains `why the request header HTTP_USE_JWT_COOKIE`_ was updated in https://github.com/openedx/edx-platform/pull/33680. 59 | 60 | .. _why the request header HTTP_USE_JWT_COOKIE: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0009-jwt-in-session-cookie.rst#login---cookie---api 61 | .. _JwtRedirectToLoginIfUnauthenticatedMiddleware: https://github.com/edx/edx-drf-extensions/blob/270cf521a72b506d7df595c4c479c7ca232b4bec/edx_rest_framework_extensions/auth/jwt/middleware.py#L87 62 | 63 | Change History 64 | -------------- 65 | 66 | 2023-11-08 67 | ~~~~~~~~~~ 68 | * Updated implementation status, since forgiving JWTs has been rolled out and the ENABLE_FORGIVING_JWT_COOKIES toggle and HTTP_USE_JWT_COOKIE header have been fully removed. 69 | 70 | 2023-10-30 71 | ~~~~~~~~~~ 72 | * Details added for handling of a variety of situations when the JWT cookie user and the session user do not match. 73 | 74 | 2023-08-14 75 | ~~~~~~~~~~ 76 | * Merged original ADR 77 | -------------------------------------------------------------------------------- /docs/decisions/index.rst: -------------------------------------------------------------------------------- 1 | Architectural Decision Records 2 | ############################## 3 | 4 | .. toctree:: 5 | :glob: 6 | :numbered: 7 | :maxdepth: 1 8 | 9 | ./* 10 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. edx-drf-extensions documentation master file 2 | 3 | edX REST Framework Extensions 4 | ============================= 5 | 6 | This package provides extensions for `Django REST Framework `_ that are useful 7 | for developing on the edX platform. 8 | 9 | Requirements 10 | ------------ 11 | 12 | * Python (3.8+) 13 | * Django (2.2, 3.2) 14 | * Django REST Framework (3.9+) 15 | 16 | Installation 17 | ------------ 18 | 19 | Install using ``pip``: 20 | 21 | .. code-block:: bash 22 | 23 | $ pip install edx-drf-extensions 24 | 25 | Table of Contents 26 | ----------------- 27 | 28 | .. toctree:: 29 | :maxdepth: 2 30 | 31 | settings 32 | authentication 33 | middleware 34 | permissions 35 | utils 36 | changelog 37 | decisions/index 38 | -------------------------------------------------------------------------------- /docs/middleware.rst: -------------------------------------------------------------------------------- 1 | Middleware 2 | ========== 3 | This module contains middleware to ensure best practices of DRF and other endpoints.. 4 | 5 | .. automodule:: edx_rest_framework_extensions.middleware 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/permissions.rst: -------------------------------------------------------------------------------- 1 | Permissions 2 | =========== 3 | Permissions determine whether a request should be granted or denied access. Unless otherwise noted, all of the classes 4 | below adhere to the Django `REST Framework's API for permission classes `_. 5 | 6 | .. automodule:: edx_rest_framework_extensions.permissions 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ======== 3 | 4 | All settings for this package reside in a dict, `EDX_DRF_EXTENSIONS`. Within this dict, the following keys should be 5 | specified, depending on the functionality you are using. 6 | 7 | 8 | BearerAuthentication 9 | -------------------- 10 | 11 | .. py:currentmodule:: edx_rest_framework_extensions 12 | 13 | These settings are used by the :class:`~authentication.BearerAuthentication` class. 14 | 15 | ``OAUTH2_USER_INFO_URL`` 16 | ~~~~~~~~~~~~~~~~~~~~~~~~ 17 | 18 | Default: ``None`` 19 | 20 | URL of an endpoint on the OAuth2 provider where :class:`~authentication.BearerAuthentication` can retrieve details 21 | about the user associated with the provided access token. This endpoint should return a JSON object with user details 22 | and ``HTTP 200`` if, and only if, the access token is valid. See 23 | :meth:`BearerAuthentication.process_user_info_response() ` 24 | for an example of the expected data format. 25 | 26 | 27 | JwtAuthentication 28 | ----------------- 29 | 30 | .. py:currentmodule:: edx_rest_framework_extensions 31 | 32 | These settings are used by the :class:`~authentication.JwtAuthentication` class. Since this class is based on 33 | :class:`JSONWebTokenAuthentication`, most of its settings can be found in the documentation for ``rest_framework_jwt`` 34 | at http://getblimp.github.io/django-rest-framework-jwt/#additional-settings. 35 | 36 | ``JWT_AUTH['JWT_VERIFY_AUDIENCE']`` 37 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 38 | 39 | Default: ``True`` 40 | 41 | If you do *not* want to verify the JWT audience, set the ``'JWT_VERIFY_AUDIENCE'`` key in the ``JWT_AUTH`` setting 42 | to ``False``. 43 | 44 | 45 | ``JWT_PAYLOAD_USER_ATTRIBUTES`` 46 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 47 | 48 | Default: ``('email',)`` 49 | 50 | The list of user attributes in the JWT payload that :class:`~authentication.JwtAuthentication` will use to update the 51 | local ``User`` model. These payload attributes should exactly match the names the attributes on the local ``User`` 52 | model. 53 | -------------------------------------------------------------------------------- /docs/utils.rst: -------------------------------------------------------------------------------- 1 | Utility Functions 2 | ================= 3 | This module contains useful utility functions. 4 | 5 | .. automodule:: edx_rest_framework_extensions.utils 6 | :members: 7 | -------------------------------------------------------------------------------- /docs_settings.py: -------------------------------------------------------------------------------- 1 | """ Django settings for docs compilation. 2 | 3 | This should contain the bare minimum necessary for Django to start. 4 | """ 5 | 6 | SECRET_KEY = 'insecure-secret-key' 7 | 8 | INSTALLED_APPS = ( 9 | 'django.contrib.auth', 10 | 'django.contrib.contenttypes', 11 | ) 12 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/__init__.py: -------------------------------------------------------------------------------- 1 | """ edx Django REST Framework extensions. """ 2 | 3 | __version__ = '10.6.0' # pragma: no cover 4 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/README.rst: -------------------------------------------------------------------------------- 1 | This directory contains authentication related extensions, split by authentication type: 2 | 3 | * Bearer_ 4 | 5 | * JWT_ 6 | 7 | * Session_ 8 | 9 | .. _Bearer: ./bearer 10 | .. _JWT: ./jwt 11 | .. _Session: ./session 12 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-drf-extensions/1311a63c3e0d1e93be8bf46a05ec01fb98e29cd4/edx_rest_framework_extensions/auth/__init__.py -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/bearer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-drf-extensions/1311a63c3e0d1e93be8bf46a05ec01fb98e29cd4/edx_rest_framework_extensions/auth/bearer/__init__.py -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/bearer/authentication.py: -------------------------------------------------------------------------------- 1 | """ Bearer Authentication class. """ 2 | 3 | import logging 4 | 5 | import requests 6 | from django.contrib.auth import get_user_model 7 | from edx_django_utils.monitoring import set_custom_attribute 8 | from rest_framework import exceptions 9 | from rest_framework.authentication import BaseAuthentication, get_authorization_header 10 | 11 | from edx_rest_framework_extensions.exceptions import UserInfoRetrievalFailed 12 | from edx_rest_framework_extensions.settings import get_setting 13 | 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class BearerAuthentication(BaseAuthentication): 19 | """ 20 | Simple token based authentication. 21 | 22 | This authentication class is useful for authenticating an OAuth2 access token against a remote 23 | authentication provider. Clients should authenticate by passing the token key in the "Authorization" HTTP header, 24 | prepended with the string `"Bearer "`. 25 | 26 | This class relies on the OAUTH2_USER_INFO_URL being set to the value of an endpoint on the OAuth provider, that 27 | returns a JSON object with information about the user. See ``process_user_info_response`` for the expected format 28 | of this object. This data will be used to get, or create, a ``User``. Additionally, it is assumed that a successful 29 | response from this endpoint (authenticated with the provided access token) implies the access token is valid. 30 | 31 | Example Header: 32 | Authorization: Bearer 401f7ac837da42b97f613d789819ff93537bee6a 33 | """ 34 | 35 | def get_user_info_url(self): 36 | """ Returns the URL, hosted by the OAuth2 provider, from which user information can be pulled. """ 37 | return get_setting('OAUTH2_USER_INFO_URL') 38 | 39 | def authenticate(self, request): 40 | set_custom_attribute("BearerAuthentication", "Failed") # default value 41 | if not self.get_user_info_url(): 42 | logger.warning('The setting OAUTH2_USER_INFO_URL is invalid!') 43 | set_custom_attribute("BearerAuthentication", "NoURL") 44 | return None 45 | set_custom_attribute("BearerAuthentication_user_info_url", self.get_user_info_url()) 46 | auth = get_authorization_header(request).split() 47 | 48 | if not auth or auth[0].lower() != b'bearer': 49 | set_custom_attribute("BearerAuthentication", "None") 50 | return None 51 | 52 | if len(auth) == 1: 53 | raise exceptions.AuthenticationFailed('Invalid token header. No credentials provided.') 54 | if len(auth) > 2: 55 | raise exceptions.AuthenticationFailed('Invalid token header. Token string should not contain spaces.') 56 | 57 | output = self.authenticate_credentials(auth[1].decode('utf8')) 58 | set_custom_attribute("BearerAuthentication", "Success") 59 | return output 60 | 61 | def authenticate_credentials(self, token): 62 | """ 63 | Validate the bearer token against the OAuth provider. 64 | 65 | Arguments: 66 | token (str): Access token to validate 67 | 68 | Returns: 69 | (tuple): tuple containing: 70 | 71 | user (User): User associated with the access token 72 | access_token (str): Access token 73 | 74 | Raises: 75 | AuthenticationFailed: The user is inactive, or retrieval of user info failed. 76 | """ 77 | 78 | try: 79 | user_info = self.get_user_info(token) 80 | except UserInfoRetrievalFailed as authentication_error: 81 | msg = 'Failed to retrieve user info. Unable to authenticate.' 82 | logger.error(msg) 83 | raise exceptions.AuthenticationFailed(msg) from authentication_error 84 | 85 | user, __ = get_user_model().objects.get_or_create(username=user_info['username'], defaults=user_info) 86 | 87 | if not user.is_active: 88 | raise exceptions.AuthenticationFailed('User inactive or deleted.') 89 | 90 | return user, token 91 | 92 | def get_user_info(self, token): 93 | """ 94 | Retrieves the user info from the OAuth provider. 95 | 96 | Arguments: 97 | token (str): OAuth2 access token. 98 | 99 | Returns: 100 | dict 101 | 102 | Raises: 103 | UserInfoRetrievalFailed: Retrieval of user info from the remote server failed. 104 | """ 105 | 106 | url = self.get_user_info_url() 107 | 108 | try: 109 | headers = {'Authorization': f'Bearer {token}'} 110 | response = requests.get(url, headers=headers) # pylint: disable=missing-timeout 111 | except requests.RequestException as error: 112 | logger.exception('Failed to retrieve user info due to a request exception.') 113 | raise UserInfoRetrievalFailed from error 114 | 115 | if response.status_code == 200: 116 | return self.process_user_info_response(response.json()) 117 | else: 118 | msg = 'Failed to retrieve user info. Server [{server}] responded with status [{status}].'.format( 119 | server=url, 120 | status=response.status_code 121 | ) 122 | raise UserInfoRetrievalFailed(msg) 123 | 124 | def process_user_info_response(self, response): 125 | """ 126 | Process the user info response data. 127 | 128 | By default, this simply maps the edX user info key-values (example below) to Django-friendly names. If your 129 | provider returns different fields, you should sub-class this class and override this method. 130 | 131 | .. code-block:: python 132 | 133 | { 134 | "username": "jdoe", 135 | "email": "jdoe@example.com", 136 | "first_name": "Jane", 137 | "last_name": "Doe" 138 | } 139 | 140 | Arguments: 141 | response (dict): User info data 142 | 143 | Returns: 144 | dict 145 | """ 146 | mapping = ( 147 | ('username', 'preferred_username'), 148 | ('email', 'email'), 149 | ('last_name', 'family_name'), 150 | ('first_name', 'given_name'), 151 | ) 152 | 153 | return {dest: response[source] for dest, source in mapping} 154 | 155 | def authenticate_header(self, request): 156 | return 'Bearer' 157 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/bearer/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-drf-extensions/1311a63c3e0d1e93be8bf46a05ec01fb98e29cd4/edx_rest_framework_extensions/auth/bearer/tests/__init__.py -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/bearer/tests/test_authentication.py: -------------------------------------------------------------------------------- 1 | """ Tests for Bearer authentication class. """ 2 | import json 3 | from unittest import mock 4 | 5 | import httpretty 6 | from django.contrib.auth import get_user_model 7 | from django.test import RequestFactory, TestCase, override_settings 8 | from requests import RequestException 9 | from rest_framework.exceptions import AuthenticationFailed 10 | 11 | from edx_rest_framework_extensions.auth.bearer.authentication import ( 12 | BearerAuthentication, 13 | ) 14 | from edx_rest_framework_extensions.tests import factories 15 | 16 | 17 | OAUTH2_USER_INFO_URL = 'http://example.com/oauth2/user_info/' 18 | USER_INFO = { 19 | 'username': 'jdoe', 20 | 'first_name': 'Jane', 21 | 'last_name': 'Doê', 22 | 'email': 'jdoe@example.com', 23 | } 24 | User = get_user_model() 25 | 26 | 27 | class AccessTokenMixin: 28 | """ Test mixin for dealing with OAuth2 access tokens. """ 29 | DEFAULT_TOKEN = 'abc123' 30 | 31 | def mock_user_info_response(self, status=200, username=None): 32 | """ Mock the user info endpoint response of the OAuth2 provider. """ 33 | 34 | username = username or USER_INFO['username'] 35 | data = { 36 | 'family_name': USER_INFO['last_name'], 37 | 'preferred_username': username, 38 | 'given_name': USER_INFO['first_name'], 39 | 'email': USER_INFO['email'], 40 | } 41 | 42 | httpretty.register_uri( 43 | httpretty.GET, 44 | OAUTH2_USER_INFO_URL, 45 | body=json.dumps(data), 46 | content_type='application/json', 47 | status=status 48 | ) 49 | 50 | 51 | @override_settings(EDX_DRF_EXTENSIONS={'OAUTH2_USER_INFO_URL': OAUTH2_USER_INFO_URL}) 52 | class BearerAuthenticationTests(AccessTokenMixin, TestCase): 53 | """ Tests for the BearerAuthentication class. """ 54 | TOKEN_NAME = 'Bearer' 55 | 56 | def setUp(self): 57 | super().setUp() 58 | self.auth = BearerAuthentication() 59 | self.factory = RequestFactory() 60 | 61 | def create_authenticated_request(self, token=AccessTokenMixin.DEFAULT_TOKEN, token_name=TOKEN_NAME): 62 | """ Returns a Request with the authorization set using the specified values. """ 63 | auth_header = f'{token_name} {token}' 64 | request = self.factory.get('/', HTTP_AUTHORIZATION=auth_header) 65 | return request 66 | 67 | def assert_user_authenticated(self): 68 | """ Assert a user can be authenticated with a bearer token. """ 69 | user = factories.UserFactory() 70 | self.mock_user_info_response(username=user.username) 71 | 72 | request = self.create_authenticated_request() 73 | self.assertEqual(self.auth.authenticate(request), (user, self.DEFAULT_TOKEN)) 74 | 75 | def assert_authentication_failed(self, token=AccessTokenMixin.DEFAULT_TOKEN, token_name=TOKEN_NAME): 76 | """ Assert authentication fails for a generated request. """ 77 | request = self.create_authenticated_request(token=token, token_name=token_name) 78 | self.assertRaises(AuthenticationFailed, self.auth.authenticate, request) 79 | 80 | def test_authenticate_header(self): 81 | """ The method should return the string Bearer. """ 82 | self.assertEqual(self.auth.authenticate_header(self.create_authenticated_request()), 'Bearer') 83 | 84 | @override_settings(EDX_DRF_EXTENSIONS={'OAUTH2_USER_INFO_URL': None}) 85 | def test_authenticate_no_user_info_url(self): 86 | """ If the setting OAUTH2_USER_INFO_URL is not set, the method returns None. """ 87 | 88 | # Empty value 89 | self.assertIsNone(self.auth.authenticate(self.create_authenticated_request())) 90 | 91 | # Missing value 92 | with override_settings(EDX_DRF_EXTENSIONS={}): 93 | self.assertIsNone(self.auth.authenticate(self.create_authenticated_request())) 94 | 95 | def test_authenticate_invalid_token(self): 96 | """ If no token is supplied, or if the token contains spaces, the method should raise an exception. """ 97 | 98 | # Missing token 99 | self.assert_authentication_failed(token='') 100 | 101 | # Token with spaces 102 | self.assert_authentication_failed(token='abc 123 456') 103 | 104 | def test_authenticate_invalid_token_name(self): 105 | """ If the token name is not Bearer, the method should return None. """ 106 | request = self.create_authenticated_request(token_name='foobar') 107 | self.assertIsNone(self.auth.authenticate(request)) 108 | 109 | @httpretty.activate 110 | def test_authenticate_inactive_user(self): 111 | """ If the user matching the access token is inactive, the method should raise an exception. """ 112 | user = factories.UserFactory(is_active=False) 113 | self.mock_user_info_response(username=user.username) 114 | self.assert_authentication_failed() 115 | 116 | @httpretty.activate 117 | def test_authenticate_invalid_token_response(self): 118 | """ If the user info endpoint does not return HTTP 200, the method should return raise an exception. """ 119 | self.mock_user_info_response(status=400) 120 | self.assert_authentication_failed() 121 | 122 | @httpretty.activate 123 | def test_authenticate(self): 124 | """ If the access token is valid, the user exists, and is active, a tuple containing 125 | the user and token should be returned. 126 | """ 127 | self.assert_user_authenticated() 128 | 129 | @httpretty.activate 130 | def test_authenticate_as_new_user(self): 131 | """ Verify a new user is created. """ 132 | self.mock_user_info_response() 133 | request = self.create_authenticated_request() 134 | actual_user, actual_token = self.auth.authenticate(request) 135 | 136 | self.assertEqual(actual_token, self.DEFAULT_TOKEN) 137 | self.assertEqual(actual_user, User.objects.get(username=USER_INFO['username'])) 138 | 139 | @httpretty.activate 140 | def test_authenticate_user_creation_with_existing_user(self): 141 | """ Verify an existing user is returned, if the user already exists. """ 142 | user = factories.UserFactory(username=USER_INFO['username']) 143 | self.mock_user_info_response() 144 | request = self.create_authenticated_request() 145 | actual_user, actual_token = self.auth.authenticate(request) 146 | 147 | self.assertEqual(actual_token, self.DEFAULT_TOKEN) 148 | self.assertEqual(actual_user, user) 149 | 150 | @httpretty.activate 151 | def test_authenticate_user_creation_with_request_status_failure(self): 152 | """ Verify authentication fails if the request to retrieve user info returns a non-200 status. """ 153 | original_user_count = User.objects.all().count() 154 | self.mock_user_info_response(status=401) 155 | request = self.create_authenticated_request() 156 | 157 | self.assertRaises(AuthenticationFailed, self.auth.authenticate, request) 158 | self.assertEqual(User.objects.all().count(), original_user_count) 159 | 160 | def test_authenticate_user_creation_with_request_exception(self): 161 | """ Verify authentication fails if the request to retrieve user info raises an exception. """ 162 | original_user_count = User.objects.all().count() 163 | request = self.create_authenticated_request() 164 | 165 | with mock.patch('requests.get', mock.Mock(side_effect=RequestException)): 166 | self.assertRaises(AuthenticationFailed, self.auth.authenticate, request) 167 | 168 | self.assertEqual(User.objects.all().count(), original_user_count) 169 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/jwt/README.rst: -------------------------------------------------------------------------------- 1 | JWT Authentication 2 | ================== 3 | 4 | This directory contains extensions to enable JWT Authentication for your endpoints. 5 | 6 | JWT Authentication Class 7 | ------------------------ 8 | 9 | JWT Authentication is mainly enabled by the JwtAuthentication_ class, which is a `Django Rest Framework (DRF)`_ authentication class. The REST endpoint declares which type(s) of authentication it supports or defaults to the *DEFAULT_AUTHENTICATION_CLASSES* value in DRF's *REST_FRAMEWORK* Django setting. 10 | 11 | Here is an example of using Django Settings to set JwtAuthentication_ and ``SessionAuthentication`` as default for your Django application:: 12 | 13 | REST_FRAMEWORK = { 14 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 15 | 'edx_rest_framework_extensions.auth.jwt.authentication.JwtAuthentication', 16 | 'rest_framework.authentication.SessionAuthentication', 17 | ), 18 | } 19 | 20 | Here is an example of a DRF API endpoint implemented using JwtAuthentication_ explicitly:: 21 | 22 | from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication 23 | from rest_framework.views import APIView 24 | 25 | class MyAPIView(APIView): 26 | authentication_classes = (JwtAuthentication, ) 27 | ... 28 | 29 | Additional notes about this class: 30 | 31 | * JwtAuthentication_ extends the JSONWebTokenAuthentication_ class implemented in the django-rest-framework-jwt_ library. 32 | 33 | * JwtAuthentication_ is used to authenticate an API request only if it is listed in the endpoint's authentication_classes_ and the request's Authorization header specifies "JWT" instead of "Bearer". 34 | 35 | * **Note:** The Credentials service has its own implementation of JwtAuthentication and should be converted to use this common implementation. 36 | 37 | .. _Django Rest Framework (DRF): https://github.com/encode/django-rest-framework 38 | .. _JwtAuthentication: ./authentication.py 39 | .. _authentication_classes: http://www.django-rest-framework.org/api-guide/authentication/#setting-the-authentication-scheme 40 | .. _django-rest-framework-jwt: https://github.com/GetBlimp/django-rest-framework-jwt 41 | .. _JSONWebTokenAuthentication: https://github.com/GetBlimp/django-rest-framework-jwt/blob/0a0bd402ec21fd6b9a5f715d114411836fbb2923/rest_framework_jwt/authentication.py#L71 42 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/jwt/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-drf-extensions/1311a63c3e0d1e93be8bf46a05ec01fb98e29cd4/edx_rest_framework_extensions/auth/jwt/__init__.py -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/jwt/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | JWT Authentication constants. 3 | """ 4 | 5 | JWT_DELIMITER = '.' 6 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/jwt/cookies.py: -------------------------------------------------------------------------------- 1 | """ 2 | JWT Authentication cookie utilities. 3 | """ 4 | 5 | from django.conf import settings 6 | 7 | from edx_rest_framework_extensions.auth.jwt.decoder import configured_jwt_decode_handler 8 | 9 | 10 | def jwt_cookie_name(): 11 | # Warning: This method should probably not supply a default outside 12 | # of JWT_AUTH_COOKIE, because JwtAuthentication will never see 13 | # the cookie without the setting. This default should probably be 14 | # removed, but that would take some further investigation. In the 15 | # meantime, this default has been duplicated to test_settings.py. 16 | return settings.JWT_AUTH.get('JWT_AUTH_COOKIE') or 'edx-jwt-cookie' 17 | 18 | 19 | def jwt_cookie_header_payload_name(): 20 | return settings.JWT_AUTH.get('JWT_AUTH_COOKIE_HEADER_PAYLOAD') or 'edx-jwt-cookie-header-payload' 21 | 22 | 23 | def jwt_cookie_signature_name(): 24 | return settings.JWT_AUTH.get('JWT_AUTH_COOKIE_SIGNATURE') or 'edx-jwt-cookie-signature' 25 | 26 | 27 | def get_decoded_jwt(request): 28 | """ 29 | Grab jwt from jwt cookie in request if possible. 30 | 31 | Returns a decoded (verified) jwt dict if it can be found. 32 | Returns None if the jwt is not found. 33 | """ 34 | jwt_cookie = request.COOKIES.get(jwt_cookie_name(), None) 35 | 36 | if not jwt_cookie: 37 | return None 38 | return configured_jwt_decode_handler(jwt_cookie) 39 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/jwt/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-drf-extensions/1311a63c3e0d1e93be8bf46a05ec01fb98e29cd4/edx_rest_framework_extensions/auth/jwt/tests/__init__.py -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/jwt/tests/test_cookies.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for jwt cookies module. 3 | """ 4 | from unittest import mock 5 | 6 | import ddt 7 | from django.test import TestCase, override_settings 8 | 9 | from edx_rest_framework_extensions.auth.jwt.decoder import jwt_decode_handler 10 | from edx_rest_framework_extensions.auth.jwt.tests.utils import ( 11 | generate_jwt_token, 12 | generate_latest_version_payload, 13 | ) 14 | from edx_rest_framework_extensions.tests.factories import UserFactory 15 | 16 | from .. import cookies 17 | 18 | 19 | @ddt.ddt 20 | class TestJwtAuthCookies(TestCase): 21 | @ddt.data( 22 | (cookies.jwt_cookie_name, 'JWT_AUTH_COOKIE', 'custom-jwt-cookie-name'), 23 | (cookies.jwt_cookie_header_payload_name, 'JWT_AUTH_COOKIE_HEADER_PAYLOAD', 'custom-jwt-header-payload-name'), 24 | (cookies.jwt_cookie_signature_name, 'JWT_AUTH_COOKIE_SIGNATURE', 'custom-jwt-signature-name'), 25 | ) 26 | @ddt.unpack 27 | def test_get_setting_value(self, jwt_cookie_func, setting_name, setting_value): 28 | with override_settings(JWT_AUTH={setting_name: setting_value}): 29 | self.assertEqual(jwt_cookie_func(), setting_value) 30 | 31 | @ddt.data( 32 | (cookies.jwt_cookie_name, 'edx-jwt-cookie'), 33 | (cookies.jwt_cookie_header_payload_name, 'edx-jwt-cookie-header-payload'), 34 | (cookies.jwt_cookie_signature_name, 'edx-jwt-cookie-signature'), 35 | ) 36 | @ddt.unpack 37 | def test_get_default_value(self, jwt_cookie_func, expected_default_value): 38 | self.assertEqual(jwt_cookie_func(), expected_default_value) 39 | 40 | def test_get_decoded_jwt_from_existing_cookie(self): 41 | user = UserFactory() 42 | payload = generate_latest_version_payload(user) 43 | jwt = generate_jwt_token(payload) 44 | expected_decoded_jwt = jwt_decode_handler(jwt) 45 | 46 | mock_request_with_cookie = mock.Mock(COOKIES={'edx-jwt-cookie': jwt}) 47 | 48 | decoded_jwt = cookies.get_decoded_jwt(mock_request_with_cookie) 49 | self.assertEqual(expected_decoded_jwt, decoded_jwt) 50 | 51 | def test_get_decoded_jwt_when_no_cookie(self): 52 | mock_request = mock.Mock(COOKIES={}) 53 | 54 | self.assertIsNone(cookies.get_decoded_jwt(mock_request)) 55 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/jwt/tests/test_decoder.py: -------------------------------------------------------------------------------- 1 | """ Tests for utility functions. """ 2 | import copy 3 | from unittest import mock 4 | 5 | import ddt 6 | import jwt 7 | from django.conf import settings 8 | from django.test import TestCase, override_settings 9 | 10 | from edx_rest_framework_extensions.auth.jwt.decoder import ( 11 | decode_jwt_filters, 12 | decode_jwt_is_restricted, 13 | decode_jwt_scopes, 14 | get_asymmetric_only_jwt_decode_handler, 15 | jwt_decode_handler, 16 | unsafe_jwt_decode_handler, 17 | ) 18 | from edx_rest_framework_extensions.auth.jwt.tests.utils import ( 19 | generate_asymmetric_jwt_token, 20 | generate_jwt_token, 21 | generate_latest_version_payload, 22 | generate_unversioned_payload, 23 | ) 24 | from edx_rest_framework_extensions.tests.factories import UserFactory 25 | 26 | 27 | def exclude_from_jwt_auth_setting(key): 28 | """ 29 | Clone the JWT_AUTH setting dict and remove the given key. 30 | """ 31 | jwt_auth = copy.deepcopy(settings.JWT_AUTH) 32 | del jwt_auth[key] 33 | return jwt_auth 34 | 35 | 36 | def update_jwt_auth_setting(jwt_auth_overrides): 37 | """ 38 | Clone the JWT_AUTH setting dict and update it with the given overrides. 39 | """ 40 | jwt_auth = copy.deepcopy(settings.JWT_AUTH) 41 | jwt_auth.update(jwt_auth_overrides) 42 | return jwt_auth 43 | 44 | 45 | @ddt.ddt 46 | class JWTDecodeHandlerTests(TestCase): 47 | """ Tests for the `jwt_decode_handler` utility function. """ 48 | def setUp(self): 49 | super().setUp() 50 | self.user = UserFactory() 51 | self.payload = generate_latest_version_payload(self.user) 52 | self.jwt = generate_jwt_token(self.payload) 53 | 54 | def test_success(self): 55 | """ 56 | Confirms that the format of the valid response from the token decoder matches the payload 57 | """ 58 | self.assertDictEqual(jwt_decode_handler(self.jwt), self.payload) 59 | 60 | @ddt.data(*settings.JWT_AUTH['JWT_ISSUERS']) 61 | def test_valid_token_multiple_valid_issuers(self, jwt_issuer): 62 | """ 63 | Validates that a valid token is properly decoded given a list of multiple valid issuers 64 | """ 65 | 66 | # Verify that each valid issuer is properly matched against the valid issuers list 67 | # and used to decode the token that was generated using said valid issuer data 68 | self.payload['iss'] = jwt_issuer['ISSUER'] 69 | token = generate_jwt_token(self.payload, jwt_issuer['SECRET_KEY']) 70 | self.assertEqual(jwt_decode_handler(token), self.payload) 71 | 72 | def test_failure_invalid_issuer(self): 73 | """ 74 | Verifies the function logs decode failures with invalid issuer, 75 | and raises an InvalidTokenError if the token cannot be decoded 76 | """ 77 | 78 | # Create tokens using each invalid issuer and attempt to decode them against 79 | # the valid issuers list, which won't work 80 | with mock.patch('edx_rest_framework_extensions.auth.jwt.decoder.logger') as patched_log: 81 | with self.assertRaises(jwt.InvalidTokenError): 82 | self.payload['iss'] = 'invalid-issuer' 83 | # signing key of None will use the default valid signing key 84 | valid_signing_key = None 85 | # Generate a token using the invalid issuer data 86 | token = generate_jwt_token(self.payload, valid_signing_key) 87 | # Attempt to decode the token against the entries in the valid issuers list, 88 | # which will fail with an InvalidTokenError 89 | jwt_decode_handler(token) 90 | 91 | msg = "Token decode failed due to mismatched issuer [%s]" 92 | patched_log.info.assert_any_call(msg, 'invalid-issuer') 93 | 94 | def test_failure_invalid_token(self): 95 | """ 96 | Verifies the function logs decode failures, and raises an InvalidTokenError if the token cannot be decoded 97 | """ 98 | 99 | # Create tokens using each invalid issuer and attempt to decode them against 100 | # the valid issuers list, which won't work 101 | with mock.patch('edx_rest_framework_extensions.auth.jwt.decoder.logger') as patched_log: 102 | with self.assertRaises(jwt.InvalidTokenError): 103 | # Attempt to decode an invalid token, which will fail with an InvalidTokenError 104 | jwt_decode_handler("invalid.token") 105 | 106 | patched_log.exception.assert_any_call("Token verification failed.") 107 | 108 | @override_settings(JWT_AUTH=exclude_from_jwt_auth_setting('JWT_SUPPORTED_VERSION')) 109 | def test_supported_jwt_version_not_specified(self): 110 | """ 111 | Verifies the JWT is decoded successfully when the JWT_SUPPORTED_VERSION setting is not specified. 112 | """ 113 | token = generate_jwt_token(self.payload) 114 | self.assertDictEqual(jwt_decode_handler(token), self.payload) 115 | 116 | @ddt.data(None, '0.5.0', '1.0.0', '1.0.5', '1.5.0', '1.5.5') 117 | def test_supported_jwt_version(self, jwt_version): 118 | """ 119 | Verifies the JWT is decoded successfully with different supported versions in the token. 120 | """ 121 | jwt_payload = generate_latest_version_payload(self.user, version=jwt_version) 122 | token = generate_jwt_token(jwt_payload) 123 | self.assertDictEqual(jwt_decode_handler(token), jwt_payload) 124 | 125 | @override_settings(JWT_AUTH=update_jwt_auth_setting({'JWT_SUPPORTED_VERSION': '0.5.0'})) 126 | def test_unsupported_jwt_version(self): 127 | """ 128 | Verifies the function logs decode failures, and raises an 129 | InvalidTokenError if the token version is not supported. 130 | """ 131 | with mock.patch('edx_rest_framework_extensions.auth.jwt.decoder.logger') as patched_log: 132 | with self.assertRaises(jwt.InvalidTokenError): 133 | token = generate_jwt_token(self.payload) 134 | jwt_decode_handler(token) 135 | 136 | msg = "Token decode failed due to unsupported JWT version number [%s]" 137 | patched_log.info.assert_any_call(msg, '1.1.0') 138 | 139 | def test_upgrade(self): 140 | """ 141 | Verifies the JWT is upgraded when an old (starting) version is provided. 142 | """ 143 | jwt_payload = generate_unversioned_payload(self.user) 144 | token = generate_jwt_token(jwt_payload) 145 | 146 | upgraded_payload = generate_latest_version_payload(self.user, version='1.0.0') 147 | 148 | # Keep time-related values constant for full-proof comparison. 149 | upgraded_payload['iat'], upgraded_payload['exp'] = jwt_payload['iat'], jwt_payload['exp'] 150 | self.assertDictEqual(jwt_decode_handler(token), upgraded_payload) 151 | 152 | def test_failure_invalid_signature(self): 153 | """ 154 | Verifies the function logs decode failures with invalid signature, 155 | and raises an InvalidTokenError if the token cannot be decoded 156 | """ 157 | # Create tokens using each invalid signature and attempt to decode them against 158 | # the valid signature. 159 | with mock.patch('edx_rest_framework_extensions.auth.jwt.decoder.logger') as patched_log: 160 | with self.assertRaises(jwt.InvalidTokenError): 161 | invalid_signing_key = 'invalid-secret-key' 162 | 163 | # Generate a token using the invalid signing key data 164 | token = generate_jwt_token(self.payload, invalid_signing_key) 165 | # Attempt to decode the token against invalid signature, 166 | # which will fail with an InvalidTokenError 167 | jwt_decode_handler(token) 168 | 169 | patched_log.exception.assert_any_call("Token verification failed.") 170 | 171 | @ddt.data("exp", "iat") 172 | def test_required_claims(self, claim): 173 | """ 174 | Verify that tokens that do not carry 'exp' or 'iat' claims are rejected 175 | """ 176 | # Deletes required claim from payload 177 | del self.payload[claim] 178 | token = generate_jwt_token(self.payload) 179 | with self.assertRaises(jwt.MissingRequiredClaimError): 180 | # Decode to see if MissingRequiredClaimError exception is raised or not 181 | jwt_decode_handler(token) 182 | 183 | def test_failure_decode_symmetric_set_as_False(self): 184 | """ 185 | Verifies the function logs decode failures with symmetric token set as false, 186 | and raises an InvalidTokenError if token is symmetric 187 | """ 188 | # Create valid token symmetric jwt token and set decode_symmetric_token as False. 189 | with mock.patch('edx_rest_framework_extensions.auth.jwt.decoder.logger') as patched_log: 190 | with self.assertRaises(jwt.InvalidTokenError): 191 | token = generate_jwt_token(self.payload) 192 | jwt_decode_handler(token, decode_symmetric_token=False) 193 | 194 | patched_log.exception.assert_any_call("Token verification failed.") 195 | 196 | def test_success_asymmetric_jwt_decode(self): 197 | """ 198 | Validates that a valid asymmetric token is properly decoded 199 | """ 200 | token = generate_asymmetric_jwt_token(self.payload) 201 | self.assertEqual(get_asymmetric_only_jwt_decode_handler(token), self.payload) 202 | 203 | @mock.patch('edx_rest_framework_extensions.auth.jwt.decoder.set_custom_attribute') 204 | def test_keyset_size_and_other_monitoring(self, mock_set_custom_attribute): 205 | """ 206 | Validates a variety of custom attributes are recorded, including the keyset size. 207 | """ 208 | asymmetric_token = generate_asymmetric_jwt_token(self.payload) 209 | symmetric_token = generate_jwt_token(self.payload) 210 | 211 | # The secret key is included by default making a list of length 2, but for 212 | # asymmetric-only there is only 1 key in the keyset. 213 | self.assertEqual(jwt_decode_handler(asymmetric_token), self.payload) 214 | self.assertEqual(get_asymmetric_only_jwt_decode_handler(asymmetric_token), self.payload) 215 | self.assertEqual(jwt_decode_handler(symmetric_token), self.payload) 216 | 217 | assert mock_set_custom_attribute.call_args_list == [ 218 | mock.call('jwt_auth_check_symmetric_key', True), 219 | mock.call('jwt_auth_verify_asymmetric_keys_count', 1), 220 | mock.call('jwt_auth_asymmetric_verified', True), 221 | mock.call('jwt_auth_issuer', 'test-issuer-1'), 222 | mock.call('jwt_auth_issuer_verification', 'matches-first-issuer'), 223 | 224 | mock.call('jwt_auth_check_symmetric_key', False), 225 | mock.call('jwt_auth_verify_asymmetric_keys_count', 1), 226 | mock.call('jwt_auth_asymmetric_verified', True), 227 | mock.call('jwt_auth_issuer', 'test-issuer-1'), 228 | mock.call('jwt_auth_issuer_verification', 'matches-first-issuer'), 229 | 230 | mock.call('jwt_auth_check_symmetric_key', True), 231 | mock.call('jwt_auth_verify_asymmetric_keys_count', 1), 232 | mock.call('jwt_auth_verify_all_keys_count', 2), 233 | mock.call('jwt_auth_symmetric_verified', True), 234 | mock.call('jwt_auth_issuer', 'test-issuer-1'), 235 | mock.call('jwt_auth_issuer_verification', 'matches-first-issuer'), 236 | ] 237 | 238 | def test_unsafe_success_with_invalid_token(self): 239 | """ 240 | Verifies unsafe decode is successful, even with invalid claims and signature 241 | """ 242 | self.payload['iss'] = 'invalid-iss' 243 | self.payload['exp'] = 'invalid-exp' 244 | self.payload['aud'] = 'invalid-aud' 245 | invalid_signing_key = 'invalid-secret-key' 246 | 247 | # Generate a token using the invalid signing key 248 | token = generate_jwt_token(self.payload, invalid_signing_key) 249 | decoded_token = unsafe_jwt_decode_handler(token) 250 | assert decoded_token['preferred_username'] is not None 251 | 252 | 253 | def _jwt_decode_handler_with_defaults(token): # pylint: disable=unused-argument 254 | """ 255 | Accepts anything as a token and returns a fake JWT payload with defaults. 256 | """ 257 | return { 258 | 'scopes': ['fake:scope'], 259 | 'is_restricted': True, 260 | 'filters': ['fake:filter'], 261 | } 262 | 263 | 264 | def _jwt_decode_handler_no_defaults(token): # pylint: disable=unused-argument 265 | """ 266 | Accepts anything as a token and returns a fake JWT payload with no defaults. 267 | """ 268 | return {} 269 | 270 | 271 | @ddt.ddt 272 | class JWTDecodeHandlerSettingTests(TestCase): 273 | """ 274 | Tests to ensure utility functions respect JWT_DECODE_HANDLER setting. 275 | 276 | Note: An attempt was made to use ``override_settings`` to actually set 277 | ``JWT_DECODE_HANDLER``, but clean-up of the tests in tearDown was not working, 278 | even after reloading the module, and it was failing other tests in the test suite. 279 | """ 280 | NORMALLY_INVALID_TOKEN = 'this is a valid jwt only with fake_jwt_decode_handler' 281 | 282 | @ddt.data( 283 | ('_jwt_decode_handler_with_defaults', ['fake:scope']), 284 | ('_jwt_decode_handler_no_defaults', []) 285 | ) 286 | @ddt.unpack 287 | @mock.patch('edx_rest_framework_extensions.auth.jwt.decoder.api_settings') 288 | def test_decode_jwt_scopes(self, jwt_decode_handler_name, expected_scope, mock_api_settings): 289 | mock_api_settings.JWT_DECODE_HANDLER = globals()[jwt_decode_handler_name] 290 | scopes = decode_jwt_scopes(self.NORMALLY_INVALID_TOKEN) 291 | self.assertEqual(scopes, expected_scope) 292 | 293 | @ddt.data( 294 | ('_jwt_decode_handler_with_defaults', True), 295 | ('_jwt_decode_handler_no_defaults', False) 296 | ) 297 | @ddt.unpack 298 | @mock.patch('edx_rest_framework_extensions.auth.jwt.decoder.api_settings') 299 | def test_decode_jwt_is_restricted(self, jwt_decode_handler_name, expected_is_restricted, mock_api_settings): 300 | mock_api_settings.JWT_DECODE_HANDLER = globals()[jwt_decode_handler_name] 301 | is_restricted = decode_jwt_is_restricted(self.NORMALLY_INVALID_TOKEN) 302 | self.assertEqual(is_restricted, expected_is_restricted) 303 | 304 | @ddt.data( 305 | ('_jwt_decode_handler_with_defaults', [['fake', 'filter']]), 306 | ('_jwt_decode_handler_no_defaults', []) 307 | ) 308 | @ddt.unpack 309 | @mock.patch('edx_rest_framework_extensions.auth.jwt.decoder.api_settings') 310 | def test_decode_jwt_filters(self, jwt_decode_handler_name, expected_filter, mock_api_settings): 311 | mock_api_settings.JWT_DECODE_HANDLER = globals()[jwt_decode_handler_name] 312 | filters = decode_jwt_filters(self.NORMALLY_INVALID_TOKEN) 313 | self.assertEqual(filters, expected_filter) 314 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/jwt/tests/utils.py: -------------------------------------------------------------------------------- 1 | """ Utility functions for tests. """ 2 | from time import time 3 | 4 | import jwt 5 | from django.conf import settings 6 | from jwt.api_jwk import PyJWK 7 | 8 | 9 | def generate_jwt(user, scopes=None, filters=None, is_restricted=None): 10 | """ 11 | Generate a valid JWT for authenticated requests. 12 | """ 13 | access_token = generate_latest_version_payload( 14 | user, 15 | scopes=scopes, 16 | filters=filters, 17 | is_restricted=is_restricted 18 | ) 19 | return generate_jwt_token(access_token) 20 | 21 | 22 | def generate_jwt_token(payload, signing_key=None): 23 | """ 24 | Generate a valid JWT token for authenticated requests. 25 | """ 26 | signing_key = signing_key or settings.JWT_AUTH['JWT_ISSUERS'][0]['SECRET_KEY'] 27 | return jwt.encode(payload, signing_key) 28 | 29 | 30 | def generate_asymmetric_jwt_token(payload): 31 | """ 32 | Generate a valid asymmetric JWT token for authenticated requests. 33 | """ 34 | private_key = PyJWK.from_json(settings.JWT_AUTH['JWT_PRIVATE_SIGNING_JWK']) 35 | algorithm = settings.JWT_AUTH['JWT_SIGNING_ALGORITHM'] 36 | return jwt.encode(payload, key=private_key.key, algorithm=algorithm) 37 | 38 | 39 | def generate_latest_version_payload(user, scopes=None, filters=None, version=None, 40 | is_restricted=None): 41 | """ 42 | Generate a valid JWT payload given a user and optionally scopes and filters. 43 | """ 44 | payload = generate_unversioned_payload(user) 45 | payload.update({ 46 | # fix this version and add newly introduced fields as the version updates. 47 | 'version': '1.1.0', 48 | 'filters': [], 49 | 'is_restricted': False, 50 | }) 51 | if scopes is not None: 52 | payload['scopes'] = scopes 53 | if version is not None: 54 | payload['version'] = version 55 | if filters is not None: 56 | payload['filters'] = filters 57 | if is_restricted is not None: 58 | payload['is_restricted'] = is_restricted 59 | return payload 60 | 61 | 62 | def generate_unversioned_payload(user): 63 | """ 64 | Generate an unversioned valid JWT payload given a user. 65 | 66 | WARNING: This test utility is mocking JWT creation of the identity service (LMS). 67 | - A safer alternative might be to move the LMS's JWT creation code to this library. 68 | """ 69 | jwt_issuer_data = settings.JWT_AUTH['JWT_ISSUERS'][0] 70 | now = int(time()) 71 | ttl = 600 72 | payload = { 73 | 'iss': jwt_issuer_data['ISSUER'], 74 | 'aud': jwt_issuer_data['AUDIENCE'], 75 | 'preferred_username': user.username, # preferred_username is used by Open edX JWTs. 76 | # WARNING: This `user_id` implementation could lead to bugs because `user_id` should be 77 | # conditionally added based on scope, and should not always be available. 78 | 'user_id': user.id, 79 | 'email': user.email, 80 | 'iat': now, 81 | 'exp': now + ttl, 82 | 'scopes': [], 83 | } 84 | return payload 85 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/session/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-drf-extensions/1311a63c3e0d1e93be8bf46a05ec01fb98e29cd4/edx_rest_framework_extensions/auth/session/__init__.py -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/session/authentication.py: -------------------------------------------------------------------------------- 1 | """ Session Authentication classes. """ 2 | 3 | from rest_framework.authentication import SessionAuthentication 4 | 5 | 6 | class SessionAuthenticationAllowInactiveUser(SessionAuthentication): 7 | """Ensure that the user is logged in, but do not require the account to be active. 8 | 9 | We use this in the special case that a user has created an account, 10 | but has not yet activated it. We still want to allow the user to 11 | enroll in courses, so we remove the usual restriction 12 | on session authentication that requires an active account. 13 | 14 | You should use this authentication class ONLY for end-points that 15 | it's safe for an un-activated user to access. For example, 16 | we can allow a user to update his/her own enrollments without 17 | activating an account. 18 | 19 | """ 20 | def authenticate(self, request): 21 | """Authenticate the user, requiring a logged-in account and CSRF. 22 | 23 | This is exactly the same as the `SessionAuthentication` implementation, 24 | with the `user.is_active` check removed. 25 | 26 | Args: 27 | request (HttpRequest) 28 | 29 | Returns: 30 | Tuple of `(user, token)` 31 | 32 | Raises: 33 | PermissionDenied: The CSRF token check failed. 34 | 35 | """ 36 | # Get the underlying HttpRequest object 37 | request = request._request # pylint: disable=protected-access 38 | user = getattr(request, 'user', None) 39 | 40 | # Unauthenticated, CSRF validation not required 41 | # This is where regular `SessionAuthentication` checks that the user is active. 42 | # We have removed that check in this implementation. 43 | # But we added a check to prevent anonymous users since we require a logged-in account. 44 | if not user or user.is_anonymous: 45 | return None 46 | 47 | self.enforce_csrf(request) 48 | 49 | # CSRF passed with authenticated user 50 | return (user, None) 51 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/session/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-drf-extensions/1311a63c3e0d1e93be8bf46a05ec01fb98e29cd4/edx_rest_framework_extensions/auth/session/tests/__init__.py -------------------------------------------------------------------------------- /edx_rest_framework_extensions/auth/session/tests/test_authentication.py: -------------------------------------------------------------------------------- 1 | """ Test for SessionAuthenticationAllowInactiveUser class """ 2 | from django.test import RequestFactory, TestCase 3 | 4 | from edx_rest_framework_extensions.auth.session.authentication import ( 5 | SessionAuthenticationAllowInactiveUser, 6 | ) 7 | from edx_rest_framework_extensions.tests import factories 8 | 9 | 10 | class SessionAuthenticationAllowInactiveUserTests(TestCase): 11 | def setUp(self): 12 | super().setUp() 13 | self.user = factories.UserFactory( 14 | email='inactive', username='inactive@example.com', password='dummypassword', is_active=False 15 | ) 16 | self.request = RequestFactory().get('/') 17 | 18 | def test_authenticate(self): 19 | """Verify inactive user is authenticated.""" 20 | self.request.user = self.user 21 | self.request._request = self.request # pylint: disable=protected-access 22 | user, _ = SessionAuthenticationAllowInactiveUser().authenticate(self.request) 23 | self.assertEqual(user, self.user) 24 | 25 | def test_user_not_exist(self): 26 | """Verify request with no user return None.""" 27 | self.request._request = self.request # pylint: disable=protected-access 28 | user = SessionAuthenticationAllowInactiveUser().authenticate(self.request) 29 | self.assertEqual(user, None) 30 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Application configuration constants and code. 3 | """ 4 | 5 | # .. toggle_name: EDX_DRF_EXTENSIONS[ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE] 6 | # .. toggle_implementation: DjangoSetting 7 | # .. toggle_default: False 8 | # .. toggle_description: Toggle for setting request.user with jwt cookie authentication. This makes the JWT cookie 9 | # user available to middleware while processing the request, if the session user wasn't already available. This 10 | # requires JwtAuthCookieMiddleware to work. 11 | # .. toggle_use_cases: temporary 12 | # .. toggle_creation_date: 2019-10-15 13 | # .. toggle_target_removal_date: 2024-12-31 14 | # .. toggle_warning: This feature caused a memory leak in edx-platform. This toggle is temporary only if we can make it 15 | # work in all services, or find a replacement. Consider making this a permanent toggle instead. 16 | # .. toggle_tickets: ARCH-1210, ARCH-1199, ARCH-1197 17 | ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE = 'ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE' 18 | 19 | # .. toggle_name: EDX_DRF_EXTENSIONS[ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH] 20 | # .. toggle_implementation: DjangoSetting 21 | # .. toggle_default: False 22 | # .. toggle_description: Toggle to add a check for matching user email in JWT and LMS user email 23 | # for authentication in JwtAuthentication class. This toggle should only be enabled in the 24 | # LMS as our identity service that is also creating the JWTs. 25 | # .. toggle_use_cases: open_edx 26 | # .. toggle_creation_date: 2023-12-20 27 | # .. toggle_tickets: VAN-1694 28 | ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH = 'ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH' 29 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/exceptions.py: -------------------------------------------------------------------------------- 1 | """ Custom exceptions. """ 2 | 3 | 4 | class UserInfoRetrievalFailed(Exception): 5 | """ Raised when we fail to retrieve user info (e.g. from the OAuth provider). """ 6 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/middleware.py: -------------------------------------------------------------------------------- 1 | """ 2 | Middleware to ensure best practices of DRF and other endpoints. 3 | """ 4 | import warnings 5 | 6 | from django.utils.deprecation import MiddlewareMixin 7 | from edx_django_utils import monitoring 8 | from edx_django_utils.cache import DEFAULT_REQUEST_CACHE 9 | 10 | import edx_rest_framework_extensions 11 | from edx_rest_framework_extensions.auth.jwt.cookies import jwt_cookie_name 12 | 13 | 14 | class RequestCustomAttributesMiddleware(MiddlewareMixin): 15 | """ 16 | Adds various request related custom attributes. 17 | 18 | Possible custom attributes include: 19 | request_authenticated_user_set_in_middleware: 20 | Example values: 'process_request', 'process_view', 'process_response', or 'process_exception'. 21 | 22 | Attribute won't exist if user is not authenticated. 23 | 24 | request_auth_type_guess: 25 | Example values include: no-user, unauthenticated, jwt, bearer, other-token-type, jwt-cookie, or 26 | session-or-other 27 | 28 | Note: These are just guesses because if a token was expired, for example, 29 | the user could have been authenticated by some other means. 30 | 31 | request_client_name: The client name from edx-rest-api-client calls. 32 | request_referer: The referrer for the request. 33 | request_user_agent: The user agent string from the request header. 34 | request_user_id: The user id of the request user. 35 | 36 | request_is_staff_or_superuser: `staff` or `superuser` depending on whether the 37 | user in the request is a django staff or superuser. 38 | 39 | This middleware is dependent on the RequestCacheMiddleware. You must 40 | include this middleware later. For example:: 41 | 42 | MIDDLEWARE = ( 43 | 'edx_django_utils.cache.middleware.RequestCacheMiddleware', 44 | 'edx_rest_framework_extensions.middleware.RequestCustomAttributesMiddleware', 45 | ) 46 | 47 | This middleware should also appear after any authentication middleware. 48 | 49 | """ 50 | def process_request(self, request): 51 | """ 52 | Caches if authenticated user was found. 53 | """ 54 | self._cache_if_authenticated_user_found_in_middleware(request, 'process_request') 55 | 56 | def process_view(self, request, view_func, view_args, view_kwargs): # pylint: disable=unused-argument 57 | """ 58 | Caches if authenticated user was found. 59 | """ 60 | self._cache_if_authenticated_user_found_in_middleware(request, 'process_view') 61 | 62 | def process_response(self, request, response): 63 | """ 64 | Add custom attributes for various details of the request. 65 | """ 66 | self._cache_if_authenticated_user_found_in_middleware(request, 'process_response') 67 | self._set_all_request_attributes(request) 68 | return response 69 | 70 | def process_exception(self, request, exception): # pylint: disable=unused-argument 71 | """ 72 | Django middleware handler to process an exception 73 | """ 74 | self._cache_if_authenticated_user_found_in_middleware(request, 'process_exception') 75 | self._set_all_request_attributes(request) 76 | 77 | def _set_all_request_attributes(self, request): 78 | """ 79 | Sets all the request custom attributes 80 | """ 81 | # .. custom_attribute_name: edx_drf_extensions_version 82 | # .. custom_attribute_description: The version of the edx-drf-extensions library installed, which may be 83 | # useful when trying to rollout important changes to all services. Note that RequestCustomAttributesMiddleware 84 | # must be installed for this to work. Also, versions before 8.7.0 will not include this attribute, but 85 | # should have ``request_auth_type_guess``. 86 | monitoring.set_custom_attribute('edx_drf_extensions_version', edx_rest_framework_extensions.__version__) 87 | 88 | self._set_request_auth_type_guess_attribute(request) 89 | self._set_request_user_agent_attributes(request) 90 | self._set_request_referer_attribute(request) 91 | self._set_request_user_id_attribute(request) 92 | self._set_request_authenticated_user_found_in_middleware_attribute() 93 | self._set_request_is_staff_or_superuser(request) 94 | 95 | def _set_request_is_staff_or_superuser(self, request): 96 | """ 97 | Add `request_is_staff_or_superuser` custom attribute. 98 | 99 | Custom Attributes: 100 | request_is_staff_or_superuser 101 | """ 102 | value = None 103 | if hasattr(request, 'user') and request.user: 104 | if request.user.is_superuser: 105 | value = 'superuser' 106 | elif request.user.is_staff: 107 | value = 'staff' 108 | 109 | if value: 110 | monitoring.set_custom_attribute('request_is_staff_or_superuser', value) 111 | 112 | def _set_request_user_id_attribute(self, request): 113 | """ 114 | Add enduser.id (and request_user_id) custom attributes. 115 | """ 116 | if hasattr(request, 'user') and hasattr(request.user, 'id') and request.user.id: 117 | # .. custom_attribute_name: enduser.id 118 | # .. custom_attribute_description: The user's id when available. The name enduser.id is an 119 | # OpenTelemetry convention that works with some of New Relic's tooling. See 120 | # https://docs.newrelic.com/docs/errors-inbox/error-users-impacted/ 121 | monitoring.set_custom_attribute('enduser.id', request.user.id) 122 | # .. custom_attribute_name: request_user_id 123 | # .. custom_attribute_description: The user's id when available. This duplicates enduser.id, 124 | # and could be deprecated/removed. 125 | monitoring.set_custom_attribute('request_user_id', request.user.id) 126 | 127 | def _set_request_referer_attribute(self, request): 128 | """ 129 | Add custom attribute 'request_referer' for http referer. 130 | """ 131 | if 'referer' in request.headers and request.headers['referer']: 132 | monitoring.set_custom_attribute('request_referer', request.headers['referer']) 133 | 134 | def _set_request_user_agent_attributes(self, request): 135 | """ 136 | Add custom attributes for user agent for python. 137 | 138 | Custom Attributes: 139 | request_user_agent 140 | request_client_name: The client name from edx-rest-api-client calls. 141 | """ 142 | if 'user-agent' in request.headers and request.headers['user-agent']: 143 | user_agent = request.headers['user-agent'] 144 | monitoring.set_custom_attribute('request_user_agent', user_agent) 145 | if user_agent: 146 | # Example agent string from edx-rest-api-client: 147 | # python-requests/2.9.1 edx-rest-api-client/1.7.2 ecommerce 148 | # See https://github.com/openedx/edx-rest-api-client/commit/692903c30b157f7a4edabc2f53aae1742db3a019 149 | user_agent_parts = user_agent.split() 150 | if len(user_agent_parts) == 3 and user_agent_parts[1].startswith('edx-rest-api-client/'): 151 | monitoring.set_custom_attribute('request_client_name', user_agent_parts[2]) 152 | 153 | def _set_request_auth_type_guess_attribute(self, request): 154 | """ 155 | Add custom attribute 'request_auth_type_guess' for the authentication type used. 156 | """ 157 | if not hasattr(request, 'user') or not request.user: 158 | auth_type = 'no-user' 159 | elif not request.user.is_authenticated: 160 | auth_type = 'unauthenticated' 161 | elif 'authorization' in request.headers and request.headers['authorization']: 162 | token_parts = request.headers['authorization'].split() 163 | # Example: "JWT eyJhbGciO..." 164 | if len(token_parts) == 2: 165 | auth_type = token_parts[0].lower() # 'jwt' or 'bearer' (for example) 166 | else: 167 | auth_type = 'other-token-type' 168 | elif jwt_cookie_name() in request.COOKIES: 169 | auth_type = 'jwt-cookie' 170 | else: 171 | auth_type = 'session-or-other' 172 | 173 | # .. custom_attribute_name: request_auth_type_guess 174 | # .. custom_attribute_description: This is a somewhat odd custom attribute, because 175 | # we are taking a guess at authentication. Possible values include: 176 | # no-user, 177 | # unauthenticated, 178 | # jwt/bearer/other-token-type, 179 | # jwt-cookie, 180 | # session-or-other (catch all). 181 | monitoring.set_custom_attribute('request_auth_type_guess', auth_type) 182 | 183 | AUTHENTICATED_USER_FOUND_CACHE_KEY = 'edx-drf-extensions.authenticated_user_found_in_middleware' 184 | 185 | def _set_request_authenticated_user_found_in_middleware_attribute(self): 186 | """ 187 | Add custom attribute 'request_authenticated_user_found_in_middleware' if authenticated user was found. 188 | """ 189 | cached_response = DEFAULT_REQUEST_CACHE.get_cached_response(self.AUTHENTICATED_USER_FOUND_CACHE_KEY) 190 | if cached_response.is_found: 191 | monitoring.set_custom_attribute( 192 | 'request_authenticated_user_found_in_middleware', 193 | cached_response.value 194 | ) 195 | 196 | def _cache_if_authenticated_user_found_in_middleware(self, request, value): 197 | """ 198 | Updates the cached process step in which the authenticated user was found, if it hasn't already been found. 199 | """ 200 | cached_response = DEFAULT_REQUEST_CACHE.get_cached_response(self.AUTHENTICATED_USER_FOUND_CACHE_KEY) 201 | if cached_response.is_found: 202 | # since we are tracking the earliest point the authenticated user was found, 203 | # and the value was already set in earlier middleware step, do not set again. 204 | return 205 | 206 | if hasattr(request, 'user') and request.user and request.user.is_authenticated: 207 | DEFAULT_REQUEST_CACHE.set(self.AUTHENTICATED_USER_FOUND_CACHE_KEY, value) 208 | 209 | 210 | class RequestMetricsMiddleware(RequestCustomAttributesMiddleware): 211 | """ 212 | Deprecated class for handling middleware. Class has been renamed to RequestCustomAttributesMiddleware. 213 | """ 214 | def __init__(self, *args, **kwargs): 215 | super().__init__(*args, **kwargs) 216 | msg = "Use 'RequestCustomAttributesMiddleware' in place of 'RequestMetricsMiddleware'." 217 | warnings.warn(msg, DeprecationWarning) 218 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/paginators.py: -------------------------------------------------------------------------------- 1 | """ Paginatator methods for edX API implementations.""" 2 | 3 | from django.core.paginator import InvalidPage, Paginator 4 | from django.http import Http404 5 | from rest_framework import pagination 6 | from rest_framework.response import Response 7 | 8 | 9 | class DefaultPagination(pagination.PageNumberPagination): 10 | """ 11 | Default paginator for APIs in edx-platform. 12 | 13 | This is configured in settings to be automatically used 14 | by any subclass of Django Rest Framework's generic API views. 15 | """ 16 | page_size_query_param = "page_size" 17 | page_size = 10 18 | max_page_size = 100 19 | 20 | def get_paginated_response(self, data): 21 | """ 22 | Annotate the response with pagination information. 23 | """ 24 | return Response({ 25 | 'next': self.get_next_link(), 26 | 'previous': self.get_previous_link(), 27 | 'count': self.page.paginator.count, 28 | 'num_pages': self.page.paginator.num_pages, 29 | 'current_page': self.page.number, 30 | 'start': (self.page.number - 1) * self.get_page_size(self.request), 31 | 'results': data 32 | }) 33 | 34 | 35 | class NamespacedPageNumberPagination(pagination.PageNumberPagination): 36 | """ 37 | Pagination scheme that returns results with pagination metadata 38 | embedded in a "pagination" attribute. Can be used with data 39 | that comes as a list of items, or as a dict with a "results" 40 | attribute that contains a list of items. 41 | """ 42 | 43 | page_size_query_param = "page_size" 44 | 45 | def get_result_count(self): 46 | """ 47 | Returns total number of results 48 | """ 49 | return self.page.paginator.count 50 | 51 | def get_num_pages(self): 52 | """ 53 | Returns total number of pages the results are divided into 54 | """ 55 | return self.page.paginator.num_pages 56 | 57 | def get_paginated_response(self, data): 58 | """ 59 | Annotate the response with pagination information 60 | """ 61 | metadata = { 62 | 'next': self.get_next_link(), 63 | 'previous': self.get_previous_link(), 64 | 'count': self.get_result_count(), 65 | 'num_pages': self.get_num_pages(), 66 | } 67 | if isinstance(data, dict): 68 | if 'results' not in data: 69 | raise TypeError('Malformed result dict') 70 | data['pagination'] = metadata 71 | else: 72 | data = { 73 | 'results': data, 74 | 'pagination': metadata, 75 | } 76 | return Response(data) 77 | 78 | 79 | def paginate_search_results(object_class, search_results, page_size, page): 80 | """ 81 | Takes search results and returns a Page object populated 82 | with db objects for that page. 83 | 84 | :param object_class: Model class to use when querying the db for objects. 85 | :param search_results: search results. 86 | :param page_size: Number of results per page. 87 | :param page: Page number. 88 | :return: Paginator object with model objects 89 | """ 90 | paginator = Paginator(search_results['results'], page_size) 91 | 92 | # This code is taken from within the GenericAPIView#paginate_queryset method. 93 | # It is common code, but 94 | try: 95 | page_number = paginator.validate_number(page) 96 | except InvalidPage as page_error: 97 | if page == 'last': 98 | page_number = paginator.num_pages 99 | else: 100 | raise Http404("Page is not 'last', nor can it be converted to an int.") from page_error 101 | 102 | try: 103 | paged_results = paginator.page(page_number) 104 | except InvalidPage as exception: 105 | raise Http404( 106 | "Invalid page {page_number}: {message}".format( 107 | page_number=page_number, 108 | message=str(exception) 109 | ) 110 | ) from exception 111 | 112 | search_queryset_pks = [item['data']['pk'] for item in paged_results.object_list] 113 | queryset = object_class.objects.filter(pk__in=search_queryset_pks) 114 | 115 | def ordered_objects(primary_key): 116 | """ Returns database object matching the search result object""" 117 | for obj in queryset: 118 | if obj.pk == primary_key: 119 | return obj 120 | return None 121 | 122 | # map over the search results and get a list of database objects in the same order 123 | object_results = list(map(ordered_objects, search_queryset_pks)) 124 | paged_results.object_list = object_results 125 | 126 | return paged_results 127 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/permissions.py: -------------------------------------------------------------------------------- 1 | """ Permission classes. """ 2 | import logging 3 | 4 | from opaque_keys.edx.keys import CourseKey 5 | from rest_framework.permissions import BasePermission, IsAuthenticated 6 | 7 | from edx_rest_framework_extensions.auth.jwt.authentication import is_jwt_authenticated 8 | from edx_rest_framework_extensions.auth.jwt.decoder import ( 9 | decode_jwt_filters, 10 | decode_jwt_is_restricted, 11 | decode_jwt_scopes, 12 | ) 13 | 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | class IsSuperuser(BasePermission): 19 | """ Allows access only to superusers. """ 20 | 21 | def has_permission(self, request, view): 22 | return request.user and request.user.is_superuser 23 | 24 | 25 | class IsStaff(BasePermission): 26 | """ 27 | Allows access to "global" staff users.. 28 | """ 29 | def has_permission(self, request, view): 30 | return request.user.is_staff 31 | 32 | 33 | class IsUserInUrl(BasePermission): 34 | """ 35 | Allows access if the requesting user matches the user in the URL. 36 | """ 37 | def has_permission(self, request, view): 38 | allowed = request.user.username.lower() == get_username_param(request) 39 | if not allowed: 40 | log.info("Permission IsUserInUrl: not satisfied for requesting user %s.", request.user.username) 41 | return allowed 42 | 43 | 44 | class JwtRestrictedApplication(BasePermission): 45 | """ 46 | Allows access if the request was successfully authenticated with JwtAuthentication 47 | by a RestrictedApplication. 48 | """ 49 | message = 'Not a Restricted JWT Application.' 50 | 51 | def has_permission(self, request, view): 52 | ret_val = is_jwt_authenticated(request) and decode_jwt_is_restricted(request.auth) 53 | log.debug("Permission JwtRestrictedApplication: returns %s.", ret_val) 54 | return ret_val 55 | 56 | 57 | class NotJwtRestrictedApplication(BasePermission): 58 | """ 59 | Allows access if either the request was not authenticated with JwtAuthentication, or 60 | if it was successfully authenticated with JwtAuthentication and the Jwt was not 61 | flagged as restricted. 62 | 63 | Note: Anonymous access will also pass this permission. 64 | 65 | """ 66 | def has_permission(self, request, view): 67 | return not JwtRestrictedApplication().has_permission(request, view) 68 | 69 | 70 | class JwtHasScope(BasePermission): 71 | """ 72 | The request is authenticated as a user and the token used has the right scope. 73 | """ 74 | message = 'JWT missing required scopes.' 75 | 76 | def has_permission(self, request, view): 77 | jwt_scopes = decode_jwt_scopes(request.auth) 78 | required_scopes = set(getattr(view, 'required_scopes', [])) 79 | allowed = bool(required_scopes) and required_scopes.issubset(jwt_scopes) 80 | if not allowed: 81 | log.warning( 82 | "Permission JwtHasScope: required scopes '%s' are not a subset of the token's scopes '%s'.", 83 | required_scopes, 84 | jwt_scopes, 85 | ) 86 | return allowed 87 | 88 | 89 | class JwtHasContentOrgFilterForRequestedCourse(BasePermission): 90 | """ 91 | The JWT used to authenticate contains the appropriate content provider 92 | filter for the requested course resource. 93 | """ 94 | message = 'JWT missing required content_org filter.' 95 | 96 | def has_permission(self, request, view): 97 | """ 98 | Ensure that the course_id kwarg provided to the view contains one 99 | of the organizations specified in the content provider filters 100 | in the JWT used to authenticate. 101 | """ 102 | course_key = CourseKey.from_string(view.kwargs.get('course_id')) 103 | jwt_filters = decode_jwt_filters(request.auth) 104 | for filter_type, filter_value in jwt_filters: 105 | if filter_type == 'content_org' and filter_value == course_key.org: 106 | return True 107 | log.warning( 108 | "Permission JwtHasContentOrgFilterForRequestedCourse: no filter found for %s.", 109 | course_key.org, 110 | ) 111 | return False 112 | 113 | 114 | class JwtHasUserFilterForRequestedUser(BasePermission): 115 | """ 116 | The JWT used to authenticate contains the appropriate user filter for the 117 | requested user resource. 118 | """ 119 | message = 'JWT missing required user filter.' 120 | 121 | def has_permission(self, request, view): 122 | """ 123 | If the JWT has a user filter, verify that the filtered 124 | user value matches the user in the URL. 125 | """ 126 | user_filter = self._get_user_filter(request) 127 | if not user_filter: 128 | # no user filters are present in the token to limit access 129 | return True 130 | 131 | username_param = get_username_param(request) 132 | allowed = user_filter == username_param 133 | if not allowed: 134 | log.warning( 135 | "Permission JwtHasUserFilterForRequestedUser: user_filter %s doesn't match username %s.", 136 | user_filter, 137 | username_param, 138 | ) 139 | return allowed 140 | 141 | def _get_user_filter(self, request): 142 | jwt_filters = decode_jwt_filters(request.auth) 143 | for filter_type, filter_value in jwt_filters: 144 | if filter_type == 'user': 145 | if filter_value == 'me': 146 | filter_value = request.user.username.lower() 147 | return filter_value 148 | return None 149 | 150 | 151 | class LoginRedirectIfUnauthenticated(IsAuthenticated): 152 | """ 153 | A DRF permission class that will login redirect unauthorized users. 154 | 155 | It can be used to convert a plain Django view that was using @login_required 156 | into a DRF APIView, which is useful to enable our DRF JwtAuthentication class. 157 | 158 | Requires JwtRedirectToLoginIfUnauthenticatedMiddleware to work. 159 | 160 | """ 161 | 162 | 163 | _NOT_JWT_RESTRICTED_PERMISSIONS = NotJwtRestrictedApplication & (IsStaff | IsUserInUrl) 164 | _JWT_RESTRICTED_PERMISSIONS = ( 165 | JwtRestrictedApplication & 166 | JwtHasScope & 167 | JwtHasContentOrgFilterForRequestedCourse & 168 | JwtHasUserFilterForRequestedUser 169 | ) 170 | JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS = ( 171 | IsAuthenticated & 172 | (_NOT_JWT_RESTRICTED_PERMISSIONS | _JWT_RESTRICTED_PERMISSIONS) 173 | ) 174 | 175 | 176 | def get_username_param(request): 177 | user_parameter_name = 'username' 178 | url_username = ( 179 | getattr(request, 'parser_context', {}).get('kwargs', {}).get(user_parameter_name, '') or 180 | request.GET.get(user_parameter_name, '') 181 | ) 182 | return url_username.lower() 183 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | NOTE: Support for multiple JWT_ISSUERS is deprecated. Instead, Asymmetric JWTs 3 | make this simpler by using JWK keysets to list all available public keys. 4 | 5 | Settings for edx-drf-extensions are all namespaced in the EDX_DRF_EXTENSIONS setting. 6 | For example your project's `settings.py` file might look like this: 7 | 8 | EDX_DRF_EXTENSIONS = { 9 | 'OAUTH2_ACCESS_TOKEN_URL': 'https://example.com/oauth2/access_token' 10 | } 11 | """ 12 | import logging 13 | import warnings 14 | 15 | from django.conf import settings 16 | from rest_framework_jwt.settings import api_settings 17 | 18 | from edx_rest_framework_extensions.config import ( 19 | ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH, 20 | ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE, 21 | ) 22 | 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | DEFAULT_SETTINGS = { 28 | ENABLE_JWT_AND_LMS_USER_EMAIL_MATCH: False, 29 | ENABLE_SET_REQUEST_USER_FOR_JWT_COOKIE: False, 30 | 31 | 'JWT_PAYLOAD_MERGEABLE_USER_ATTRIBUTES': (), 32 | # Map JWT claims to user attributes. 33 | 'JWT_PAYLOAD_USER_ATTRIBUTE_MAPPING': { 34 | 'administrator': 'is_staff', 35 | 'email': 'email', 36 | }, 37 | 38 | 'OAUTH2_USER_INFO_URL': None, 39 | } 40 | 41 | 42 | def get_setting(name): 43 | """ Returns the value of the named setting. 44 | 45 | Arguments: 46 | name (str): Name of the setting to retrieve 47 | 48 | Raises: 49 | KeyError: The specified setting does not exist. 50 | """ 51 | try: 52 | return getattr(settings, 'EDX_DRF_EXTENSIONS', {})[name] 53 | except KeyError: 54 | return DEFAULT_SETTINGS[name] 55 | 56 | 57 | def _get_current_jwt_issuers(): 58 | """ 59 | Internal helper to retrieve the current set of JWT_ISSUERS from the JWT_AUTH configuration 60 | Having this allows for easier testing/mocking 61 | """ 62 | # If we have a 'JWT_ISSUERS' list defined, return it 63 | return settings.JWT_AUTH.get('JWT_ISSUERS', None) 64 | 65 | 66 | def _get_deprecated_jwt_issuers(): 67 | """ 68 | Internal helper to retrieve the deprecated set of JWT_ISSUER data from the JWT_AUTH configuration 69 | Having this allows for easier testing/mocking 70 | """ 71 | # If JWT_ISSUERS is not defined, attempt to return the deprecated settings. 72 | warnings.warn( 73 | "'JWT_ISSUERS' list not defined, checking for deprecated settings.", 74 | DeprecationWarning 75 | ) 76 | 77 | return [ 78 | { 79 | 'ISSUER': api_settings.JWT_ISSUER, 80 | 'SECRET_KEY': api_settings.JWT_SECRET_KEY, 81 | 'AUDIENCE': api_settings.JWT_AUDIENCE 82 | } 83 | ] 84 | 85 | 86 | def get_jwt_issuers(): 87 | """ 88 | Retrieves the JWT_ISSUERS list from system configuraiton. If no list is defined in JWT_AUTH/JWT_ISSUERS 89 | an attempt is made to instead return the deprecated JWT configuration settings. 90 | """ 91 | # If we have a 'JWT_ISSUERS' list defined, return it 92 | jwt_issuers = _get_current_jwt_issuers() 93 | if jwt_issuers: 94 | return jwt_issuers 95 | # If we do not, return the deprecated configuration 96 | return _get_deprecated_jwt_issuers() 97 | 98 | 99 | def get_first_jwt_issuer(): 100 | """ 101 | Retrieves the first issuer in the JWT_ISSUERS list. 102 | 103 | As mentioned above, support for multiple JWT_ISSUERS is deprecated. They 104 | are currently used only to distinguish the "ISSUER" field across sites. 105 | So in many cases, we just need the first issuer value. 106 | """ 107 | return get_jwt_issuers()[0] 108 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-drf-extensions/1311a63c3e0d1e93be8bf46a05ec01fb98e29cd4/edx_rest_framework_extensions/tests/__init__.py -------------------------------------------------------------------------------- /edx_rest_framework_extensions/tests/factories.py: -------------------------------------------------------------------------------- 1 | """ Test factories. """ 2 | import factory 3 | from django.contrib.auth import get_user_model 4 | 5 | 6 | PASSWORD = 'password' 7 | 8 | 9 | class UserFactory(factory.DjangoModelFactory): 10 | """ User factory. """ 11 | username = email = factory.Sequence(lambda n: f'user{n}') 12 | email = factory.Sequence(lambda n: f'user{n}@example.com') 13 | password = factory.PostGenerationMethodCall('set_password', PASSWORD) 14 | is_active = True 15 | is_superuser = False 16 | is_staff = False 17 | 18 | class Meta: 19 | model = get_user_model() 20 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for middlewares. 3 | """ 4 | import re 5 | from unittest.mock import Mock, call, patch 6 | 7 | import ddt 8 | from django.contrib.auth.models import AnonymousUser 9 | from django.test import RequestFactory, TestCase 10 | from edx_django_utils.cache import RequestCache 11 | 12 | from edx_rest_framework_extensions.auth.jwt.cookies import jwt_cookie_name 13 | from edx_rest_framework_extensions.middleware import ( 14 | RequestCustomAttributesMiddleware, 15 | RequestMetricsMiddleware, 16 | ) 17 | from edx_rest_framework_extensions.tests.factories import UserFactory 18 | 19 | 20 | @ddt.ddt 21 | class TestRequestCustomAttributesMiddleware(TestCase): 22 | def setUp(self): 23 | super().setUp() 24 | RequestCache.clear_all_namespaces() 25 | self.request = RequestFactory().get('/') 26 | self.mock_response = Mock() 27 | self.middleware = RequestCustomAttributesMiddleware(self.mock_response) 28 | 29 | @patch('edx_django_utils.monitoring.set_custom_attribute') 30 | def test_edx_drf_extensions_version_attribute(self, mock_set_custom_attribute): 31 | self.request.user = AnonymousUser() 32 | 33 | self.middleware.process_response(self.request, None) 34 | # if call_args_list contains call('edx_drf_extensions_version', '8.7.0'), then version_list = ['8.7.0'] 35 | version_list = [ 36 | x.args[1] for x in mock_set_custom_attribute.call_args_list if x.args[0] == 'edx_drf_extensions_version' 37 | ] 38 | assert len(version_list) == 1 39 | assert re.search(r'\d+\.\d+\.\d+', version_list[0]) 40 | 41 | @patch('edx_django_utils.monitoring.set_custom_attribute') 42 | def test_request_auth_type_guess_anonymous_attribute(self, mock_set_custom_attribute): 43 | self.request.user = AnonymousUser() 44 | 45 | self.middleware.process_response(self.request, None) 46 | mock_set_custom_attribute.assert_called_with('request_auth_type_guess', 'unauthenticated') 47 | 48 | @patch('edx_django_utils.monitoring.set_custom_attribute') 49 | def test_request_no_headers(self, mock_set_custom_attribute): 50 | self.request.user = None 51 | self.middleware.process_response(self.request, None) 52 | mock_set_custom_attribute.assert_called_with('request_auth_type_guess', 'no-user') 53 | 54 | @patch('edx_django_utils.monitoring.set_custom_attribute') 55 | def test_request_blank_headers(self, mock_set_custom_attribute): 56 | self.request.META['HTTP_USER_AGENT'] = '' 57 | self.request.META['HTTP_REFERER'] = '' 58 | self.request.META['HTTP_AUTHORIZATION'] = '' 59 | 60 | self.middleware.process_response(self.request, None) 61 | mock_set_custom_attribute.assert_called_with('request_auth_type_guess', 'no-user') 62 | 63 | @patch('edx_django_utils.monitoring.set_custom_attribute') 64 | def test_request_referer_attribute(self, mock_set_custom_attribute): 65 | self.request.META['HTTP_REFERER'] = 'test-http-referer' 66 | 67 | self.middleware.process_response(self.request, None) 68 | expected_calls = [ 69 | call('request_referer', 'test-http-referer'), 70 | call('request_auth_type_guess', 'no-user'), 71 | ] 72 | mock_set_custom_attribute.assert_has_calls(expected_calls, any_order=True) 73 | 74 | @patch('edx_django_utils.monitoring.set_custom_attribute') 75 | def test_request_rest_client_user_agent_attributes(self, mock_set_custom_attribute): 76 | self.request.META['HTTP_USER_AGENT'] = 'python-requests/2.9.1 edx-rest-api-client/1.7.2 test-client' 77 | 78 | self.middleware.process_response(self.request, None) 79 | expected_calls = [ 80 | call('request_user_agent', 'python-requests/2.9.1 edx-rest-api-client/1.7.2 test-client'), 81 | call('request_client_name', 'test-client'), 82 | call('request_auth_type_guess', 'no-user'), 83 | ] 84 | mock_set_custom_attribute.assert_has_calls(expected_calls, any_order=True) 85 | 86 | @patch('edx_django_utils.monitoring.set_custom_attribute') 87 | def test_request_standard_user_agent_attributes(self, mock_set_custom_attribute): 88 | self.request.META['HTTP_USER_AGENT'] = 'test-user-agent' 89 | 90 | self.middleware.process_response(self.request, None) 91 | expected_calls = [ 92 | call('request_user_agent', 'test-user-agent'), 93 | call('request_auth_type_guess', 'no-user'), 94 | ] 95 | mock_set_custom_attribute.assert_has_calls(expected_calls, any_order=True) 96 | 97 | @ddt.data( 98 | ('jwt abcdefg', 'jwt'), 99 | ('bearer abcdefg', 'bearer'), 100 | ('abcdefg', 'other-token-type'), 101 | ) 102 | @ddt.unpack 103 | @patch('edx_django_utils.monitoring.set_custom_attribute') 104 | def test_request_auth_type_guess_token_attribute(self, token, expected_token_type, mock_set_custom_attribute): 105 | self.request.user = UserFactory() 106 | self.request.META['HTTP_AUTHORIZATION'] = token 107 | 108 | self.middleware.process_response(self.request, None) 109 | mock_set_custom_attribute.assert_any_call('request_auth_type_guess', expected_token_type) 110 | 111 | @patch('edx_django_utils.monitoring.set_custom_attribute') 112 | def test_request_auth_type_guess_jwt_cookie_attribute(self, mock_set_custom_attribute): 113 | self.request.user = UserFactory() 114 | self.request.COOKIES[jwt_cookie_name()] = 'reconstituted-jwt-cookie' 115 | 116 | self.middleware.process_response(self.request, None) 117 | mock_set_custom_attribute.assert_any_call('request_auth_type_guess', 'jwt-cookie') 118 | 119 | @patch('edx_django_utils.monitoring.set_custom_attribute') 120 | def test_request_auth_type_guess_session_attribute(self, mock_set_custom_attribute): 121 | self.request.user = UserFactory() 122 | 123 | self.middleware.process_response(self.request, None) 124 | mock_set_custom_attribute.assert_any_call('request_auth_type_guess', 'session-or-other') 125 | 126 | @patch('edx_django_utils.monitoring.set_custom_attribute') 127 | def test_enduser_id_attribute(self, mock_set_custom_attribute): 128 | self.request.user = UserFactory() 129 | 130 | self.middleware.process_response(self.request, None) 131 | 132 | expected_calls = [ 133 | call('enduser.id', self.request.user.id), 134 | call('request_user_id', self.request.user.id), 135 | call('request_authenticated_user_found_in_middleware', 'process_response'), 136 | ] 137 | mock_set_custom_attribute.assert_has_calls(expected_calls, any_order=True) 138 | 139 | @patch('edx_django_utils.monitoring.set_custom_attribute') 140 | def test_enduser_id_attribute_with_exception(self, mock_set_custom_attribute): 141 | self.request.user = UserFactory() 142 | 143 | self.middleware.process_exception(self.request, None) 144 | 145 | expected_calls = [ 146 | call('enduser.id', self.request.user.id), 147 | call('request_user_id', self.request.user.id), 148 | call('request_authenticated_user_found_in_middleware', 'process_exception'), 149 | ] 150 | mock_set_custom_attribute.assert_has_calls(expected_calls, any_order=True) 151 | 152 | @patch('edx_django_utils.monitoring.set_custom_attribute') 153 | def test_authenticated_user_found_in_process_request(self, mock_set_custom_attribute): 154 | self.request.user = UserFactory() 155 | self.middleware.process_request(self.request) 156 | self.middleware.process_response(self.request, None) 157 | 158 | mock_set_custom_attribute.assert_any_call( 159 | 'request_authenticated_user_found_in_middleware', 'process_request' 160 | ) 161 | 162 | @patch('edx_django_utils.monitoring.set_custom_attribute') 163 | def test_authenticated_user_found_in_process_view(self, mock_set_custom_attribute): 164 | self.request.user = UserFactory() 165 | self.middleware.process_view(self.request, None, None, None) 166 | self.middleware.process_response(self.request, None) 167 | 168 | mock_set_custom_attribute.assert_any_call( 169 | 'request_authenticated_user_found_in_middleware', 'process_view' 170 | ) 171 | 172 | @patch('edx_django_utils.monitoring.set_custom_attribute') 173 | def test_authenticated_user_found_is_properly_reset(self, mock_set_custom_attribute): 174 | # set user before process_request 175 | self.request.user = UserFactory() 176 | self.middleware.process_request(self.request) 177 | self.middleware.process_response(self.request, None) 178 | 179 | mock_set_custom_attribute.assert_any_call( 180 | 'request_authenticated_user_found_in_middleware', 'process_request' 181 | ) 182 | 183 | # set up new request and set user before process_response 184 | mock_set_custom_attribute.reset_mock() 185 | RequestCache.clear_all_namespaces() 186 | self.request = RequestFactory().get('/') 187 | self.request.user = UserFactory() 188 | self.middleware.process_response(self.request, None) 189 | 190 | mock_set_custom_attribute.assert_any_call( 191 | 'request_authenticated_user_found_in_middleware', 'process_response' 192 | ) 193 | 194 | @patch('edx_django_utils.monitoring.set_custom_attribute') 195 | def test_authenticated_standard_user(self, mock_set_custom_attribute): 196 | self.request.user = UserFactory() 197 | self.middleware.process_request(self.request) 198 | self.middleware.process_response(self.request, None) 199 | 200 | attributes_called_with = [c[0] for c in mock_set_custom_attribute.call_args_list] 201 | assert 'request_is_staff_or_superuser' not in attributes_called_with 202 | 203 | @patch('edx_django_utils.monitoring.set_custom_attribute') 204 | def test_authenticated_staff_user(self, mock_set_custom_attribute): 205 | self.request.user = UserFactory(is_staff=True) 206 | self.middleware.process_request(self.request) 207 | self.middleware.process_response(self.request, None) 208 | 209 | mock_set_custom_attribute.assert_any_call('request_is_staff_or_superuser', 'staff') 210 | 211 | @patch('edx_django_utils.monitoring.set_custom_attribute') 212 | def test_authenticated_superuser(self, mock_set_custom_attribute): 213 | self.request.user = UserFactory(is_superuser=True) 214 | self.middleware.process_request(self.request) 215 | self.middleware.process_response(self.request, None) 216 | 217 | mock_set_custom_attribute.assert_any_call('request_is_staff_or_superuser', 'superuser') 218 | 219 | 220 | @ddt.ddt 221 | class TestRequestMetricsMiddleware(TestCase): 222 | """ 223 | Temporary smoke test of deprecated RequestMetricsMiddleware. 224 | 225 | The deprecated class is a subclass of RequestCustomAttributesMiddleware, 226 | which is more fully tested. 227 | """ 228 | def setUp(self): 229 | super().setUp() 230 | RequestCache.clear_all_namespaces() 231 | self.request = RequestFactory().get('/') 232 | self.mock_response = Mock() 233 | self.middleware = RequestMetricsMiddleware(self.mock_response) 234 | 235 | @patch('edx_django_utils.monitoring.set_custom_attribute') 236 | def test_request_auth_type_guess_anonymous_attribute(self, mock_set_custom_attribute): 237 | self.request.user = AnonymousUser() 238 | 239 | self.middleware.process_response(self.request, None) 240 | mock_set_custom_attribute.assert_called_with('request_auth_type_guess', 'unauthenticated') 241 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/tests/test_paginators.py: -------------------------------------------------------------------------------- 1 | """ Tests paginator methods """ 2 | 3 | from collections import namedtuple 4 | from unittest import TestCase 5 | from unittest.mock import MagicMock, Mock 6 | 7 | import ddt 8 | from django.http import Http404 9 | from django.test import RequestFactory 10 | from rest_framework import serializers 11 | 12 | from edx_rest_framework_extensions.paginators import ( 13 | NamespacedPageNumberPagination, 14 | paginate_search_results, 15 | ) 16 | 17 | 18 | @ddt.ddt 19 | class PaginateSearchResultsTestCase(TestCase): 20 | """Test cases for paginate_search_results method""" 21 | 22 | def setUp(self): 23 | super().setUp() 24 | 25 | self.default_size = 6 26 | self.default_page = 1 27 | self.search_results = { 28 | "count": 3, 29 | "took": 1, 30 | "results": [ 31 | { 32 | '_id': 0, 33 | 'data': { 34 | 'pk': 0, 35 | 'name': 'object 0' 36 | } 37 | }, 38 | { 39 | '_id': 1, 40 | 'data': { 41 | 'pk': 1, 42 | 'name': 'object 1' 43 | } 44 | }, 45 | { 46 | '_id': 2, 47 | 'data': { 48 | 'pk': 2, 49 | 'name': 'object 2' 50 | } 51 | }, 52 | { 53 | '_id': 3, 54 | 'data': { 55 | 'pk': 3, 56 | 'name': 'object 3' 57 | } 58 | }, 59 | { 60 | '_id': 4, 61 | 'data': { 62 | 'pk': 4, 63 | 'name': 'object 4' 64 | } 65 | }, 66 | { 67 | '_id': 5, 68 | 'data': { 69 | 'pk': 5, 70 | 'name': 'object 5' 71 | } 72 | }, 73 | ] 74 | } 75 | self.mock_model = Mock() 76 | self.mock_model.objects = Mock() 77 | self.mock_model.objects.filter = Mock() 78 | 79 | @ddt.data( 80 | (1, 1, True), 81 | (1, 3, True), 82 | (1, 5, True), 83 | (1, 10, False), 84 | (2, 1, True), 85 | (2, 3, False), 86 | (2, 5, False), 87 | ) 88 | @ddt.unpack 89 | def test_paginated_results(self, page_number, page_size, has_next): 90 | """ Test the page returned has the expected db objects and acts 91 | like a proper page object. 92 | """ 93 | id_range = get_object_range(page_number, page_size) 94 | db_objects = [build_mock_object(obj_id) for obj_id in id_range] 95 | self.mock_model.objects.filter = MagicMock(return_value=db_objects) 96 | 97 | page = paginate_search_results(self.mock_model, self.search_results, page_size, page_number) 98 | 99 | self.mock_model.objects.filter.assert_called_with(pk__in=id_range) 100 | self.assertEqual(db_objects, page.object_list) 101 | self.assertTrue(page.number, page_number) 102 | self.assertEqual(page.has_next(), has_next) 103 | 104 | def test_paginated_results_last_keyword(self): 105 | """ Test the page returned has the expected db objects and acts 106 | like a proper page object using 'last' keyword. 107 | """ 108 | page_number = 2 109 | page_size = 3 110 | id_range = get_object_range(page_number, page_size) 111 | db_objects = [build_mock_object(obj_id) for obj_id in id_range] 112 | self.mock_model.objects.filter = MagicMock(return_value=db_objects) 113 | page = paginate_search_results(self.mock_model, self.search_results, page_size, 'last') 114 | 115 | self.mock_model.objects.filter.assert_called_with(pk__in=id_range) 116 | self.assertEqual(db_objects, page.object_list) 117 | self.assertTrue(page.number, page_number) 118 | self.assertFalse(page.has_next()) 119 | 120 | @ddt.data(10, -1, 0, 'str') 121 | def test_invalid_page_number(self, page_num): 122 | """ Test that a Http404 error is raised with non-integer and out-of-range pages 123 | """ 124 | with self.assertRaises(Http404): 125 | paginate_search_results(self.mock_model, self.search_results, self.default_size, page_num) 126 | 127 | 128 | class NamespacedPaginationTestCase(TestCase): 129 | """ 130 | Test behavior of `NamespacedPageNumberPagination` 131 | """ 132 | 133 | TestUser = namedtuple('TestUser', ['username', 'email']) 134 | 135 | class TestUserSerializer(serializers.Serializer): # pylint: disable=abstract-method 136 | """ 137 | Simple serializer to paginate results from 138 | """ 139 | username = serializers.CharField() 140 | email = serializers.CharField() 141 | 142 | expected_data = { 143 | 'results': [ 144 | {'username': 'user_5', 'email': 'user_5@example.com'}, 145 | {'username': 'user_6', 'email': 'user_6@example.com'}, 146 | {'username': 'user_7', 'email': 'user_7@example.com'}, 147 | {'username': 'user_8', 'email': 'user_8@example.com'}, 148 | {'username': 'user_9', 'email': 'user_9@example.com'}, 149 | ], 150 | 'pagination': { 151 | 'next': 'http://testserver/endpoint?page=3&page_size=5', 152 | 'previous': 'http://testserver/endpoint?page_size=5', 153 | 'count': 25, 154 | 'num_pages': 5, 155 | } 156 | } 157 | 158 | def setUp(self): 159 | super().setUp() 160 | self.paginator = NamespacedPageNumberPagination() 161 | self.users = [self.TestUser(f'user_{idx}', f'user_{idx}@example.com') for idx in range(25)] 162 | self.request_factory = RequestFactory() 163 | 164 | def test_basic_pagination(self): 165 | request = self.request_factory.get('/endpoint', data={'page': 2, 'page_size': 5}) 166 | request.query_params = {'page': 2, 'page_size': 5} 167 | paged_users = self.paginator.paginate_queryset(self.users, request) 168 | results = self.TestUserSerializer(paged_users, many=True).data 169 | self.assertEqual(self.expected_data, self.paginator.get_paginated_response(results).data) 170 | 171 | 172 | def build_mock_object(obj_id): 173 | """ Build a mock object with the passed id""" 174 | mock_object = Mock() 175 | object_config = { 176 | 'pk': obj_id, 177 | 'name': f"object {obj_id}" 178 | } 179 | mock_object.configure_mock(**object_config) 180 | return mock_object 181 | 182 | 183 | def get_object_range(page, page_size): 184 | """ Get the range of expected object ids given a page and page size. 185 | This will take into account the max_id of the sample data. Currently 5. 186 | """ 187 | max_id = 5 188 | start = min((page - 1) * page_size, max_id) 189 | end = min(start + page_size, max_id + 1) 190 | return list(range(start, end)) 191 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | """ Tests for settings. """ 2 | import warnings 3 | from unittest import mock 4 | 5 | from django.conf import settings 6 | from django.test import TestCase, override_settings 7 | 8 | from edx_rest_framework_extensions.settings import get_jwt_issuers, get_setting 9 | 10 | 11 | class SettingsTests(TestCase): 12 | """ Tests for settings retrieval. """ 13 | 14 | @override_settings(EDX_DRF_EXTENSIONS={}) 15 | def test_get_setting_with_missing_key(self): 16 | """ Verify the function raises KeyError if the setting is not defined. """ 17 | self.assertRaises(KeyError, get_setting, 'not_defined') 18 | 19 | def test_get_setting(self): 20 | """ Verify the function returns the value of the specified setting from the EDX_DRF_EXTENSIONS dict. """ 21 | 22 | _settings = { 23 | 'some-setting': 'some-value', 24 | 'another-one': False 25 | } 26 | 27 | with override_settings(EDX_DRF_EXTENSIONS=_settings): 28 | for key, value in _settings.items(): 29 | self.assertEqual(get_setting(key), value) 30 | 31 | def test_get_current_jwt_issuers(self): 32 | """ 33 | Verify the get_jwt_issuers operation returns the current issuer information when configured 34 | """ 35 | self.assertEqual(get_jwt_issuers(), settings.JWT_AUTH['JWT_ISSUERS']) 36 | 37 | def test_get_deprecated_jwt_issuers(self): 38 | """ 39 | Verify the get_jwt_issuers operation returns the deprecated issuer information when current 40 | issuers are not configured for the system. 41 | """ 42 | _deprecated = [ 43 | { 44 | 'ISSUER': settings.JWT_AUTH['JWT_ISSUER'], 45 | 'SECRET_KEY': settings.JWT_AUTH['JWT_SECRET_KEY'], 46 | 'AUDIENCE': settings.JWT_AUTH['JWT_AUDIENCE'], 47 | } 48 | ] 49 | 50 | mock_call = 'edx_rest_framework_extensions.settings._get_current_jwt_issuers' 51 | with mock.patch(mock_call, mock.Mock(return_value=None)): 52 | with warnings.catch_warnings(record=True) as warning_list: 53 | warnings.simplefilter("default") 54 | self.assertEqual(get_jwt_issuers(), _deprecated) 55 | self.assertEqual(len(warning_list), 1) 56 | self.assertTrue(issubclass(warning_list[-1].category, DeprecationWarning)) 57 | msg = "'JWT_ISSUERS' list not defined, checking for deprecated settings." 58 | self.assertIn(msg, str(warning_list[-1].message)) 59 | -------------------------------------------------------------------------------- /edx_rest_framework_extensions/utils.py: -------------------------------------------------------------------------------- 1 | """ Utility functions. """ 2 | 3 | # for compatibility with rest_framework_jwt 4 | # pylint: disable=unused-import 5 | from edx_rest_framework_extensions.auth.jwt.decoder import jwt_decode_handler 6 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Django administration utility. 4 | """ 5 | 6 | import os 7 | import sys 8 | 9 | 10 | PWD = os.path.abspath(os.path.dirname(__file__)) 11 | 12 | if __name__ == '__main__': 13 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_settings') 14 | sys.path.append(PWD) 15 | try: 16 | from django.core.management import \ 17 | execute_from_command_line # pylint: disable=wrong-import-position 18 | except ImportError: 19 | # The above import may fail for some other reason. Ensure that the 20 | # issue is really that Django is missing to avoid masking other 21 | # exceptions on Python 2. 22 | try: 23 | import django # pylint: disable=unused-import, wrong-import-position 24 | except ImportError: 25 | raise ImportError( 26 | "Couldn't import Django. Are you sure it's installed and " 27 | "available on your PYTHONPATH environment variable? Did you " 28 | "forget to activate a virtual environment?" 29 | ) 30 | raise 31 | execute_from_command_line(sys.argv) 32 | -------------------------------------------------------------------------------- /openedx.yaml: -------------------------------------------------------------------------------- 1 | # This file describes this Open edX repo, as described in OEP-2: 2 | # https://open-edx-proposals.readthedocs.io/en/latest/oep-0002-bp-repo-metadata.html#specification 3 | 4 | nick: drfx 5 | oeps: 6 | oep-2: true 7 | oep-7: true 8 | oep-18: 9 | state: false 10 | reason: There is an upgrade Makefile target, but it is not automated 11 | tags: 12 | - library 13 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | # *************************** 2 | # ** DO NOT EDIT THIS FILE ** 3 | # *************************** 4 | # 5 | # This file was generated by edx-lint: https://github.com/openedx/edx-lint 6 | # 7 | # If you want to change this file, you have two choices, depending on whether 8 | # you want to make a local change that applies only to this repo, or whether 9 | # you want to make a central change that applies to all repos using edx-lint. 10 | # 11 | # Note: If your pylintrc file is simply out-of-date relative to the latest 12 | # pylintrc in edx-lint, ensure you have the latest edx-lint installed 13 | # and then follow the steps for a "LOCAL CHANGE". 14 | # 15 | # LOCAL CHANGE: 16 | # 17 | # 1. Edit the local pylintrc_tweaks file to add changes just to this 18 | # repo's file. 19 | # 20 | # 2. Run: 21 | # 22 | # $ edx_lint write pylintrc 23 | # 24 | # 3. This will modify the local file. Submit a pull request to get it 25 | # checked in so that others will benefit. 26 | # 27 | # 28 | # CENTRAL CHANGE: 29 | # 30 | # 1. Edit the pylintrc file in the edx-lint repo at 31 | # https://github.com/openedx/edx-lint/blob/master/edx_lint/files/pylintrc 32 | # 33 | # 2. install the updated version of edx-lint (in edx-lint): 34 | # 35 | # $ pip install . 36 | # 37 | # 3. Run (in edx-lint): 38 | # 39 | # $ edx_lint write pylintrc 40 | # 41 | # 4. Make a new version of edx_lint, submit and review a pull request with the 42 | # pylintrc update, and after merging, update the edx-lint version and 43 | # publish the new version. 44 | # 45 | # 5. In your local repo, install the newer version of edx-lint. 46 | # 47 | # 6. Run: 48 | # 49 | # $ edx_lint write pylintrc 50 | # 51 | # 7. This will modify the local file. Submit a pull request to get it 52 | # checked in so that others will benefit. 53 | # 54 | # 55 | # 56 | # 57 | # 58 | # STAY AWAY FROM THIS FILE! 59 | # 60 | # 61 | # 62 | # 63 | # 64 | # SERIOUSLY. 65 | # 66 | # ------------------------------ 67 | # Generated by edx-lint version: 5.4.0 68 | # ------------------------------ 69 | [MASTER] 70 | ignore = 71 | persistent = yes 72 | load-plugins = edx_lint.pylint 73 | 74 | [MESSAGES CONTROL] 75 | enable = 76 | blacklisted-name, 77 | line-too-long, 78 | 79 | abstract-class-instantiated, 80 | abstract-method, 81 | access-member-before-definition, 82 | anomalous-backslash-in-string, 83 | anomalous-unicode-escape-in-string, 84 | arguments-differ, 85 | assert-on-tuple, 86 | assigning-non-slot, 87 | assignment-from-no-return, 88 | assignment-from-none, 89 | attribute-defined-outside-init, 90 | bad-except-order, 91 | bad-format-character, 92 | bad-format-string-key, 93 | bad-format-string, 94 | bad-open-mode, 95 | bad-reversed-sequence, 96 | bad-staticmethod-argument, 97 | bad-str-strip-call, 98 | bad-super-call, 99 | binary-op-exception, 100 | boolean-datetime, 101 | catching-non-exception, 102 | cell-var-from-loop, 103 | confusing-with-statement, 104 | continue-in-finally, 105 | dangerous-default-value, 106 | duplicate-argument-name, 107 | duplicate-bases, 108 | duplicate-except, 109 | duplicate-key, 110 | expression-not-assigned, 111 | format-combined-specification, 112 | format-needs-mapping, 113 | function-redefined, 114 | global-variable-undefined, 115 | import-error, 116 | import-self, 117 | inconsistent-mro, 118 | inherit-non-class, 119 | init-is-generator, 120 | invalid-all-object, 121 | invalid-format-index, 122 | invalid-length-returned, 123 | invalid-sequence-index, 124 | invalid-slice-index, 125 | invalid-slots-object, 126 | invalid-slots, 127 | invalid-unary-operand-type, 128 | logging-too-few-args, 129 | logging-too-many-args, 130 | logging-unsupported-format, 131 | lost-exception, 132 | method-hidden, 133 | misplaced-bare-raise, 134 | misplaced-future, 135 | missing-format-argument-key, 136 | missing-format-attribute, 137 | missing-format-string-key, 138 | no-member, 139 | no-method-argument, 140 | no-name-in-module, 141 | no-self-argument, 142 | no-value-for-parameter, 143 | non-iterator-returned, 144 | non-parent-method-called, 145 | nonexistent-operator, 146 | not-a-mapping, 147 | not-an-iterable, 148 | not-callable, 149 | not-context-manager, 150 | not-in-loop, 151 | pointless-statement, 152 | pointless-string-statement, 153 | raising-bad-type, 154 | raising-non-exception, 155 | redefined-builtin, 156 | redefined-outer-name, 157 | redundant-keyword-arg, 158 | repeated-keyword, 159 | return-arg-in-generator, 160 | return-in-init, 161 | return-outside-function, 162 | signature-differs, 163 | super-init-not-called, 164 | super-method-not-called, 165 | syntax-error, 166 | test-inherits-tests, 167 | too-few-format-args, 168 | too-many-format-args, 169 | too-many-function-args, 170 | translation-of-non-string, 171 | truncated-format-string, 172 | undefined-all-variable, 173 | undefined-loop-variable, 174 | undefined-variable, 175 | unexpected-keyword-arg, 176 | unexpected-special-method-signature, 177 | unpacking-non-sequence, 178 | unreachable, 179 | unsubscriptable-object, 180 | unsupported-binary-operation, 181 | unsupported-membership-test, 182 | unused-format-string-argument, 183 | unused-format-string-key, 184 | used-before-assignment, 185 | using-constant-test, 186 | yield-outside-function, 187 | 188 | astroid-error, 189 | fatal, 190 | method-check-failed, 191 | parse-error, 192 | raw-checker-failed, 193 | 194 | empty-docstring, 195 | invalid-characters-in-docstring, 196 | missing-docstring, 197 | wrong-spelling-in-comment, 198 | wrong-spelling-in-docstring, 199 | 200 | unused-argument, 201 | unused-import, 202 | unused-variable, 203 | 204 | eval-used, 205 | exec-used, 206 | 207 | bad-classmethod-argument, 208 | bad-mcs-classmethod-argument, 209 | bad-mcs-method-argument, 210 | bare-except, 211 | broad-except, 212 | consider-iterating-dictionary, 213 | consider-using-enumerate, 214 | global-at-module-level, 215 | global-variable-not-assigned, 216 | literal-used-as-attribute, 217 | logging-format-interpolation, 218 | logging-not-lazy, 219 | multiple-imports, 220 | multiple-statements, 221 | no-classmethod-decorator, 222 | no-staticmethod-decorator, 223 | protected-access, 224 | redundant-unittest-assert, 225 | reimported, 226 | simplifiable-if-statement, 227 | simplifiable-range, 228 | singleton-comparison, 229 | superfluous-parens, 230 | unidiomatic-typecheck, 231 | unnecessary-lambda, 232 | unnecessary-pass, 233 | unnecessary-semicolon, 234 | unneeded-not, 235 | useless-else-on-loop, 236 | wrong-assert-type, 237 | 238 | deprecated-method, 239 | deprecated-module, 240 | 241 | too-many-boolean-expressions, 242 | too-many-nested-blocks, 243 | too-many-statements, 244 | 245 | wildcard-import, 246 | wrong-import-order, 247 | wrong-import-position, 248 | 249 | missing-final-newline, 250 | mixed-line-endings, 251 | trailing-newlines, 252 | trailing-whitespace, 253 | unexpected-line-ending-format, 254 | 255 | bad-inline-option, 256 | bad-option-value, 257 | deprecated-pragma, 258 | unrecognized-inline-option, 259 | useless-suppression, 260 | disable = 261 | bad-indentation, 262 | broad-exception-raised, 263 | consider-using-f-string, 264 | duplicate-code, 265 | file-ignored, 266 | fixme, 267 | global-statement, 268 | invalid-name, 269 | locally-disabled, 270 | no-else-return, 271 | suppressed-message, 272 | too-few-public-methods, 273 | too-many-ancestors, 274 | too-many-arguments, 275 | too-many-branches, 276 | too-many-instance-attributes, 277 | too-many-lines, 278 | too-many-locals, 279 | too-many-public-methods, 280 | too-many-return-statements, 281 | ungrouped-imports, 282 | unspecified-encoding, 283 | unused-wildcard-import, 284 | use-maxsplit-arg, 285 | 286 | feature-toggle-needs-doc, 287 | illegal-waffle-usage, 288 | 289 | logging-fstring-interpolation,no-member, too-many-positional-arguments 290 | 291 | [REPORTS] 292 | output-format = text 293 | reports = no 294 | score = no 295 | 296 | [BASIC] 297 | module-rgx = (([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 298 | const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns|logger|User)$ 299 | class-rgx = [A-Z_][a-zA-Z0-9]+$ 300 | function-rgx = ([a-z_][a-z0-9_]{2,40}|test_[a-z0-9_]+)$ 301 | method-rgx = ([a-z_][a-z0-9_]{2,40}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$ 302 | attr-rgx = [a-z_][a-z0-9_]{2,30}$ 303 | argument-rgx = [a-z_][a-z0-9_]{2,30}$ 304 | variable-rgx = [a-z_][a-z0-9_]{2,30}$ 305 | class-attribute-rgx = ([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 306 | inlinevar-rgx = [A-Za-z_][A-Za-z0-9_]*$ 307 | good-names = f,i,j,k,db,ex,Run,_,__ 308 | bad-names = foo,bar,baz,toto,tutu,tata 309 | no-docstring-rgx = __.*__$|test_.+|setUp$|setUpClass$|tearDown$|tearDownClass$|Meta$ 310 | docstring-min-length = 5 311 | 312 | [FORMAT] 313 | max-line-length = 120 314 | ignore-long-lines = ^\s*(# )?((?)|(\.\. \w+: .*))$ 315 | single-line-if-stmt = no 316 | max-module-lines = 1000 317 | indent-string = ' ' 318 | 319 | [MISCELLANEOUS] 320 | notes = FIXME,XXX,TODO 321 | 322 | [SIMILARITIES] 323 | min-similarity-lines = 4 324 | ignore-comments = yes 325 | ignore-docstrings = yes 326 | ignore-imports = no 327 | 328 | [TYPECHECK] 329 | ignore-mixin-members = yes 330 | ignored-classes = SQLObject 331 | unsafe-load-any-extension = yes 332 | generated-members = 333 | REQUEST, 334 | acl_users, 335 | aq_parent, 336 | objects, 337 | DoesNotExist, 338 | can_read, 339 | can_write, 340 | get_url, 341 | size, 342 | content, 343 | status_code, 344 | create, 345 | build, 346 | fields, 347 | tag, 348 | org, 349 | course, 350 | category, 351 | name, 352 | revision, 353 | _meta, 354 | 355 | [VARIABLES] 356 | init-import = no 357 | dummy-variables-rgx = _|dummy|unused|.*_unused 358 | additional-builtins = 359 | 360 | [CLASSES] 361 | defining-attr-methods = __init__,__new__,setUp 362 | valid-classmethod-first-arg = cls 363 | valid-metaclass-classmethod-first-arg = mcs 364 | 365 | [DESIGN] 366 | max-args = 5 367 | ignored-argument-names = _.* 368 | max-locals = 15 369 | max-returns = 6 370 | max-branches = 12 371 | max-statements = 50 372 | max-parents = 7 373 | max-attributes = 7 374 | min-public-methods = 2 375 | max-public-methods = 20 376 | 377 | [IMPORTS] 378 | deprecated-modules = regsub,TERMIOS,Bastion,rexec 379 | import-graph = 380 | ext-import-graph = 381 | int-import-graph = 382 | 383 | [EXCEPTIONS] 384 | overgeneral-exceptions = builtins.Exception 385 | 386 | # 3d6ea133547fe5894b68602b76c8d69a9d460e8c 387 | -------------------------------------------------------------------------------- /pylintrc_tweaks: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | load-plugins = edx_lint.pylint 3 | 4 | [MESSAGES CONTROL] 5 | disable+= no-member, too-many-positional-arguments 6 | 7 | [BASIC] 8 | const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns|logger|User)$ 9 | -------------------------------------------------------------------------------- /requirements/base.in: -------------------------------------------------------------------------------- 1 | -c constraints.txt 2 | 3 | Django>=2.2 4 | djangorestframework>=3.9.0 5 | drf-jwt 6 | django-waffle 7 | edx-django-utils>=3.8.0 # using new set_custom_attribute method 8 | edx-opaque-keys 9 | pyjwt[crypto]>=2.1.0 # depends on newer jwt.decode and jwt.encode 10 | requests>=2.7.0 11 | semantic_version 12 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | asgiref==3.8.1 8 | # via django 9 | certifi==2025.1.31 10 | # via requests 11 | cffi==1.17.1 12 | # via 13 | # cryptography 14 | # pynacl 15 | charset-normalizer==3.4.1 16 | # via requests 17 | click==8.1.8 18 | # via edx-django-utils 19 | cryptography==44.0.2 20 | # via pyjwt 21 | django==4.2.20 22 | # via 23 | # -c requirements/common_constraints.txt 24 | # -r requirements/base.in 25 | # django-crum 26 | # django-waffle 27 | # djangorestframework 28 | # drf-jwt 29 | # edx-django-utils 30 | django-crum==0.7.9 31 | # via edx-django-utils 32 | django-waffle==4.2.0 33 | # via 34 | # -r requirements/base.in 35 | # edx-django-utils 36 | djangorestframework==3.16.0 37 | # via 38 | # -r requirements/base.in 39 | # drf-jwt 40 | dnspython==2.7.0 41 | # via pymongo 42 | drf-jwt==1.19.2 43 | # via -r requirements/base.in 44 | edx-django-utils==7.2.0 45 | # via -r requirements/base.in 46 | edx-opaque-keys==2.12.0 47 | # via -r requirements/base.in 48 | idna==3.10 49 | # via requests 50 | newrelic==10.8.1 51 | # via edx-django-utils 52 | pbr==6.1.1 53 | # via stevedore 54 | psutil==7.0.0 55 | # via edx-django-utils 56 | pycparser==2.22 57 | # via cffi 58 | pyjwt[crypto]==2.10.1 59 | # via 60 | # -r requirements/base.in 61 | # drf-jwt 62 | pymongo==4.11.3 63 | # via edx-opaque-keys 64 | pynacl==1.5.0 65 | # via edx-django-utils 66 | requests==2.32.3 67 | # via -r requirements/base.in 68 | semantic-version==2.10.0 69 | # via -r requirements/base.in 70 | sqlparse==0.5.3 71 | # via django 72 | stevedore==5.4.1 73 | # via 74 | # edx-django-utils 75 | # edx-opaque-keys 76 | typing-extensions==4.13.1 77 | # via edx-opaque-keys 78 | urllib3==2.2.3 79 | # via 80 | # -c requirements/common_constraints.txt 81 | # requests 82 | 83 | # The following packages are considered to be unsafe in a requirements file: 84 | # setuptools 85 | -------------------------------------------------------------------------------- /requirements/common_constraints.txt: -------------------------------------------------------------------------------- 1 | # A central location for most common version constraints 2 | # (across edx repos) for pip-installation. 3 | # 4 | # Similar to other constraint files this file doesn't install any packages. 5 | # It specifies version constraints that will be applied if a package is needed. 6 | # When pinning something here, please provide an explanation of why it is a good 7 | # idea to pin this package across all edx repos, Ideally, link to other information 8 | # that will help people in the future to remove the pin when possible. 9 | # Writing an issue against the offending project and linking to it here is good. 10 | # 11 | # Note: Changes to this file will automatically be used by other repos, referencing 12 | # this file from Github directly. It does not require packaging in edx-lint. 13 | 14 | # using LTS django version 15 | Django<5.0 16 | 17 | # elasticsearch>=7.14.0 includes breaking changes in it which caused issues in discovery upgrade process. 18 | # elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html 19 | # See https://github.com/openedx/edx-platform/issues/35126 for more info 20 | elasticsearch<7.14.0 21 | 22 | # django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected 23 | django-simple-history==3.0.0 24 | 25 | # Cause: https://github.com/openedx/edx-lint/issues/458 26 | # This can be unpinned once https://github.com/openedx/edx-lint/issues/459 has been resolved. 27 | pip<24.3 28 | 29 | # Cause: https://github.com/openedx/edx-lint/issues/475 30 | # This can be unpinned once https://github.com/openedx/edx-lint/issues/476 has been resolved. 31 | urllib3<2.3.0 32 | -------------------------------------------------------------------------------- /requirements/constraints.txt: -------------------------------------------------------------------------------- 1 | # Version constraints for pip installation. 2 | # 3 | # This file doesn't install any packages. It specifies version constraints 4 | # that will be applied if a package is needed. 5 | # 6 | # When pinning something here, please provide an explanation of why. Ideally, 7 | # link to other information that will help people in the future to remove the 8 | # pin when possible. Writing an issue against the offending project and 9 | # linking to it here is good. 10 | 11 | # This file contains all common constraints for edx-repos 12 | -c common_constraints.txt 13 | 14 | # Sphinx>5.3.0 requires docutils>=0.18,<0.20 but 15 | # sphinx_rtd_theme which needs docutils<0.18 16 | # which is causing make upgrade job to fail due to conflicts 17 | # Constraint can be removed once sphinx_rtd_theme>=1.1.1 is available on PyPI 18 | sphinx==5.3.0 19 | 20 | # For python greater than or equal to 3.9 backports.zoneinfo causing failures 21 | backports.zoneinfo; python_version<'3.9' 22 | -------------------------------------------------------------------------------- /requirements/dev.in: -------------------------------------------------------------------------------- 1 | -c constraints.txt 2 | 3 | -r base.txt 4 | 5 | -r test.txt 6 | 7 | -r docs.txt 8 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | accessible-pygments==0.0.5 8 | # via 9 | # -r requirements/docs.txt 10 | # pydata-sphinx-theme 11 | alabaster==0.7.16 12 | # via 13 | # -r requirements/docs.txt 14 | # sphinx 15 | asgiref==3.8.1 16 | # via 17 | # -r requirements/base.txt 18 | # -r requirements/test.txt 19 | # django 20 | astroid==3.3.9 21 | # via 22 | # -r requirements/test.txt 23 | # pylint 24 | # pylint-celery 25 | babel==2.17.0 26 | # via 27 | # -r requirements/docs.txt 28 | # pydata-sphinx-theme 29 | # sphinx 30 | beautifulsoup4==4.13.3 31 | # via 32 | # -r requirements/docs.txt 33 | # pydata-sphinx-theme 34 | cachetools==5.5.2 35 | # via 36 | # -r requirements/test.txt 37 | # tox 38 | certifi==2025.1.31 39 | # via 40 | # -r requirements/base.txt 41 | # -r requirements/docs.txt 42 | # -r requirements/test.txt 43 | # requests 44 | cffi==1.17.1 45 | # via 46 | # -r requirements/base.txt 47 | # -r requirements/test.txt 48 | # cryptography 49 | # pynacl 50 | chardet==5.2.0 51 | # via 52 | # -r requirements/test.txt 53 | # tox 54 | charset-normalizer==3.4.1 55 | # via 56 | # -r requirements/base.txt 57 | # -r requirements/docs.txt 58 | # -r requirements/test.txt 59 | # requests 60 | click==8.1.8 61 | # via 62 | # -r requirements/base.txt 63 | # -r requirements/test.txt 64 | # click-log 65 | # code-annotations 66 | # edx-django-utils 67 | # edx-lint 68 | click-log==0.4.0 69 | # via 70 | # -r requirements/test.txt 71 | # edx-lint 72 | code-annotations==2.2.0 73 | # via 74 | # -r requirements/test.txt 75 | # edx-lint 76 | colorama==0.4.6 77 | # via 78 | # -r requirements/test.txt 79 | # tox 80 | coverage[toml]==7.8.0 81 | # via 82 | # -r requirements/test.txt 83 | # pytest-cov 84 | cryptography==44.0.2 85 | # via 86 | # -r requirements/base.txt 87 | # -r requirements/test.txt 88 | # pyjwt 89 | ddt==1.7.2 90 | # via -r requirements/test.txt 91 | dill==0.3.9 92 | # via 93 | # -r requirements/test.txt 94 | # pylint 95 | distlib==0.3.9 96 | # via 97 | # -r requirements/test.txt 98 | # virtualenv 99 | django==4.2.20 100 | # via 101 | # -c requirements/common_constraints.txt 102 | # -r requirements/base.txt 103 | # -r requirements/test.txt 104 | # django-crum 105 | # django-waffle 106 | # djangorestframework 107 | # drf-jwt 108 | # edx-django-utils 109 | django-crum==0.7.9 110 | # via 111 | # -r requirements/base.txt 112 | # -r requirements/test.txt 113 | # edx-django-utils 114 | django-waffle==4.2.0 115 | # via 116 | # -r requirements/base.txt 117 | # -r requirements/test.txt 118 | # edx-django-utils 119 | djangorestframework==3.16.0 120 | # via 121 | # -r requirements/base.txt 122 | # -r requirements/test.txt 123 | # drf-jwt 124 | dnspython==2.7.0 125 | # via 126 | # -r requirements/base.txt 127 | # -r requirements/test.txt 128 | # pymongo 129 | docutils==0.19 130 | # via 131 | # -r requirements/docs.txt 132 | # pydata-sphinx-theme 133 | # sphinx 134 | drf-jwt==1.19.2 135 | # via 136 | # -r requirements/base.txt 137 | # -r requirements/test.txt 138 | edx-django-utils==7.2.0 139 | # via 140 | # -r requirements/base.txt 141 | # -r requirements/test.txt 142 | edx-lint==5.6.0 143 | # via -r requirements/test.txt 144 | edx-opaque-keys==2.12.0 145 | # via 146 | # -r requirements/base.txt 147 | # -r requirements/test.txt 148 | factory-boy==2.12.0 149 | # via -r requirements/test.txt 150 | faker==37.1.0 151 | # via 152 | # -r requirements/test.txt 153 | # factory-boy 154 | filelock==3.18.0 155 | # via 156 | # -r requirements/test.txt 157 | # tox 158 | # virtualenv 159 | httpretty==1.1.4 160 | # via -r requirements/test.txt 161 | idna==3.10 162 | # via 163 | # -r requirements/base.txt 164 | # -r requirements/docs.txt 165 | # -r requirements/test.txt 166 | # requests 167 | imagesize==1.4.1 168 | # via 169 | # -r requirements/docs.txt 170 | # sphinx 171 | iniconfig==2.1.0 172 | # via 173 | # -r requirements/test.txt 174 | # pytest 175 | isort==6.0.1 176 | # via 177 | # -r requirements/test.txt 178 | # pylint 179 | jinja2==3.1.6 180 | # via 181 | # -r requirements/docs.txt 182 | # -r requirements/test.txt 183 | # code-annotations 184 | # sphinx 185 | markupsafe==3.0.2 186 | # via 187 | # -r requirements/docs.txt 188 | # -r requirements/test.txt 189 | # jinja2 190 | mccabe==0.7.0 191 | # via 192 | # -r requirements/test.txt 193 | # pylint 194 | newrelic==10.8.1 195 | # via 196 | # -r requirements/base.txt 197 | # -r requirements/test.txt 198 | # edx-django-utils 199 | packaging==24.2 200 | # via 201 | # -r requirements/docs.txt 202 | # -r requirements/test.txt 203 | # pydata-sphinx-theme 204 | # pyproject-api 205 | # pytest 206 | # sphinx 207 | # tox 208 | pbr==6.1.1 209 | # via 210 | # -r requirements/base.txt 211 | # -r requirements/test.txt 212 | # stevedore 213 | platformdirs==4.3.7 214 | # via 215 | # -r requirements/test.txt 216 | # pylint 217 | # tox 218 | # virtualenv 219 | pluggy==1.5.0 220 | # via 221 | # -r requirements/test.txt 222 | # pytest 223 | # tox 224 | psutil==7.0.0 225 | # via 226 | # -r requirements/base.txt 227 | # -r requirements/test.txt 228 | # edx-django-utils 229 | pycodestyle==2.13.0 230 | # via -r requirements/test.txt 231 | pycparser==2.22 232 | # via 233 | # -r requirements/base.txt 234 | # -r requirements/test.txt 235 | # cffi 236 | pydata-sphinx-theme==0.15.4 237 | # via 238 | # -r requirements/docs.txt 239 | # sphinx-book-theme 240 | pygments==2.19.1 241 | # via 242 | # -r requirements/docs.txt 243 | # accessible-pygments 244 | # pydata-sphinx-theme 245 | # sphinx 246 | pyjwt[crypto]==2.10.1 247 | # via 248 | # -r requirements/base.txt 249 | # -r requirements/test.txt 250 | # drf-jwt 251 | pylint==3.3.6 252 | # via 253 | # -r requirements/test.txt 254 | # edx-lint 255 | # pylint-celery 256 | # pylint-django 257 | # pylint-plugin-utils 258 | pylint-celery==0.3 259 | # via 260 | # -r requirements/test.txt 261 | # edx-lint 262 | pylint-django==2.6.1 263 | # via 264 | # -r requirements/test.txt 265 | # edx-lint 266 | pylint-plugin-utils==0.8.2 267 | # via 268 | # -r requirements/test.txt 269 | # pylint-celery 270 | # pylint-django 271 | pymongo==4.11.3 272 | # via 273 | # -r requirements/base.txt 274 | # -r requirements/test.txt 275 | # edx-opaque-keys 276 | pynacl==1.5.0 277 | # via 278 | # -r requirements/base.txt 279 | # -r requirements/test.txt 280 | # edx-django-utils 281 | pyproject-api==1.9.0 282 | # via 283 | # -r requirements/test.txt 284 | # tox 285 | pytest==8.3.5 286 | # via 287 | # -r requirements/test.txt 288 | # pytest-cov 289 | # pytest-django 290 | pytest-cov==6.1.0 291 | # via -r requirements/test.txt 292 | pytest-django==4.11.1 293 | # via -r requirements/test.txt 294 | python-slugify==8.0.4 295 | # via 296 | # -r requirements/test.txt 297 | # code-annotations 298 | pyyaml==6.0.2 299 | # via 300 | # -r requirements/test.txt 301 | # code-annotations 302 | requests==2.32.3 303 | # via 304 | # -r requirements/base.txt 305 | # -r requirements/docs.txt 306 | # -r requirements/test.txt 307 | # sphinx 308 | semantic-version==2.10.0 309 | # via 310 | # -r requirements/base.txt 311 | # -r requirements/test.txt 312 | six==1.17.0 313 | # via 314 | # -r requirements/test.txt 315 | # edx-lint 316 | snowballstemmer==2.2.0 317 | # via 318 | # -r requirements/docs.txt 319 | # sphinx 320 | soupsieve==2.6 321 | # via 322 | # -r requirements/docs.txt 323 | # beautifulsoup4 324 | sphinx==5.3.0 325 | # via 326 | # -c requirements/constraints.txt 327 | # -r requirements/docs.txt 328 | # pydata-sphinx-theme 329 | # sphinx-book-theme 330 | sphinx-book-theme==1.1.3 331 | # via -r requirements/docs.txt 332 | sphinxcontrib-applehelp==2.0.0 333 | # via 334 | # -r requirements/docs.txt 335 | # sphinx 336 | sphinxcontrib-devhelp==2.0.0 337 | # via 338 | # -r requirements/docs.txt 339 | # sphinx 340 | sphinxcontrib-htmlhelp==2.1.0 341 | # via 342 | # -r requirements/docs.txt 343 | # sphinx 344 | sphinxcontrib-jsmath==1.0.1 345 | # via 346 | # -r requirements/docs.txt 347 | # sphinx 348 | sphinxcontrib-qthelp==2.0.0 349 | # via 350 | # -r requirements/docs.txt 351 | # sphinx 352 | sphinxcontrib-serializinghtml==2.0.0 353 | # via 354 | # -r requirements/docs.txt 355 | # sphinx 356 | sqlparse==0.5.3 357 | # via 358 | # -r requirements/base.txt 359 | # -r requirements/test.txt 360 | # django 361 | stevedore==5.4.1 362 | # via 363 | # -r requirements/base.txt 364 | # -r requirements/test.txt 365 | # code-annotations 366 | # edx-django-utils 367 | # edx-opaque-keys 368 | text-unidecode==1.3 369 | # via 370 | # -r requirements/test.txt 371 | # python-slugify 372 | tomlkit==0.13.2 373 | # via 374 | # -r requirements/test.txt 375 | # pylint 376 | tox==4.25.0 377 | # via -r requirements/test.txt 378 | typing-extensions==4.13.1 379 | # via 380 | # -r requirements/base.txt 381 | # -r requirements/docs.txt 382 | # -r requirements/test.txt 383 | # beautifulsoup4 384 | # edx-opaque-keys 385 | # pydata-sphinx-theme 386 | tzdata==2025.2 387 | # via 388 | # -r requirements/test.txt 389 | # faker 390 | urllib3==2.2.3 391 | # via 392 | # -c requirements/common_constraints.txt 393 | # -r requirements/base.txt 394 | # -r requirements/docs.txt 395 | # -r requirements/test.txt 396 | # requests 397 | virtualenv==20.30.0 398 | # via 399 | # -r requirements/test.txt 400 | # tox 401 | 402 | # The following packages are considered to be unsafe in a requirements file: 403 | # setuptools 404 | -------------------------------------------------------------------------------- /requirements/docs.in: -------------------------------------------------------------------------------- 1 | # Packages required for building documentation 2 | 3 | # Make sure we don't generate requirements that conflict 4 | # with test.txt, otherwise dev.txt will fail to generate. 5 | -c constraints.txt 6 | -c test.txt 7 | 8 | Sphinx>=1.3.6 9 | sphinx-book-theme # Common theme for all Open edX projects 10 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | accessible-pygments==0.0.5 8 | # via pydata-sphinx-theme 9 | alabaster==0.7.16 10 | # via sphinx 11 | babel==2.17.0 12 | # via 13 | # pydata-sphinx-theme 14 | # sphinx 15 | beautifulsoup4==4.13.3 16 | # via pydata-sphinx-theme 17 | certifi==2025.1.31 18 | # via 19 | # -c requirements/test.txt 20 | # requests 21 | charset-normalizer==3.4.1 22 | # via 23 | # -c requirements/test.txt 24 | # requests 25 | docutils==0.19 26 | # via 27 | # pydata-sphinx-theme 28 | # sphinx 29 | idna==3.10 30 | # via 31 | # -c requirements/test.txt 32 | # requests 33 | imagesize==1.4.1 34 | # via sphinx 35 | jinja2==3.1.6 36 | # via 37 | # -c requirements/test.txt 38 | # sphinx 39 | markupsafe==3.0.2 40 | # via 41 | # -c requirements/test.txt 42 | # jinja2 43 | packaging==24.2 44 | # via 45 | # -c requirements/test.txt 46 | # pydata-sphinx-theme 47 | # sphinx 48 | pydata-sphinx-theme==0.15.4 49 | # via sphinx-book-theme 50 | pygments==2.19.1 51 | # via 52 | # accessible-pygments 53 | # pydata-sphinx-theme 54 | # sphinx 55 | requests==2.32.3 56 | # via 57 | # -c requirements/test.txt 58 | # sphinx 59 | snowballstemmer==2.2.0 60 | # via sphinx 61 | soupsieve==2.6 62 | # via beautifulsoup4 63 | sphinx==5.3.0 64 | # via 65 | # -c requirements/constraints.txt 66 | # -r requirements/docs.in 67 | # pydata-sphinx-theme 68 | # sphinx-book-theme 69 | sphinx-book-theme==1.1.3 70 | # via -r requirements/docs.in 71 | sphinxcontrib-applehelp==2.0.0 72 | # via sphinx 73 | sphinxcontrib-devhelp==2.0.0 74 | # via sphinx 75 | sphinxcontrib-htmlhelp==2.1.0 76 | # via sphinx 77 | sphinxcontrib-jsmath==1.0.1 78 | # via sphinx 79 | sphinxcontrib-qthelp==2.0.0 80 | # via sphinx 81 | sphinxcontrib-serializinghtml==2.0.0 82 | # via sphinx 83 | typing-extensions==4.13.1 84 | # via 85 | # -c requirements/test.txt 86 | # beautifulsoup4 87 | # pydata-sphinx-theme 88 | urllib3==2.2.3 89 | # via 90 | # -c requirements/common_constraints.txt 91 | # -c requirements/test.txt 92 | # requests 93 | -------------------------------------------------------------------------------- /requirements/pip-tools.in: -------------------------------------------------------------------------------- 1 | # Requirements for working with requirements. 2 | 3 | -c constraints.txt 4 | 5 | pip-tools 6 | -------------------------------------------------------------------------------- /requirements/pip-tools.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | build==1.2.2.post1 8 | # via pip-tools 9 | click==8.1.8 10 | # via pip-tools 11 | packaging==24.2 12 | # via build 13 | pip-tools==7.4.1 14 | # via -r requirements/pip-tools.in 15 | pyproject-hooks==1.2.0 16 | # via 17 | # build 18 | # pip-tools 19 | wheel==0.45.1 20 | # via pip-tools 21 | 22 | # The following packages are considered to be unsafe in a requirements file: 23 | # pip 24 | # setuptools 25 | -------------------------------------------------------------------------------- /requirements/pip.in: -------------------------------------------------------------------------------- 1 | # Core dependencies for installing other packages 2 | -c constraints.txt 3 | 4 | pip 5 | setuptools 6 | wheel 7 | -------------------------------------------------------------------------------- /requirements/pip.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | wheel==0.45.1 8 | # via -r requirements/pip.in 9 | 10 | # The following packages are considered to be unsafe in a requirements file: 11 | pip==24.2 12 | # via 13 | # -c /home/runner/work/edx-drf-extensions/edx-drf-extensions/requirements/common_constraints.txt 14 | # -r requirements/pip.in 15 | setuptools==78.1.0 16 | # via -r requirements/pip.in 17 | -------------------------------------------------------------------------------- /requirements/test.in: -------------------------------------------------------------------------------- 1 | # Requirements for testing. 2 | 3 | -c constraints.txt 4 | 5 | -r base.txt 6 | 7 | 8 | coverage 9 | ddt 10 | edx-lint 11 | isort 12 | factory_boy>=2.6.1,<3.0.0 13 | httpretty 14 | pycodestyle 15 | pytest-cov 16 | pytest-django 17 | tox 18 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | asgiref==3.8.1 8 | # via 9 | # -r requirements/base.txt 10 | # django 11 | astroid==3.3.9 12 | # via 13 | # pylint 14 | # pylint-celery 15 | cachetools==5.5.2 16 | # via tox 17 | certifi==2025.1.31 18 | # via 19 | # -r requirements/base.txt 20 | # requests 21 | cffi==1.17.1 22 | # via 23 | # -r requirements/base.txt 24 | # cryptography 25 | # pynacl 26 | chardet==5.2.0 27 | # via tox 28 | charset-normalizer==3.4.1 29 | # via 30 | # -r requirements/base.txt 31 | # requests 32 | click==8.1.8 33 | # via 34 | # -r requirements/base.txt 35 | # click-log 36 | # code-annotations 37 | # edx-django-utils 38 | # edx-lint 39 | click-log==0.4.0 40 | # via edx-lint 41 | code-annotations==2.2.0 42 | # via edx-lint 43 | colorama==0.4.6 44 | # via tox 45 | coverage[toml]==7.8.0 46 | # via 47 | # -r requirements/test.in 48 | # pytest-cov 49 | cryptography==44.0.2 50 | # via 51 | # -r requirements/base.txt 52 | # pyjwt 53 | ddt==1.7.2 54 | # via -r requirements/test.in 55 | dill==0.3.9 56 | # via pylint 57 | distlib==0.3.9 58 | # via virtualenv 59 | # via 60 | # -c requirements/common_constraints.txt 61 | # -r requirements/base.txt 62 | # django-crum 63 | # django-waffle 64 | # djangorestframework 65 | # drf-jwt 66 | # edx-django-utils 67 | django-crum==0.7.9 68 | # via 69 | # -r requirements/base.txt 70 | # edx-django-utils 71 | django-waffle==4.2.0 72 | # via 73 | # -r requirements/base.txt 74 | # edx-django-utils 75 | # via 76 | # -r requirements/base.txt 77 | # drf-jwt 78 | dnspython==2.7.0 79 | # via 80 | # -r requirements/base.txt 81 | # pymongo 82 | drf-jwt==1.19.2 83 | # via -r requirements/base.txt 84 | edx-django-utils==7.2.0 85 | # via -r requirements/base.txt 86 | edx-lint==5.6.0 87 | # via -r requirements/test.in 88 | edx-opaque-keys==2.12.0 89 | # via -r requirements/base.txt 90 | factory-boy==2.12.0 91 | # via -r requirements/test.in 92 | faker==37.1.0 93 | # via factory-boy 94 | filelock==3.18.0 95 | # via 96 | # tox 97 | # virtualenv 98 | httpretty==1.1.4 99 | # via -r requirements/test.in 100 | idna==3.10 101 | # via 102 | # -r requirements/base.txt 103 | # requests 104 | iniconfig==2.1.0 105 | # via pytest 106 | isort==6.0.1 107 | # via 108 | # -r requirements/test.in 109 | # pylint 110 | jinja2==3.1.6 111 | # via code-annotations 112 | markupsafe==3.0.2 113 | # via jinja2 114 | mccabe==0.7.0 115 | # via pylint 116 | newrelic==10.8.1 117 | # via 118 | # -r requirements/base.txt 119 | # edx-django-utils 120 | packaging==24.2 121 | # via 122 | # pyproject-api 123 | # pytest 124 | # tox 125 | pbr==6.1.1 126 | # via 127 | # -r requirements/base.txt 128 | # stevedore 129 | platformdirs==4.3.7 130 | # via 131 | # pylint 132 | # tox 133 | # virtualenv 134 | pluggy==1.5.0 135 | # via 136 | # pytest 137 | # tox 138 | psutil==7.0.0 139 | # via 140 | # -r requirements/base.txt 141 | # edx-django-utils 142 | pycodestyle==2.13.0 143 | # via -r requirements/test.in 144 | pycparser==2.22 145 | # via 146 | # -r requirements/base.txt 147 | # cffi 148 | pyjwt[crypto]==2.10.1 149 | # via 150 | # -r requirements/base.txt 151 | # drf-jwt 152 | pylint==3.3.6 153 | # via 154 | # edx-lint 155 | # pylint-celery 156 | # pylint-django 157 | # pylint-plugin-utils 158 | pylint-celery==0.3 159 | # via edx-lint 160 | pylint-django==2.6.1 161 | # via edx-lint 162 | pylint-plugin-utils==0.8.2 163 | # via 164 | # pylint-celery 165 | # pylint-django 166 | pymongo==4.11.3 167 | # via 168 | # -r requirements/base.txt 169 | # edx-opaque-keys 170 | pynacl==1.5.0 171 | # via 172 | # -r requirements/base.txt 173 | # edx-django-utils 174 | pyproject-api==1.9.0 175 | # via tox 176 | pytest==8.3.5 177 | # via 178 | # pytest-cov 179 | # pytest-django 180 | pytest-cov==6.1.0 181 | # via -r requirements/test.in 182 | pytest-django==4.11.1 183 | # via -r requirements/test.in 184 | python-slugify==8.0.4 185 | # via code-annotations 186 | pyyaml==6.0.2 187 | # via code-annotations 188 | requests==2.32.3 189 | # via -r requirements/base.txt 190 | semantic-version==2.10.0 191 | # via -r requirements/base.txt 192 | six==1.17.0 193 | # via edx-lint 194 | sqlparse==0.5.3 195 | # via 196 | # -r requirements/base.txt 197 | # django 198 | stevedore==5.4.1 199 | # via 200 | # -r requirements/base.txt 201 | # code-annotations 202 | # edx-django-utils 203 | # edx-opaque-keys 204 | text-unidecode==1.3 205 | # via python-slugify 206 | tomlkit==0.13.2 207 | # via pylint 208 | tox==4.25.0 209 | # via -r requirements/test.in 210 | typing-extensions==4.13.1 211 | # via 212 | # -r requirements/base.txt 213 | # edx-opaque-keys 214 | tzdata==2025.2 215 | # via faker 216 | urllib3==2.2.3 217 | # via 218 | # -c requirements/common_constraints.txt 219 | # -r requirements/base.txt 220 | # requests 221 | virtualenv==20.30.0 222 | # via tox 223 | 224 | # The following packages are considered to be unsafe in a requirements file: 225 | # setuptools 226 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [pycodestyle] 5 | max-line-length = 120 6 | exclude=.git,migrations,.venv,.tox,docs 7 | 8 | [tool:isort] 9 | indent=' ' 10 | line_length=88 11 | multi_line_output=3 12 | lines_after_imports=2 13 | include_trailing_comma=True 14 | skip= 15 | settings 16 | migrations 17 | 18 | [tool:pytest] 19 | DJANGO_SETTINGS_MODULE = test_settings 20 | addopts = --cov csrf --cov edx_rest_framework_extensions --cov-report term-missing --cov-report html 21 | norecursedirs = .* tests.py docs requirements 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import re 4 | 5 | from setuptools import find_packages, setup 6 | 7 | 8 | def is_requirement(line): 9 | """ 10 | Return True if the requirement line is a package requirement. 11 | 12 | Returns: 13 | bool: True if the line is not blank, a comment, 14 | a URL, or an included file 15 | """ 16 | # UPDATED VIA SEMGREP - if you need to remove/modify this method remove this line and add a comment specifying why 17 | 18 | return line and line.strip() and not line.startswith(('-r', '#', '-e', 'git+', '-c')) 19 | 20 | 21 | def load_requirements(*requirements_paths): 22 | """ 23 | Load all requirements from the specified requirements files. 24 | 25 | Requirements will include any constraints from files specified 26 | with -c in the requirements files. 27 | Returns a list of requirement strings. 28 | """ 29 | # UPDATED VIA SEMGREP - if you need to remove/modify this method remove this line and add a comment specifying why. 30 | 31 | # e.g. {"django": "Django", "confluent-kafka": "confluent_kafka[avro]"} 32 | by_canonical_name = {} 33 | 34 | def check_name_consistent(package): 35 | """ 36 | Raise exception if package is named different ways. 37 | 38 | This ensures that packages are named consistently so we can match 39 | constraints to packages. It also ensures that if we require a package 40 | with extras we don't constrain it without mentioning the extras (since 41 | that too would interfere with matching constraints.) 42 | """ 43 | canonical = package.lower().replace('_', '-').split('[')[0] 44 | seen_spelling = by_canonical_name.get(canonical) 45 | if seen_spelling is None: 46 | by_canonical_name[canonical] = package 47 | elif seen_spelling != package: 48 | raise Exception( 49 | f'Encountered both "{seen_spelling}" and "{package}" in requirements ' 50 | 'and constraints files; please use just one or the other.' 51 | ) 52 | 53 | requirements = {} 54 | constraint_files = set() 55 | 56 | # groups "pkg<=x.y.z,..." into ("pkg", "<=x.y.z,...") 57 | re_package_name_base_chars = r"a-zA-Z0-9\-_." # chars allowed in base package name 58 | # Two groups: name[maybe,extras], and optionally a constraint 59 | requirement_line_regex = re.compile( 60 | r"([%s]+(?:\[[%s,\s]+\])?)([<>=][^#\s]+)?" 61 | % (re_package_name_base_chars, re_package_name_base_chars) 62 | ) 63 | 64 | def add_version_constraint_or_raise(current_line, current_requirements, add_if_not_present): 65 | regex_match = requirement_line_regex.match(current_line) 66 | if regex_match: 67 | package = regex_match.group(1) 68 | version_constraints = regex_match.group(2) 69 | check_name_consistent(package) 70 | existing_version_constraints = current_requirements.get(package, None) 71 | # It's fine to add constraints to an unconstrained package, 72 | # but raise an error if there are already constraints in place. 73 | if existing_version_constraints and existing_version_constraints != version_constraints: 74 | raise BaseException(f'Multiple constraint definitions found for {package}:' 75 | f' "{existing_version_constraints}" and "{version_constraints}".' 76 | f'Combine constraints into one location with {package}' 77 | f'{existing_version_constraints},{version_constraints}.') 78 | if add_if_not_present or package in current_requirements: 79 | current_requirements[package] = version_constraints 80 | 81 | # Read requirements from .in files and store the path to any 82 | # constraint files that are pulled in. 83 | for path in requirements_paths: 84 | with open(path) as reqs: 85 | for line in reqs: 86 | if is_requirement(line): 87 | add_version_constraint_or_raise(line, requirements, True) 88 | if line and line.startswith('-c') and not line.startswith('-c http'): 89 | constraint_files.add(os.path.dirname(path) + '/' + line.split('#')[0].replace('-c', '').strip()) 90 | 91 | # process constraint files: add constraints to existing requirements 92 | for constraint_file in constraint_files: 93 | with open(constraint_file) as reader: 94 | for line in reader: 95 | if is_requirement(line): 96 | add_version_constraint_or_raise(line, requirements, False) 97 | 98 | # process back into list of pkg><=constraints strings 99 | constrained_requirements = [f'{pkg}{version or ""}' for (pkg, version) in sorted(requirements.items())] 100 | return constrained_requirements 101 | 102 | 103 | def get_version(*file_paths): 104 | """ 105 | Extract the version string from the file at the given relative path fragments. 106 | """ 107 | filename = os.path.join(os.path.dirname(__file__), *file_paths) 108 | with open(filename, encoding='utf-8') as opened_file: 109 | version_file = opened_file.read() 110 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", 111 | version_file, re.M) 112 | if version_match: 113 | return version_match.group(1) 114 | raise RuntimeError('Unable to find version string.') 115 | 116 | 117 | VERSION = get_version("edx_rest_framework_extensions", "__init__.py") 118 | 119 | README = open(os.path.join(os.path.dirname(__file__), 'README.rst'), encoding="utf8").read() 120 | CHANGELOG = open(os.path.join(os.path.dirname(__file__), 'CHANGELOG.rst'), encoding="utf8").read() 121 | 122 | 123 | setup( 124 | name='edx-drf-extensions', 125 | version=VERSION, 126 | description='edX extensions of Django REST Framework', 127 | long_description=README + '\n\n' + CHANGELOG, 128 | author='edX', 129 | author_email='oscm@edx.org', 130 | url='https://github.com/openedx/edx-drf-extensions', 131 | license='Apache 2.0', 132 | classifiers=[ 133 | 'Development Status :: 5 - Production/Stable', 134 | 'Environment :: Web Environment', 135 | 'Intended Audience :: Developers', 136 | 'License :: OSI Approved :: Apache Software License', 137 | 'Operating System :: OS Independent', 138 | 'Programming Language :: Python :: 3', 139 | 'Programming Language :: Python :: 3.11', 140 | 'Framework :: Django', 141 | 'Framework :: Django :: 4.2', 142 | 'Framework :: Django :: 5.2', 143 | ], 144 | packages=find_packages(exclude=["tests"]), 145 | install_requires=load_requirements('requirements/base.in'), 146 | tests_require=load_requirements('requirements/test.in'), 147 | ) 148 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | These settings are here to use during tests, because django requires them. 3 | 4 | In a real-world use case, apps in this project are installed into other 5 | Django applications, so these settings will not be used. 6 | """ 7 | 8 | SECRET_KEY = 'insecure-secret-key' 9 | 10 | ROOT_URLCONF = 'csrf.urls' 11 | 12 | INSTALLED_APPS = ( 13 | 'csrf.apps.CsrfAppConfig', 14 | 'django.contrib.auth', 15 | 'django.contrib.contenttypes', 16 | 'django.contrib.sessions', 17 | 'edx_rest_framework_extensions', 18 | 'rest_framework_jwt', 19 | 'waffle', 20 | ) 21 | 22 | DATABASES = { 23 | 'default': { 24 | 'ENGINE': 'django.db.backends.sqlite3', 25 | 'NAME': 'default.db', 26 | 'USER': '', 27 | 'PASSWORD': '', 28 | 'HOST': '', 29 | 'PORT': '', 30 | } 31 | } 32 | 33 | EDX_DRF_EXTENSIONS = {} 34 | 35 | # USER_SETTINGS overrides for djangorestframework-jwt APISettings class 36 | # See https://github.com/GetBlimp/django-rest-framework-jwt/blob/master/rest_framework_jwt/settings.py 37 | JWT_AUTH = { 38 | 39 | 'JWT_AUTH_COOKIE': 'edx-jwt-cookie', 40 | 41 | 'JWT_AUDIENCE': 'test-aud', 42 | 43 | 'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.auth.jwt.decoder.jwt_decode_handler', 44 | 45 | 'JWT_ISSUER': 'test-iss', 46 | 47 | 'JWT_LEEWAY': 1, 48 | 49 | # This matches the configuration of all Open edX services. 50 | 'JWT_PAYLOAD_GET_USERNAME_HANDLER': lambda d: d.get('preferred_username'), 51 | 52 | 'JWT_SECRET_KEY': 'test-key', 53 | 54 | 'JWT_PUBLIC_SIGNING_JWK_SET': """ 55 | { 56 | "keys": [ 57 | { 58 | "kid": "BTZ9HA6K", 59 | "kty": "RSA", 60 | "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", 61 | "e": "AQAB" 62 | } 63 | ] 64 | } 65 | """, # noqa: E501 66 | 67 | 'JWT_PRIVATE_SIGNING_JWK': """ 68 | { 69 | "kid": "BTZ9HA6K", 70 | "kty": "RSA", 71 | "key_ops": [ 72 | "sign" 73 | ], 74 | "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", 75 | "e": "AQAB", 76 | "d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_XvFUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZsgbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3xWTLh8qOnHqGko93A1diLRCrKHOvnpvgQ", 77 | "p": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Loz3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy1KB6fCby0C9WE", 78 | "q": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfHayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", 79 | "dp": "Azh08H8r2_sJuBXAzx_mQ6iZnAZQ619PnJFOXjTqnMgcaK8iSHLL2CgDIUQwteUcBphgP0uBrfWIBs5jmM8rUtVz4CcrPb5jdjhHjuu4NxmnFbPlhNoOp8OBUjPP3S-h-fPoaFjxDrUqz_zCdPVzp4S6UTkf6Hu-SiI9CFVFZ8E", 80 | "dq": "WQ44_KTIbIej9qnYUPMA1DoaAF8ImVDIdiOp9c79dC7FvCpN3w-lnuugrYDM1j9Tk5bRrY7-JuE6OaKQgOtajoS1BIxjYHj5xAVPD15CVevOihqeq5Zx0ZAAYmmCKRrfUe0iLx2QnIcoKH1-Azs23OXeeo6nysznZjvv9NVJv60", 81 | "qi": "KSWGH607H1kNG2okjYdmVdNgLxTUB-Wye9a9FNFE49UmQIOJeZYXtDzcjk8IiK3g-EU3CqBeDKVUgHvHFu4_Wj3IrIhKYizS4BeFmOcPDvylDQCmJcC9tXLQgHkxM_MEJ7iLn9FOLRshh7GPgZphXxMhezM26Cz-8r3_mACHu84" 82 | } 83 | """, # noqa: E501 84 | 85 | 'JWT_SIGNING_ALGORITHM': 'RS512', 86 | 87 | 'JWT_SUPPORTED_VERSION': '1.0.0', 88 | 89 | 'JWT_VERIFY_AUDIENCE': False, 90 | 91 | 'JWT_VERIFY_EXPIRATION': True, 92 | 93 | 'JWT_AUTH_HEADER_PREFIX': 'JWT', 94 | # JWT_ISSUERS enables token decoding for multiple issuers (Note: This is not a native DRF-JWT field) 95 | # We use it to allow different values for the 'ISSUER' field, but keep the same SECRET_KEY and 96 | # AUDIENCE values across all issuers. 97 | 'JWT_ISSUERS': [ 98 | { 99 | 'ISSUER': 'test-issuer-1', 100 | 'SECRET_KEY': 'test-secret-key', 101 | 'AUDIENCE': 'test-audience', 102 | }, 103 | { 104 | 'ISSUER': 'test-issuer-2', 105 | 'SECRET_KEY': 'test-secret-key', 106 | 'AUDIENCE': 'test-audience', 107 | } 108 | ], 109 | } 110 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{312}-django{42,52}-drf{latest}, quality, docs 3 | 4 | [testenv] 5 | setenv = 6 | PYTHONPATH = {toxinidir} 7 | deps = 8 | -r{toxinidir}/requirements/test.txt 9 | django42: Django>=4.2,<4.3 10 | django52: Django>=5.2,<5.3 11 | drflatest: djangorestframework 12 | commands = 13 | python -Wd -m pytest --cov {posargs} 14 | coverage report 15 | 16 | [testenv:quality] 17 | setenv = 18 | PYTHONPATH = {toxinidir} 19 | allowlist_externals = 20 | make 21 | commands = 22 | make quality 23 | 24 | [testenv:docs] 25 | changedir = docs 26 | deps = 27 | -r{toxinidir}/requirements/docs.txt 28 | allowlist_externals = 29 | make 30 | commands = 31 | make html 32 | --------------------------------------------------------------------------------