├── .coveragerc ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ ├── auto-merge.yml │ ├── ci.yaml │ └── codeql.yml ├── .gitignore ├── .mypy.ini ├── .readthedocs.yml ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── aiohttp_security ├── __init__.py ├── abc.py ├── api.py ├── cookies_identity.py ├── jwt_identity.py ├── py.typed └── session_identity.py ├── demo ├── __init__.py ├── database_auth │ ├── __init__.py │ ├── __main__.py │ ├── db.py │ ├── db_auth.py │ ├── handlers.py │ └── main.py ├── dictionary_auth │ ├── __init__.py │ ├── authz.py │ ├── handlers.py │ ├── main.py │ └── users.py └── simple_example_auth.py ├── docs ├── Makefile ├── _static │ └── aiohttp-icon-128x128.png ├── aiohttp-icon.ico ├── aiohttp_doctools.py ├── conf.py ├── example.rst ├── example_db_auth.rst ├── glossary.rst ├── index.rst ├── make.bat ├── reference.rst └── usage.rst ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── test_cookies_identity.py ├── test_dict_autz.py ├── test_jwt_identity.py ├── test_no_auth.py ├── test_no_identity.py └── test_session_identity.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = aiohttp_security, tests 4 | omit = site-packages 5 | 6 | [html] 7 | directory = coverage -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | enable-extensions = G 3 | extend-exclude = build/ 4 | max-doc-length = 90 5 | max-line-length = 90 6 | select = A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,B901,B902,B903,B950 7 | # E226: Missing whitespace around arithmetic operators can help group things together. 8 | # E501: Superseeded by B950 (from Bugbear) 9 | # E722: Superseeded by B001 (from Bugbear) 10 | # W503: Mutually exclusive with W504. 11 | ignore = E226,E501,E722,W503 12 | per-file-ignores = 13 | # B011: assert False used for coverage skipping 14 | # S101: Pytest uses assert 15 | tests/*:B011,S101 16 | 17 | # flake8-import-order 18 | application-import-names = aiohttp_security 19 | import-order-style = pycharm 20 | 21 | # flake8-quotes 22 | inline-quotes = " 23 | 24 | # flake8-requirements 25 | requirements-file = requirements-dev.txt 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request_target 3 | 4 | permissions: 5 | pull-requests: write 6 | contents: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2.4.0 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | run: gh pr merge --auto --squash "$PR_URL" 20 | env: 21 | PR_URL: ${{github.event.pull_request.html_url}} 22 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - '[0-9].[0-9]+' 8 | tags: [ 'v*' ] 9 | pull_request: 10 | branches: 11 | - master 12 | - '[0-9].[0-9]+' 13 | 14 | jobs: 15 | lint: 16 | name: Linter 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 5 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | - name: Setup Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: '3.10' 26 | cache: 'pip' 27 | cache-dependency-path: '**/requirements*.txt' 28 | - name: Install dependencies 29 | uses: py-actions/py-dependency-install@v4 30 | with: 31 | path: requirements-dev.txt 32 | - name: Install itself 33 | run: | 34 | pip install . 35 | - name: Mypy 36 | run: mypy 37 | - name: Flake8 38 | run: flake8 39 | - name: Prepare twine checker 40 | run: | 41 | pip install -U build twine 42 | python -m build 43 | - name: Run twine checker 44 | run: | 45 | twine check --strict dist/* 46 | 47 | test: 48 | name: Tests 49 | runs-on: ubuntu-latest 50 | strategy: 51 | matrix: 52 | pyver: ['pypy-3.9', '3.9', '3.10', '3.11', '3.12', '3.13'] 53 | timeout-minutes: 15 54 | steps: 55 | - name: Checkout 56 | uses: actions/checkout@v4 57 | - name: Set up Python ${{ matrix.python-version }} 58 | uses: actions/setup-python@v5 59 | with: 60 | python-version: ${{ matrix.pyver }} 61 | cache: 'pip' 62 | cache-dependency-path: '**/requirements*.txt' 63 | - name: Install dependencies 64 | uses: py-actions/py-dependency-install@v4 65 | with: 66 | path: requirements.txt 67 | - name: Run unittests 68 | env: 69 | COLOR: 'yes' 70 | run: | 71 | pytest tests --cov-report xml 72 | python -m coverage xml 73 | - name: Upload coverage 74 | uses: codecov/codecov-action@v5 75 | with: 76 | file: ./coverage.xml 77 | flags: unit 78 | fail_ci_if_error: false 79 | 80 | check: # This job does nothing and is only used for the branch protection 81 | if: always() 82 | 83 | needs: [lint, test] 84 | 85 | runs-on: ubuntu-latest 86 | 87 | steps: 88 | - name: Decide whether the needed jobs succeeded or failed 89 | uses: re-actors/alls-green@release/v1 90 | with: 91 | jobs: ${{ toJSON(needs) }} 92 | 93 | deploy: 94 | name: Deploy 95 | environment: release 96 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 97 | needs: [check] 98 | runs-on: ubuntu-latest 99 | steps: 100 | - name: Checkout 101 | uses: actions/checkout@v4 102 | - name: Setup Python 103 | uses: actions/setup-python@v5 104 | with: 105 | python-version: 3.13 106 | - name: Install dependencies 107 | run: 108 | python -m pip install -U pip wheel setuptools build twine 109 | - name: Build dists 110 | run: | 111 | python -m build 112 | - name: Make Release 113 | uses: aio-libs/create-release@v1.6.6 114 | with: 115 | changes_file: CHANGES.rst 116 | name: aiohttp-security 117 | version_file: aiohttp_security/__init__.py 118 | github_token: ${{ secrets.GITHUB_TOKEN }} 119 | pypi_token: ${{ secrets.PYPI_API_TOKEN }} 120 | dist_dir: dist 121 | fix_issue_regex: "`#(\\d+) `" 122 | fix_issue_repl: "(#\\1)" 123 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ 'master' ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ 'master' ] 9 | schedule: 10 | - cron: '6 6 * * 5' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'python' ] 25 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 26 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | 32 | # Initializes the CodeQL tools for scanning. 33 | - name: Initialize CodeQL 34 | uses: github/codeql-action/init@v3 35 | with: 36 | languages: ${{ matrix.language }} 37 | # If you wish to specify custom queries, you can do so here or in a config file. 38 | # By default, queries listed here will override any specified in a config file. 39 | # Prefix the list here with "+" to use these queries and those in the config file. 40 | 41 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 42 | # queries: security-extended,security-and-quality 43 | 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v3 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 52 | 53 | # If the Autobuild fails above, remove it and uncomment the following three lines. 54 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 55 | 56 | # - run: | 57 | # echo "Run, Build Application using script" 58 | # ./location_of_script_within_repo/buildscript.sh 59 | 60 | - name: Perform CodeQL Analysis 61 | uses: github/codeql-action/analyze@v3 62 | with: 63 | category: "/language:${{matrix.language}}" 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | include/ 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 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | 45 | # Translations 46 | *.mo 47 | *.pot 48 | 49 | # Django stuff: 50 | *.log 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | # PyBuilder 56 | target/ 57 | 58 | coverage 59 | .pytest_cache -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | files = aiohttp_security, demo, tests 3 | check_untyped_defs = True 4 | follow_imports_for_stubs = True 5 | disallow_any_decorated = True 6 | disallow_any_generics = True 7 | disallow_any_unimported = True 8 | disallow_incomplete_defs = True 9 | disallow_subclassing_any = True 10 | disallow_untyped_calls = True 11 | disallow_untyped_decorators = True 12 | disallow_untyped_defs = True 13 | enable_error_code = redundant-expr, truthy-bool, ignore-without-code, unused-awaitable 14 | implicit_reexport = False 15 | no_implicit_optional = True 16 | pretty = True 17 | show_column_numbers = True 18 | show_error_codes = True 19 | strict_equality = True 20 | warn_incomplete_stub = True 21 | warn_redundant_casts = True 22 | warn_return_any = True 23 | warn_unreachable = True 24 | warn_unused_ignores = True 25 | 26 | [mypy-tests.*] 27 | disallow_any_decorated = False 28 | disallow_untyped_defs = False 29 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html 3 | # for details 4 | 5 | --- 6 | version: 2 7 | 8 | sphinx: 9 | # Path to your Sphinx configuration file. 10 | configuration: docs/conf.py 11 | 12 | submodules: 13 | include: all 14 | exclude: [] 15 | recursive: true 16 | 17 | build: 18 | os: ubuntu-24.04 19 | tools: 20 | python: "3.13" 21 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | CHANGES 3 | ======= 4 | 5 | .. towncrier release notes start 6 | 7 | 0.5.0 (2023-11-18) 8 | ================== 9 | 10 | - Added type annotations. 11 | - Added a reason message when permission is rejected. 12 | - Switched to ``aiohttp.web.AppKey``. 13 | - Reverted change in ``JWTIdentityPolicy`` so identity returns ``str``. 14 | 15 | 0.4.0 (2018-09-27) 16 | ================== 17 | 18 | - Bump minimal supported ``aiohttp`` version to 3.2. 19 | - Use ``request.config_dict`` for accessing ``jinja2`` environment. It 20 | allows to reuse jinja rendering engine from parent application. 21 | 22 | 0.3.0 (2018-09-06) 23 | ================== 24 | 25 | - Deprecate ``login_required`` and ``has_permission`` decorators. 26 | Use ``check_authorized`` and ``check_permission`` helper functions instead. 27 | - Bump supported ``aiohttp`` version to 3.0+. 28 | - Enable strong warnings mode for test suite, clean-up all deprecation warnings. 29 | - Polish documentation 30 | 31 | 0.2.0 (2017-11-17) 32 | ================== 33 | 34 | - Add ``is_anonymous``, ``login_required``, ``has_permission`` helpers. (#114) 35 | 36 | 0.1.2 (2017-10-17) 37 | ================== 38 | 39 | - Make aiohttp-session optional dependency. (#107) 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2015-2018 Andrew Svetlov and aio-libs team. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include CHANGES.rst 3 | include README.rst 4 | include Makefile 5 | graft aiohttp_security 6 | graft docs 7 | graft examples 8 | graft tests 9 | global-exclude *.pyc 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Some simple testing tasks (sorry, UNIX only). 2 | 3 | lint: 4 | flake8 5 | 6 | test: flake 7 | pytest -s -q ./tests/ 8 | 9 | vtest: flake 10 | pytest -s ./tests/ 11 | 12 | cov cover coverage: flake 13 | pytest -s ./tests/ --cov-report=term 14 | @echo "open file://`pwd`/coverage/index.html" 15 | 16 | clean: 17 | rm -rf `find . -name __pycache__` 18 | rm -f `find . -type f -name '*.py[co]' ` 19 | rm -f `find . -type f -name '*~' ` 20 | rm -f `find . -type f -name '.*~' ` 21 | rm -f `find . -type f -name '@*' ` 22 | rm -f `find . -type f -name '#*#' ` 23 | rm -f `find . -type f -name '*.orig' ` 24 | rm -f `find . -type f -name '*.rej' ` 25 | rm -f .coverage 26 | rm -rf coverage 27 | rm -rf build 28 | rm -rf cover 29 | make -C docs clean 30 | python setup.py clean 31 | 32 | doc: 33 | make -C docs html 34 | @echo "open file://`pwd`/docs/_build/html/index.html" 35 | 36 | .PHONY: all build venv flake test vtest testloop cov clean doc 37 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | aiohttp_security 2 | ================ 3 | .. image:: https://travis-ci.com/aio-libs/aiohttp-security.svg?branch=master 4 | :target: https://travis-ci.com/aio-libs/aiohttp-security 5 | .. image:: https://codecov.io/github/aio-libs/aiohttp-security/coverage.svg?branch=master 6 | :target: https://codecov.io/github/aio-libs/aiohttp-security 7 | .. image:: https://readthedocs.org/projects/aiohttp-security/badge/?version=latest 8 | :target: https://aiohttp-security.readthedocs.io/ 9 | .. image:: https://img.shields.io/pypi/v/aiohttp-security.svg 10 | :target: https://pypi.python.org/pypi/aiohttp-security 11 | 12 | The library provides identity and authorization for `aiohttp.web`__. 13 | 14 | .. _aiohttp_web: http://aiohttp.readthedocs.org/en/latest/web.html 15 | 16 | __ aiohttp_web_ 17 | 18 | Installation 19 | ------------ 20 | Simplest case (authorization via cookies) :: 21 | 22 | $ pip install aiohttp_security 23 | 24 | With `aiohttp-session` support :: 25 | 26 | $ pip install aiohttp_security[session] 27 | 28 | Examples 29 | -------- 30 | Take a look at examples: 31 | 32 | `Basic example`_ 33 | 34 | `Example with DB auth`_ 35 | 36 | .. _`Basic example`: docs/example.rst 37 | .. _`Example with db auth`: docs/example_db_auth.rst 38 | 39 | and demos at **demo** directory. 40 | 41 | Documentation 42 | ------------- 43 | 44 | https://aiohttp-security.readthedocs.io/ 45 | 46 | Develop 47 | ------- 48 | 49 | ``pip install -r requirements-dev.txt`` 50 | 51 | 52 | License 53 | ------- 54 | 55 | ``aiohttp_security`` is offered under the Apache 2 license. 56 | -------------------------------------------------------------------------------- /aiohttp_security/__init__.py: -------------------------------------------------------------------------------- 1 | from .abc import AbstractAuthorizationPolicy, AbstractIdentityPolicy 2 | from .api import (authorized_userid, check_authorized, check_permission, forget, 3 | is_anonymous, permits, remember, setup) 4 | from .cookies_identity import CookiesIdentityPolicy 5 | from .jwt_identity import JWTIdentityPolicy 6 | from .session_identity import SessionIdentityPolicy 7 | 8 | __version__ = '0.5.0' 9 | 10 | 11 | __all__ = ('AbstractIdentityPolicy', 'AbstractAuthorizationPolicy', 12 | 'CookiesIdentityPolicy', 'SessionIdentityPolicy', 13 | 'JWTIdentityPolicy', 14 | 'remember', 'forget', 'authorized_userid', 15 | 'permits', 'setup', 'is_anonymous', 16 | 'check_authorized', 'check_permission') 17 | -------------------------------------------------------------------------------- /aiohttp_security/abc.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from enum import Enum 3 | from typing import Any, Optional, Union 4 | 5 | from aiohttp import web 6 | 7 | # see http://plope.com/pyramid_auth_design_api_postmortem 8 | 9 | 10 | class AbstractIdentityPolicy(metaclass=abc.ABCMeta): 11 | 12 | @abc.abstractmethod 13 | async def identify(self, request: web.Request) -> Optional[str]: 14 | """Return the claimed identity of the user associated request or 15 | ``None`` if no identity can be found associated with the request.""" 16 | pass 17 | 18 | @abc.abstractmethod 19 | async def remember(self, request: web.Request, 20 | response: web.StreamResponse, identity: str, **kwargs: Any) -> None: 21 | """Remember identity. 22 | 23 | Modify response object by filling it's headers with remembered user. 24 | 25 | An individual identity policy and its consumers can decide on 26 | the composition and meaning of **kwargs. 27 | """ 28 | pass 29 | 30 | @abc.abstractmethod 31 | async def forget(self, request: web.Request, response: web.StreamResponse) -> None: 32 | """ Modify response which can be used to 'forget' the 33 | current identity on subsequent requests.""" 34 | pass 35 | 36 | 37 | class AbstractAuthorizationPolicy(metaclass=abc.ABCMeta): 38 | 39 | @abc.abstractmethod 40 | async def permits(self, identity: Optional[str], 41 | permission: Union[str, Enum], context: Any = None) -> bool: 42 | """Check user permissions. 43 | 44 | Return True if the identity is allowed the permission in the 45 | current context, else return False. 46 | """ 47 | pass 48 | 49 | @abc.abstractmethod 50 | async def authorized_userid(self, identity: str) -> Optional[str]: 51 | """Retrieve authorized user id. 52 | 53 | Return the user_id of the user identified by the identity 54 | or 'None' if no user exists related to the identity. 55 | """ 56 | pass 57 | -------------------------------------------------------------------------------- /aiohttp_security/api.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from typing import Any, Optional, Union 3 | 4 | from aiohttp import web 5 | 6 | from aiohttp_security.abc import AbstractAuthorizationPolicy, AbstractIdentityPolicy 7 | 8 | IDENTITY_KEY = web.AppKey("IDENTITY_KEY", AbstractIdentityPolicy) 9 | AUTZ_KEY = web.AppKey("AUTZ_KEY", AbstractAuthorizationPolicy) 10 | 11 | # _AIP/_AAP are shorthand for Optional[policy] when we retrieve from request. 12 | _AAP = Optional[AbstractAuthorizationPolicy] 13 | _AIP = Optional[AbstractIdentityPolicy] 14 | 15 | 16 | async def remember(request: web.Request, response: web.StreamResponse, 17 | identity: str, **kwargs: Any) -> None: 18 | """Remember identity into response. 19 | 20 | The action is performed by identity_policy.remember() 21 | 22 | Usually the identity is stored in user cookies somehow but may be 23 | pushed into custom header also. 24 | """ 25 | if not identity or not isinstance(identity, str): 26 | raise ValueError("Identity should be a str value.") 27 | identity_policy = request.config_dict.get(IDENTITY_KEY) 28 | if identity_policy is None: 29 | text = ("Security subsystem is not initialized, " 30 | "call aiohttp_security.setup(...) first") 31 | # in order to see meaningful exception message both: on console 32 | # output and rendered page we add same message to *reason* and 33 | # *text* arguments. 34 | raise web.HTTPInternalServerError(reason=text, text=text) 35 | await identity_policy.remember(request, response, identity, **kwargs) 36 | 37 | 38 | async def forget(request: web.Request, response: web.StreamResponse) -> None: 39 | """Forget previously remembered identity. 40 | 41 | Usually it clears cookie or server-side storage to forget user 42 | session. 43 | """ 44 | identity_policy = request.config_dict.get(IDENTITY_KEY) 45 | if identity_policy is None: 46 | text = ("Security subsystem is not initialized, " 47 | "call aiohttp_security.setup(...) first") 48 | # in order to see meaningful exception message both: on console 49 | # output and rendered page we add same message to *reason* and 50 | # *text* arguments. 51 | raise web.HTTPInternalServerError(reason=text, text=text) 52 | await identity_policy.forget(request, response) 53 | 54 | 55 | async def authorized_userid(request: web.Request) -> Optional[str]: 56 | identity_policy: _AIP = request.config_dict.get(IDENTITY_KEY) 57 | autz_policy: _AAP = request.config_dict.get(AUTZ_KEY) 58 | if identity_policy is None or autz_policy is None: 59 | return None 60 | identity = await identity_policy.identify(request) 61 | if identity is None: 62 | return None # non-registered user has None user_id 63 | user_id = await autz_policy.authorized_userid(identity) 64 | return user_id 65 | 66 | 67 | async def permits(request: web.Request, permission: Union[str, enum.Enum], 68 | context: Any = None) -> bool: 69 | if not permission or not isinstance(permission, (str, enum.Enum)): 70 | raise ValueError("Permission should be a str or enum value.") 71 | identity_policy: _AIP = request.config_dict.get(IDENTITY_KEY) 72 | autz_policy: _AAP = request.config_dict.get(AUTZ_KEY) 73 | if identity_policy is None or autz_policy is None: 74 | return True 75 | identity = await identity_policy.identify(request) 76 | # non-registered user still may have some permissions 77 | access = await autz_policy.permits(identity, permission, context) 78 | return access 79 | 80 | 81 | async def is_anonymous(request: web.Request) -> bool: 82 | """Check if user is anonymous. 83 | 84 | User is considered anonymous if there is not identity 85 | in request. 86 | """ 87 | identity_policy = request.config_dict.get(IDENTITY_KEY) 88 | if identity_policy is None: 89 | return True 90 | identity = await identity_policy.identify(request) 91 | if identity is None: 92 | return True 93 | return False 94 | 95 | 96 | async def check_authorized(request: web.Request) -> str: 97 | """Checker that raises HTTPUnauthorized for anonymous users. 98 | """ 99 | userid = await authorized_userid(request) 100 | if userid is None: 101 | raise web.HTTPUnauthorized() 102 | return userid 103 | 104 | 105 | async def check_permission(request: web.Request, permission: Union[str, enum.Enum], 106 | context: Any = None) -> None: 107 | """Checker that passes only to authoraised users with given permission. 108 | 109 | If user is not authorized - raises HTTPUnauthorized, 110 | if user is authorized and does not have permission - 111 | raises HTTPForbidden. 112 | """ 113 | 114 | await check_authorized(request) 115 | allowed = await permits(request, permission, context) 116 | if not allowed: 117 | raise web.HTTPForbidden(reason="User does not have '{}' permission".format(permission)) 118 | 119 | 120 | def setup(app: web.Application, identity_policy: AbstractIdentityPolicy, 121 | autz_policy: AbstractAuthorizationPolicy) -> None: 122 | if not isinstance(identity_policy, AbstractIdentityPolicy): 123 | raise ValueError("Identity policy is not subclass of AbstractIdentityPolicy") 124 | if not isinstance(autz_policy, AbstractAuthorizationPolicy): 125 | raise ValueError("Authentication policy is not subclass of AbstractAuthorizationPolicy") 126 | 127 | app[IDENTITY_KEY] = identity_policy 128 | app[AUTZ_KEY] = autz_policy 129 | -------------------------------------------------------------------------------- /aiohttp_security/cookies_identity.py: -------------------------------------------------------------------------------- 1 | """Identity policy for storing info directly into HTTP cookie. 2 | 3 | Use mostly for demonstration purposes, SessionIdentityPolicy is much 4 | more handy. 5 | 6 | """ 7 | 8 | from typing import Any, NewType, Optional, Union, cast 9 | 10 | from aiohttp import web 11 | 12 | from .abc import AbstractIdentityPolicy 13 | 14 | _Sentinel = NewType("_Sentinel", object) 15 | sentinel = _Sentinel(object()) 16 | 17 | 18 | class CookiesIdentityPolicy(AbstractIdentityPolicy): 19 | 20 | def __init__(self) -> None: 21 | self._cookie_name = 'AIOHTTP_SECURITY' 22 | self._max_age = 30 * 24 * 3600 23 | 24 | async def identify(self, request: web.Request) -> Optional[str]: 25 | return request.cookies.get(self._cookie_name) 26 | 27 | async def remember(self, request: web.Request, response: web.StreamResponse, 28 | identity: str, max_age: Union[_Sentinel, Optional[int]] = sentinel, 29 | **kwargs: Any) -> None: 30 | if max_age is sentinel: 31 | max_age = self._max_age 32 | max_age = cast(Optional[int], max_age) 33 | response.set_cookie(self._cookie_name, identity, 34 | max_age=max_age, **kwargs) 35 | 36 | async def forget(self, request: web.Request, response: web.StreamResponse) -> None: 37 | response.del_cookie(self._cookie_name) 38 | -------------------------------------------------------------------------------- /aiohttp_security/jwt_identity.py: -------------------------------------------------------------------------------- 1 | """Identity policy for storing info in the jwt token. 2 | 3 | """ 4 | 5 | from typing import Optional, Tuple, Type 6 | 7 | from aiohttp import web 8 | 9 | from .abc import AbstractIdentityPolicy 10 | 11 | try: 12 | import jwt 13 | HAS_JWT = True 14 | _bases_error: Tuple[Type[jwt.exceptions.PyJWTError], ...] 15 | _bases_error = (jwt.exceptions.PyJWTError,) 16 | except ImportError: # pragma: no cover 17 | HAS_JWT = False 18 | _bases_error = () 19 | 20 | 21 | AUTH_HEADER_NAME = 'Authorization' 22 | AUTH_SCHEME = 'Bearer ' 23 | 24 | 25 | # This class inherits from ValueError to maintain backward compatibility 26 | # with previous versions of aiohttp-security 27 | class InvalidAuthorizationScheme(ValueError, *_bases_error): # type: ignore[misc] 28 | """Exception when the auth method can't be read from header.""" 29 | 30 | 31 | class JWTIdentityPolicy(AbstractIdentityPolicy): 32 | def __init__(self, secret: str, algorithm: str = "HS256", key: str = "login"): 33 | if not HAS_JWT: 34 | raise RuntimeError('Please install `PyJWT`') 35 | self.secret = secret 36 | self.algorithm = algorithm 37 | self.key = key 38 | 39 | async def identify(self, request: web.Request) -> Optional[str]: 40 | header_identity = request.headers.get(AUTH_HEADER_NAME) 41 | 42 | if header_identity is None: 43 | return None 44 | 45 | if not header_identity.startswith(AUTH_SCHEME): 46 | raise InvalidAuthorizationScheme("Invalid authorization scheme. " 47 | "Should be `{}`".format(AUTH_SCHEME)) 48 | 49 | token = header_identity.split(' ')[1].strip() 50 | 51 | identity = jwt.decode(token, 52 | self.secret, 53 | algorithms=[self.algorithm]) 54 | return identity.get(self.key) # type: ignore[no-any-return] 55 | 56 | async def remember(self, request: web.Request, response: web.StreamResponse, 57 | identity: str, **kwargs: None) -> None: 58 | pass 59 | 60 | async def forget(self, request: web.Request, response: web.StreamResponse) -> None: 61 | pass 62 | -------------------------------------------------------------------------------- /aiohttp_security/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-security/1d401e323e187fea5d761d92ac177faaaefe29a6/aiohttp_security/py.typed -------------------------------------------------------------------------------- /aiohttp_security/session_identity.py: -------------------------------------------------------------------------------- 1 | """Identity policy for storing info into aiohttp_session session. 2 | 3 | aiohttp_session.setup() should be called on application initialization 4 | to configure aiohttp_session properly. 5 | """ 6 | 7 | from typing import Optional 8 | 9 | from aiohttp import web 10 | try: 11 | from aiohttp_session import get_session 12 | HAS_AIOHTTP_SESSION = True 13 | except ImportError: # pragma: no cover 14 | HAS_AIOHTTP_SESSION = False 15 | 16 | from .abc import AbstractIdentityPolicy 17 | 18 | 19 | class SessionIdentityPolicy(AbstractIdentityPolicy): 20 | 21 | def __init__(self, session_key: str = 'AIOHTTP_SECURITY'): 22 | self._session_key = session_key 23 | 24 | if not HAS_AIOHTTP_SESSION: # pragma: no cover 25 | raise ImportError( 26 | 'SessionIdentityPolicy requires `aiohttp_session`') 27 | 28 | async def identify(self, request: web.Request) -> Optional[str]: 29 | session = await get_session(request) 30 | return session.get(self._session_key) 31 | 32 | async def remember(self, request: web.Request, response: web.StreamResponse, 33 | identity: str, **kwargs: object) -> None: 34 | session = await get_session(request) 35 | session[self._session_key] = identity 36 | 37 | async def forget(self, request: web.Request, response: web.StreamResponse) -> None: 38 | session = await get_session(request) 39 | session.pop(self._session_key, None) 40 | -------------------------------------------------------------------------------- /demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-security/1d401e323e187fea5d761d92ac177faaaefe29a6/demo/__init__.py -------------------------------------------------------------------------------- /demo/database_auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-security/1d401e323e187fea5d761d92ac177faaaefe29a6/demo/database_auth/__init__.py -------------------------------------------------------------------------------- /demo/database_auth/__main__.py: -------------------------------------------------------------------------------- 1 | from aiohttp.web import run_app 2 | 3 | from .main import init_app 4 | 5 | if __name__ == "__main__": 6 | run_app(init_app()) 7 | -------------------------------------------------------------------------------- /demo/database_auth/db.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 3 | 4 | 5 | class Base(DeclarativeBase): 6 | metadata = sa.MetaData(naming_convention={ 7 | "ix": "ix_%(column_0_label)s", 8 | "uq": "uq_%(table_name)s_%(column_0_name)s", 9 | "ck": "ck_%(table_name)s_%(column_0_name)s", 10 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 11 | "pk": "pk_%(table_name)s" 12 | }) 13 | 14 | 15 | class User(Base): 16 | """A user and their credentials.""" 17 | 18 | __tablename__ = "users" 19 | 20 | id: Mapped[int] = mapped_column(primary_key=True) 21 | username: Mapped[str] = mapped_column(sa.String(256), unique=True, index=True) 22 | password: Mapped[str] = mapped_column(sa.String(256)) 23 | is_superuser: Mapped[bool] = mapped_column( 24 | default=False, server_default=sa.sql.expression.false()) 25 | disabled: Mapped[bool] = mapped_column( 26 | default=False, server_default=sa.sql.expression.false()) 27 | permissions = relationship("Permission", cascade="all, delete") 28 | 29 | 30 | class Permission(Base): 31 | """A permission that grants a user access to something.""" 32 | 33 | __tablename__ = "permissions" 34 | 35 | user_id: Mapped[int] = mapped_column( 36 | sa.ForeignKey(User.id, ondelete="CASCADE"), primary_key=True) 37 | name: Mapped[str] = mapped_column(sa.String(64), primary_key=True) 38 | -------------------------------------------------------------------------------- /demo/database_auth/db_auth.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import sqlalchemy as sa 4 | from passlib.hash import sha256_crypt 5 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 6 | from sqlalchemy.orm import selectinload 7 | 8 | from aiohttp_security.abc import AbstractAuthorizationPolicy 9 | from .db import User 10 | 11 | 12 | def _where_authorized(identity: str) -> tuple[sa.sql.ColumnElement[bool], ...]: 13 | return (User.username == identity, ~User.disabled) 14 | 15 | 16 | class DBAuthorizationPolicy(AbstractAuthorizationPolicy): 17 | def __init__(self, dbsession: async_sessionmaker[AsyncSession]): 18 | self.dbsession = dbsession 19 | 20 | async def authorized_userid(self, identity: str) -> str | None: 21 | where = _where_authorized(identity) 22 | async with self.dbsession() as sess: 23 | user_id = await sess.scalar(sa.select(User.id).where(*where)) 24 | return str(user_id) if user_id else None 25 | 26 | async def permits(self, identity: str | None, permission: str | Enum, 27 | context: dict[str, object] | None = None) -> bool: 28 | if identity is None: 29 | return False 30 | 31 | where = _where_authorized(identity) 32 | stmt = sa.select(User).options(selectinload(User.permissions)).where(*where) 33 | async with self.dbsession() as sess: 34 | user = await sess.scalar(stmt) 35 | 36 | if user is None: 37 | return False 38 | if user.is_superuser: 39 | return True 40 | return any(p.name == permission for p in user.permissions) 41 | 42 | 43 | async def check_credentials(db_session: async_sessionmaker[AsyncSession], 44 | username: str, password: str) -> bool: 45 | where = _where_authorized(username) 46 | async with db_session() as sess: 47 | hashed_pw = await sess.scalar(sa.select(User.password).where(*where)) 48 | 49 | if hashed_pw is None: 50 | return False 51 | 52 | return sha256_crypt.verify(password, hashed_pw) 53 | -------------------------------------------------------------------------------- /demo/database_auth/handlers.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from typing import NoReturn 3 | 4 | from aiohttp import web 5 | 6 | from aiohttp_security import (authorized_userid, check_authorized, check_permission, forget, 7 | remember) 8 | from .db_auth import check_credentials 9 | 10 | 11 | class Web: 12 | index_template = dedent(""" 13 | 14 | 15 | 16 |

{message}

17 |
18 | Login: 19 | 20 | Password: 21 | 22 | 23 |
24 | Logout 25 | 26 | """) 27 | 28 | async def index(self, request: web.Request) -> web.Response: 29 | username = await authorized_userid(request) 30 | if username: 31 | template = self.index_template.format( 32 | message='Hello, {username}!'.format(username=username)) 33 | else: 34 | template = self.index_template.format(message='You need to login') 35 | return web.Response(text=template, content_type="text/html") 36 | 37 | async def login(self, request: web.Request) -> NoReturn: 38 | invalid_resp = web.HTTPUnauthorized(body=b"Invalid username/password combination") 39 | form = await request.post() 40 | login = form.get('login') 41 | password = form.get('password') 42 | 43 | if not (isinstance(login, str) and isinstance(password, str)): 44 | raise invalid_resp 45 | 46 | if await check_credentials(request.app["db_session"], login, password): 47 | response = web.HTTPFound("/") 48 | await remember(request, response, login) 49 | raise response 50 | 51 | raise invalid_resp 52 | 53 | async def logout(self, request: web.Request) -> web.Response: 54 | await check_authorized(request) 55 | response = web.Response(text="You have been logged out") 56 | await forget(request, response) 57 | return response 58 | 59 | async def internal_page(self, request: web.Request) -> web.Response: 60 | await check_permission(request, 'public') 61 | return web.Response(text="This page is visible for all registered users") 62 | 63 | async def protected_page(self, request: web.Request) -> web.Response: 64 | await check_permission(request, 'protected') 65 | return web.Response(text="You are on protected page") 66 | 67 | def configure(self, app: web.Application) -> None: 68 | router = app.router 69 | router.add_route('GET', '/', self.index, name='index') 70 | router.add_route('POST', '/login', self.login, name='login') 71 | router.add_route('GET', '/logout', self.logout, name='logout') 72 | router.add_route('GET', '/public', self.internal_page, name='public') 73 | router.add_route('GET', '/protected', self.protected_page, name='protected') 74 | -------------------------------------------------------------------------------- /demo/database_auth/main.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | from aiohttp_session import SimpleCookieStorage, setup as setup_session 3 | from sqlalchemy.ext.asyncio import (AsyncEngine, AsyncSession, async_sessionmaker, 4 | create_async_engine) 5 | 6 | from aiohttp_security import SessionIdentityPolicy 7 | from aiohttp_security import setup as setup_security 8 | from .db import Base, Permission, User 9 | from .db_auth import DBAuthorizationPolicy 10 | from .handlers import Web 11 | 12 | 13 | async def init_db(db_engine: AsyncEngine, db_session: async_sessionmaker[AsyncSession]) -> None: 14 | """Initialise DB with sample data.""" 15 | async with db_engine.begin() as conn: 16 | await conn.run_sync(Base.metadata.create_all) 17 | async with db_session.begin() as sess: 18 | pw = "$5$rounds=535000$2kqN9fxCY6Xt5/pi$tVnh0xX87g/IsnOSuorZG608CZDFbWIWBr58ay6S4pD" 19 | sess.add(User(username="admin", password=pw, is_superuser=True)) 20 | moderator = User(username="moderator", password=pw) 21 | user = User(username="user", password=pw) 22 | sess.add(moderator) 23 | sess.add(user) 24 | async with db_session.begin() as sess: 25 | sess.add(Permission(user_id=moderator.id, name="protected")) 26 | sess.add(Permission(user_id=moderator.id, name="public")) 27 | sess.add(Permission(user_id=user.id, name="public")) 28 | 29 | 30 | async def init_app() -> web.Application: 31 | app = web.Application() 32 | 33 | db_engine = create_async_engine("sqlite+aiosqlite:///:memory:") 34 | app["db_session"] = async_sessionmaker(db_engine, expire_on_commit=False) 35 | 36 | await init_db(db_engine, app["db_session"]) 37 | 38 | setup_session(app, SimpleCookieStorage()) 39 | setup_security(app, SessionIdentityPolicy(), DBAuthorizationPolicy(app["db_session"])) 40 | 41 | web_handlers = Web() 42 | web_handlers.configure(app) 43 | 44 | return app 45 | 46 | 47 | if __name__ == "__main__": 48 | web.run_app(init_app()) 49 | -------------------------------------------------------------------------------- /demo/dictionary_auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-security/1d401e323e187fea5d761d92ac177faaaefe29a6/demo/dictionary_auth/__init__.py -------------------------------------------------------------------------------- /demo/dictionary_auth/authz.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Mapping, Optional, Union 3 | 4 | from aiohttp_security.abc import AbstractAuthorizationPolicy 5 | from .users import User 6 | 7 | 8 | class DictionaryAuthorizationPolicy(AbstractAuthorizationPolicy): 9 | def __init__(self, user_map: Mapping[Optional[str], User]): 10 | super().__init__() 11 | self.user_map = user_map 12 | 13 | async def authorized_userid(self, identity: str) -> Optional[str]: 14 | """Retrieve authorized user id. 15 | Return the user_id of the user identified by the identity 16 | or 'None' if no user exists related to the identity. 17 | """ 18 | return identity if identity in self.user_map else None 19 | 20 | async def permits(self, identity: Optional[str], permission: Union[str, Enum], 21 | context: None = None) -> bool: 22 | """Check user permissions. 23 | Return True if the identity is allowed the permission in the 24 | current context, else return False. 25 | """ 26 | user = self.user_map.get(identity) 27 | if not user: 28 | return False 29 | return permission in user.permissions 30 | 31 | 32 | async def check_credentials(user_map: Mapping[Optional[str], User], username: str, 33 | password: str) -> bool: 34 | user = user_map.get(username) 35 | if not user: 36 | return False 37 | 38 | return user.password == password 39 | -------------------------------------------------------------------------------- /demo/dictionary_auth/handlers.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from typing import Dict, NoReturn, Optional 3 | 4 | from aiohttp import web 5 | 6 | from aiohttp_security import (authorized_userid, check_authorized, check_permission, forget, 7 | remember) 8 | from .authz import check_credentials 9 | from .users import User 10 | 11 | 12 | index_template = dedent(""" 13 | 14 | 15 | 16 |

{message}

17 |
18 | Login: 19 | 20 | Password: 21 | 22 | 23 |
24 | Logout 25 | 26 | """) 27 | 28 | 29 | async def index(request: web.Request) -> web.Response: 30 | username = await authorized_userid(request) 31 | if username: 32 | template = index_template.format( 33 | message='Hello, {username}!'.format(username=username)) 34 | else: 35 | template = index_template.format(message='You need to login') 36 | return web.Response( 37 | text=template, 38 | content_type='text/html', 39 | ) 40 | 41 | 42 | async def login(request: web.Request) -> NoReturn: 43 | user_map: Dict[Optional[str], User] = request.app["user_map"] 44 | invalid_response = web.HTTPUnauthorized(body="Invalid username / password combination") 45 | form = await request.post() 46 | username = form.get('username') 47 | password = form.get('password') 48 | 49 | if not (isinstance(username, str) and isinstance(password, str)): 50 | raise invalid_response 51 | 52 | verified = await check_credentials(user_map, username, password) 53 | if verified: 54 | response = web.HTTPFound("/") 55 | await remember(request, response, username) 56 | raise response 57 | 58 | raise invalid_response 59 | 60 | 61 | async def logout(request: web.Request) -> web.Response: 62 | await check_authorized(request) 63 | response = web.Response( 64 | text='You have been logged out', 65 | content_type='text/html', 66 | ) 67 | await forget(request, response) 68 | return response 69 | 70 | 71 | async def internal_page(request: web.Request) -> web.Response: 72 | await check_permission(request, 'public') 73 | response = web.Response( 74 | text='This page is visible for all registered users', 75 | content_type='text/html', 76 | ) 77 | return response 78 | 79 | 80 | async def protected_page(request: web.Request) -> web.Response: 81 | await check_permission(request, 'protected') 82 | response = web.Response( 83 | text='You are on protected page', 84 | content_type='text/html', 85 | ) 86 | return response 87 | 88 | 89 | def configure_handlers(app: web.Application) -> None: 90 | router = app.router 91 | router.add_get('/', index, name='index') 92 | router.add_post('/login', login, name='login') 93 | router.add_get('/logout', logout, name='logout') 94 | router.add_get('/public', internal_page, name='public') 95 | router.add_get('/protected', protected_page, name='protected') 96 | -------------------------------------------------------------------------------- /demo/dictionary_auth/main.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from aiohttp import web 4 | from aiohttp_session import setup as setup_session 5 | from aiohttp_session.cookie_storage import EncryptedCookieStorage 6 | from cryptography import fernet 7 | 8 | from aiohttp_security import SessionIdentityPolicy 9 | from aiohttp_security import setup as setup_security 10 | from .authz import DictionaryAuthorizationPolicy 11 | from .handlers import configure_handlers 12 | from .users import user_map 13 | 14 | 15 | def make_app() -> web.Application: 16 | app = web.Application() 17 | app["user_map"] = user_map 18 | configure_handlers(app) 19 | 20 | # secret_key must be 32 url-safe base64-encoded bytes 21 | fernet_key = fernet.Fernet.generate_key() 22 | secret_key = base64.urlsafe_b64decode(fernet_key) 23 | 24 | storage = EncryptedCookieStorage(secret_key, cookie_name='API_SESSION') 25 | setup_session(app, storage) 26 | 27 | policy = SessionIdentityPolicy() 28 | setup_security(app, policy, DictionaryAuthorizationPolicy(user_map)) 29 | 30 | return app 31 | 32 | 33 | if __name__ == '__main__': 34 | web.run_app(make_app(), port=9000) 35 | -------------------------------------------------------------------------------- /demo/dictionary_auth/users.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, NamedTuple, Optional, Tuple 2 | 3 | 4 | class User(NamedTuple): 5 | username: str 6 | password: str 7 | permissions: Tuple[str, ...] 8 | 9 | 10 | user_map: Dict[Optional[str], User] = { 11 | user.username: user for user in [ 12 | User('devin', 'password', ('public',)), 13 | User('jack', 'password', ('public', 'protected',)), 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /demo/simple_example_auth.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import NoReturn, Optional, Union 3 | 4 | from aiohttp import web 5 | from aiohttp_session import SimpleCookieStorage, session_middleware 6 | 7 | from aiohttp_security import (SessionIdentityPolicy, check_permission, forget, 8 | is_anonymous, remember, setup as setup_security) 9 | from aiohttp_security.abc import AbstractAuthorizationPolicy 10 | 11 | 12 | # Demo authorization policy for only one user. 13 | # User 'jack' has only 'listen' permission. 14 | # For more complicated authorization policies see examples 15 | # in the 'demo' directory. 16 | class SimpleJack_AuthorizationPolicy(AbstractAuthorizationPolicy): 17 | async def authorized_userid(self, identity: str) -> Optional[str]: 18 | """Retrieve authorized user id. 19 | Return the user_id of the user identified by the identity 20 | or 'None' if no user exists related to the identity. 21 | """ 22 | return identity if identity == "jack" else None 23 | 24 | async def permits(self, identity: Optional[str], permission: Union[str, Enum], 25 | context: None = None) -> bool: 26 | """Check user permissions. 27 | Return True if the identity is allowed the permission 28 | in the current context, else return False. 29 | """ 30 | return identity == 'jack' and permission in ('listen',) 31 | 32 | 33 | async def handler_root(request: web.Request) -> web.Response: 34 | tmpl = """ 35 | Hello, I'm Jack, I'm {} logged in.

36 | Log me in
37 | Log me out

38 | Check my permissions, when I'm logged in and logged out.
39 | Can I listen?
40 | Can I speak?
41 | """ 42 | is_logged = not await is_anonymous(request) 43 | return web.Response(text=tmpl.format("" if is_logged else "NOT"), content_type="text/html") 44 | 45 | 46 | async def handler_login_jack(request: web.Request) -> NoReturn: 47 | redirect_response = web.HTTPFound('/') 48 | await remember(request, redirect_response, 'jack') 49 | raise redirect_response 50 | 51 | 52 | async def handler_logout(request: web.Request) -> NoReturn: 53 | redirect_response = web.HTTPFound('/') 54 | await forget(request, redirect_response) 55 | raise redirect_response 56 | 57 | 58 | async def handler_listen(request: web.Request) -> web.Response: 59 | await check_permission(request, 'listen') 60 | return web.Response(body="I can listen!") 61 | 62 | 63 | async def handler_speak(request: web.Request) -> web.Response: 64 | await check_permission(request, 'speak') 65 | return web.Response(body="I can speak!") 66 | 67 | 68 | async def make_app() -> web.Application: 69 | # 70 | # WARNING!!! 71 | # Never use SimpleCookieStorage on production!!! 72 | # It’s highly insecure!!! 73 | # 74 | 75 | # make app 76 | middleware = session_middleware(SimpleCookieStorage()) 77 | app = web.Application(middlewares=[middleware]) 78 | 79 | # add the routes 80 | app.router.add_route('GET', '/', handler_root) 81 | app.router.add_route('GET', '/login', handler_login_jack) 82 | app.router.add_route('GET', '/logout', handler_logout) 83 | app.router.add_route('GET', '/listen', handler_listen) 84 | app.router.add_route('GET', '/speak', handler_speak) 85 | 86 | # set up policies 87 | policy = SessionIdentityPolicy() 88 | setup_security(app, policy, SimpleJack_AuthorizationPolicy()) 89 | 90 | return app 91 | 92 | 93 | if __name__ == '__main__': 94 | web.run_app(make_app(), port=9000) 95 | -------------------------------------------------------------------------------- /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/aiohttp_security.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/aiohttp_security.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/aiohttp_security" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/aiohttp_security" 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/_static/aiohttp-icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-security/1d401e323e187fea5d761d92ac177faaaefe29a6/docs/_static/aiohttp-icon-128x128.png -------------------------------------------------------------------------------- /docs/aiohttp-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-security/1d401e323e187fea5d761d92ac177faaaefe29a6/docs/aiohttp-icon.ico -------------------------------------------------------------------------------- /docs/aiohttp_doctools.py: -------------------------------------------------------------------------------- 1 | from sphinx import addnodes 2 | from sphinx.domains.python import PyClassmember, PyModulelevel 3 | 4 | 5 | class PyCoroutineMixin(object): 6 | def handle_signature(self, sig, signode): 7 | ret = super(PyCoroutineMixin, self).handle_signature(sig, signode) 8 | signode.insert(0, addnodes.desc_annotation('coroutine ', 'coroutine ')) 9 | return ret 10 | 11 | 12 | class PyCoroutineFunction(PyCoroutineMixin, PyModulelevel): 13 | def run(self): 14 | self.name = 'py:function' 15 | return PyModulelevel.run(self) 16 | 17 | 18 | class PyCoroutineMethod(PyCoroutineMixin, PyClassmember): 19 | def run(self): 20 | self.name = 'py:method' 21 | return PyClassmember.run(self) 22 | 23 | 24 | def setup(app): 25 | app.add_directive_to_domain('py', 'coroutinefunction', PyCoroutineFunction) 26 | app.add_directive_to_domain('py', 'coroutinemethod', PyCoroutineMethod) 27 | return {'version': '1.0', 'parallel_read_safe': True} 28 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # aiohttp_security documentation build configuration file, created by 5 | # sphinx-quickstart on Tue Apr 14 11:54:09 2015. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import codecs 17 | import os 18 | import re 19 | import sys 20 | 21 | _docs_path = os.path.dirname(__file__) 22 | _version_path = os.path.abspath(os.path.join(_docs_path, 23 | '..', 24 | 'aiohttp_security', 25 | '__init__.py')) 26 | with codecs.open(_version_path, 'r', 'latin1') as fp: 27 | try: 28 | _version_info = re.search(r"^__version__ = '" 29 | r"(?P\d+)" 30 | r"\.(?P\d+)" 31 | r"\.(?P\d+)" 32 | r"(?P.*)?'$", 33 | fp.read(), re.M).groupdict() 34 | except IndexError: 35 | raise RuntimeError('Unable to determine version.') 36 | 37 | 38 | # If extensions (or modules to document with autodoc) are in another directory, 39 | # add these directories to sys.path here. If the directory is relative to the 40 | # documentation root, use os.path.abspath to make it absolute, like shown here. 41 | sys.path.insert(0, os.path.abspath('..')) 42 | sys.path.insert(0, os.path.abspath('.')) 43 | 44 | # -- General configuration ------------------------------------------------ 45 | 46 | # If your documentation needs a minimal Sphinx version, state it here. 47 | # needs_sphinx = '1.0' 48 | 49 | # Add any Sphinx extension module names here, as strings. They can be 50 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 51 | # ones. 52 | extensions = [ 53 | 'sphinx.ext.intersphinx', 54 | 'sphinx.ext.viewcode', 55 | 'alabaster', 56 | ] 57 | 58 | # Add any paths that contain templates here, relative to this directory. 59 | templates_path = ['_templates'] 60 | 61 | # The suffix(es) of source filenames. 62 | # You can specify multiple suffix as a list of string: 63 | # source_suffix = ['.rst', '.md'] 64 | source_suffix = '.rst' 65 | 66 | # The encoding of source files. 67 | # source_encoding = 'utf-8-sig' 68 | 69 | # The master toctree document. 70 | master_doc = 'index' 71 | 72 | # General information about the project. 73 | project = 'aiohttp_security' 74 | copyright = '2015-2016 Andrew Svetlov' 75 | author = 'Andrew Svetlov' 76 | 77 | # The version info for the project you're documenting, acts as replacement for 78 | # |version| and |release|, also used in various other places throughout the 79 | # built documents. 80 | # 81 | # The short X.Y version. 82 | version = '{major}.{minor}'.format(**_version_info) 83 | # The full version, including alpha/beta/rc tags. 84 | release = '{major}.{minor}.{patch}-{tag}'.format(**_version_info) 85 | 86 | # The language for content autogenerated by Sphinx. Refer to documentation 87 | # for a list of supported languages. 88 | # 89 | # This is also used if you do content translation via gettext catalogs. 90 | # Usually you set "language" from the command line for these cases. 91 | language = None 92 | 93 | # There are two options for replacing |today|: either, you set today to some 94 | # non-false value, then it is used: 95 | # today = '' 96 | # Else, today_fmt is used as the format for a strftime call. 97 | # today_fmt = '%B %d, %Y' 98 | 99 | # List of patterns, relative to source directory, that match files and 100 | # directories to ignore when looking for source files. 101 | exclude_patterns = ['_build'] 102 | 103 | # The reST default role (used for this markup: `text`) to use for all 104 | # documents. 105 | # default_role = None 106 | 107 | # If true, '()' will be appended to :func: etc. cross-reference text. 108 | # add_function_parentheses = True 109 | 110 | # If true, the current module name will be prepended to all description 111 | # unit titles (such as .. function::). 112 | # add_module_names = True 113 | 114 | # If true, sectionauthor and moduleauthor directives will be shown in the 115 | # output. They are ignored by default. 116 | # show_authors = False 117 | 118 | # The name of the Pygments (syntax highlighting) style to use. 119 | pygments_style = 'sphinx' 120 | 121 | # A list of ignored prefixes for module index sorting. 122 | # modindex_common_prefix = [] 123 | 124 | # If true, keep warnings as "system message" paragraphs in the built documents. 125 | # keep_warnings = False 126 | 127 | # If true, `todo` and `todoList` produce output, else they produce nothing. 128 | todo_include_todos = False 129 | 130 | 131 | # -- Options for HTML output ---------------------------------------------- 132 | 133 | # The theme to use for HTML and HTML Help pages. See the documentation for 134 | # a list of builtin themes. 135 | html_theme = 'alabaster' 136 | 137 | # Theme options are theme-specific and customize the look and feel of a theme 138 | # further. For a list of options available for each theme, see the 139 | # documentation. 140 | html_theme_options = { 141 | 'logo': 'aiohttp-icon-128x128.png', 142 | 'description': 'Authorization and identity for aoihttp', 143 | 'github_user': 'aio-libs', 144 | 'github_repo': 'aiohttp-security', 145 | 'github_button': True, 146 | 'github_type': 'star', 147 | 'github_banner': True, 148 | 'travis_button': True, 149 | 'codecov_button': True, 150 | 'pre_bg': '#FFF6E5', 151 | 'note_bg': '#E5ECD1', 152 | 'note_border': '#BFCF8C', 153 | 'body_text': '#482C0A', 154 | 'sidebar_text': '#49443E', 155 | 'sidebar_header': '#4B4032', 156 | } 157 | 158 | # Add any paths that contain custom themes here, relative to this directory. 159 | # html_theme_path = [alabaster.get_path()] 160 | 161 | # The name for this set of Sphinx documents. If None, it defaults to 162 | # " v documentation". 163 | # html_title = None 164 | 165 | # A shorter title for the navigation bar. Default is the same as html_title. 166 | # html_short_title = None 167 | 168 | # The name of an image file (relative to this directory) to place at the top 169 | # of the sidebar. 170 | # html_logo = None 171 | 172 | # The name of an image file (within the static path) to use as favicon of the 173 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 174 | # pixels large. 175 | html_favicon = 'aiohttp-icon.ico' 176 | 177 | # Add any paths that contain custom static files (such as style sheets) here, 178 | # relative to this directory. They are copied after the builtin static files, 179 | # so a file named "default.css" will overwrite the builtin "default.css". 180 | html_static_path = ['_static'] 181 | 182 | # Add any extra paths that contain custom files (such as robots.txt or 183 | # .htaccess) here, relative to this directory. These files are copied 184 | # directly to the root of the documentation. 185 | # html_extra_path = [] 186 | 187 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 188 | # using the given strftime format. 189 | # html_last_updated_fmt = '%b %d, %Y' 190 | 191 | # If true, SmartyPants will be used to convert quotes and dashes to 192 | # typographically correct entities. 193 | # html_use_smartypants = True 194 | 195 | # Custom sidebar templates, maps document names to template names. 196 | html_sidebars = { 197 | '**': [ 198 | 'about.html', 'navigation.html', 'searchbox.html', 199 | ] 200 | } 201 | 202 | # Additional templates that should be rendered to pages, maps page names to 203 | # template names. 204 | # html_additional_pages = {} 205 | 206 | # If false, no module index is generated. 207 | # html_domain_indices = True 208 | 209 | # If false, no index is generated. 210 | # html_use_index = True 211 | 212 | # If true, the index is split into individual pages for each letter. 213 | # html_split_index = False 214 | 215 | # If true, links to the reST sources are added to the pages. 216 | # html_show_sourcelink = True 217 | 218 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 219 | # html_show_sphinx = True 220 | 221 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 222 | # html_show_copyright = True 223 | 224 | # If true, an OpenSearch description file will be output, and all pages will 225 | # contain a tag referring to it. The value of this option must be the 226 | # base URL from which the finished HTML is served. 227 | # html_use_opensearch = '' 228 | 229 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 230 | # html_file_suffix = None 231 | 232 | # Language to be used for generating the HTML full-text search index. 233 | # Sphinx supports the following languages: 234 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 235 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 236 | # html_search_language = 'en' 237 | 238 | # A dictionary with options for the search language support, empty by default. 239 | # Now only 'ja' uses this config value 240 | # html_search_options = {'type': 'default'} 241 | 242 | # The name of a javascript file (relative to the configuration directory) that 243 | # implements a search results scorer. If empty, the default will be used. 244 | # html_search_scorer = 'scorer.js' 245 | 246 | # Output file base name for HTML help builder. 247 | htmlhelp_basename = 'aiohttp_securitydoc' 248 | 249 | # -- Options for LaTeX output --------------------------------------------- 250 | 251 | # The paper size ('letterpaper' or 'a4paper'). 252 | # 'papersize': 'letterpaper', 253 | 254 | # The font size ('10pt', '11pt' or '12pt'). 255 | # 'pointsize': '10pt', 256 | 257 | # Additional stuff for the LaTeX preamble. 258 | # 'preamble': '', 259 | 260 | # Latex figure (float) alignment 261 | # 'figure_align': 'htbp', 262 | latex_elements = { 263 | } 264 | 265 | # Grouping the document tree into LaTeX files. List of tuples 266 | # (source start file, target name, title, 267 | # author, documentclass [howto, manual, or own class]). 268 | latex_documents = [ 269 | (master_doc, 'aiohttp_security.tex', 'aiohttp\\_security Documentation', 270 | 'Andrew Svetlov', 'manual'), 271 | ] 272 | 273 | # The name of an image file (relative to this directory) to place at the top of 274 | # the title page. 275 | # latex_logo = None 276 | 277 | # For "manual" documents, if this is true, then toplevel headings are parts, 278 | # not chapters. 279 | # latex_use_parts = False 280 | 281 | # If true, show page references after internal links. 282 | # latex_show_pagerefs = False 283 | 284 | # If true, show URL addresses after external links. 285 | # latex_show_urls = False 286 | 287 | # Documents to append as an appendix to all manuals. 288 | # latex_appendices = [] 289 | 290 | # If false, no module index is generated. 291 | # latex_domain_indices = True 292 | 293 | 294 | # -- Options for manual page output --------------------------------------- 295 | 296 | # One entry per manual page. List of tuples 297 | # (source start file, name, description, authors, manual section). 298 | man_pages = [ 299 | (master_doc, 'aiohttp_security', 'aiohttp_security Documentation', 300 | [author], 1) 301 | ] 302 | 303 | # If true, show URL addresses after external links. 304 | # man_show_urls = False 305 | 306 | 307 | # -- Options for Texinfo output ------------------------------------------- 308 | 309 | # Grouping the document tree into Texinfo files. List of tuples 310 | # (source start file, target name, title, author, 311 | # dir menu entry, description, category) 312 | texinfo_documents = [ 313 | (master_doc, 'aiohttp_security', 'aiohttp_security Documentation', 314 | author, 'aiohttp_security', 'One line description of project.', 315 | 'Miscellaneous'), 316 | ] 317 | 318 | # Documents to append as an appendix to all manuals. 319 | # texinfo_appendices = [] 320 | 321 | # If false, no module index is generated. 322 | # texinfo_domain_indices = True 323 | 324 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 325 | # texinfo_show_urls = 'footnote' 326 | 327 | # If true, do not generate a @detailmenu in the "Top" node's menu. 328 | # texinfo_no_detailmenu = False 329 | 330 | 331 | # Example configuration for intersphinx: refer to the Python standard library. 332 | intersphinx_mapping = { 333 | 'python': ('https://docs.python.org/3', None), 334 | 'aiohttp': ('http://aiohttp.readthedocs.org/en/stable', None), 335 | } 336 | -------------------------------------------------------------------------------- /docs/example.rst: -------------------------------------------------------------------------------- 1 | .. _aiohttp-security-example: 2 | 3 | =============================================== 4 | How to Make a Simple Server With Authorization 5 | =============================================== 6 | 7 | 8 | Simple example:: 9 | 10 | from aiohttp import web 11 | from aiohttp_session import SimpleCookieStorage, session_middleware 12 | from aiohttp_security import check_permission, \ 13 | is_anonymous, remember, forget, \ 14 | setup as setup_security, SessionIdentityPolicy 15 | from aiohttp_security.abc import AbstractAuthorizationPolicy 16 | 17 | 18 | # Demo authorization policy for only one user. 19 | # User 'jack' has only 'listen' permission. 20 | # For more complicated authorization policies see examples 21 | # in the 'demo' directory. 22 | class SimpleJack_AuthorizationPolicy(AbstractAuthorizationPolicy): 23 | async def authorized_userid(self, identity): 24 | """Retrieve authorized user id. 25 | Return the user_id of the user identified by the identity 26 | or 'None' if no user exists related to the identity. 27 | """ 28 | if identity == 'jack': 29 | return identity 30 | 31 | async def permits(self, identity, permission, context=None): 32 | """Check user permissions. 33 | Return True if the identity is allowed the permission 34 | in the current context, else return False. 35 | """ 36 | return identity == 'jack' and permission in ('listen',) 37 | 38 | 39 | async def handler_root(request): 40 | is_logged = not await is_anonymous(request) 41 | return web.Response(text=''' 42 | Hello, I'm Jack, I'm {logged} logged in.

43 | Log me in
44 | Log me out

45 | Check my permissions, 46 | when i'm logged in and logged out.
47 | Can I listen?
48 | Can I speak?
49 | '''.format( 50 | logged='' if is_logged else 'NOT', 51 | ), content_type='text/html') 52 | 53 | 54 | async def handler_login_jack(request): 55 | redirect_response = web.HTTPFound('/') 56 | await remember(request, redirect_response, 'jack') 57 | raise redirect_response 58 | 59 | 60 | async def handler_logout(request): 61 | redirect_response = web.HTTPFound('/') 62 | await forget(request, redirect_response) 63 | raise redirect_response 64 | 65 | 66 | async def handler_listen(request): 67 | await check_permission(request, 'listen') 68 | return web.Response(body="I can listen!") 69 | 70 | 71 | async def handler_speak(request): 72 | await check_permission(request, 'speak') 73 | return web.Response(body="I can speak!") 74 | 75 | 76 | async def make_app(): 77 | # 78 | # WARNING!!! 79 | # Never use SimpleCookieStorage on production!!! 80 | # It’s highly insecure!!! 81 | # 82 | 83 | # make app 84 | middleware = session_middleware(SimpleCookieStorage()) 85 | app = web.Application(middlewares=[middleware]) 86 | 87 | # add the routes 88 | app.add_routes([ 89 | web.get('/', handler_root), 90 | web.get('/login', handler_login_jack), 91 | web.get('/logout', handler_logout), 92 | web.get('/listen', handler_listen), 93 | web.get('/speak', handler_speak)]) 94 | 95 | # set up policies 96 | policy = SessionIdentityPolicy() 97 | setup_security(app, policy, SimpleJack_AuthorizationPolicy()) 98 | 99 | return app 100 | 101 | 102 | if __name__ == '__main__': 103 | web.run_app(make_app(), port=9000) 104 | -------------------------------------------------------------------------------- /docs/example_db_auth.rst: -------------------------------------------------------------------------------- 1 | .. _aiohttp-security-example-db-auth: 2 | 3 | ======================================= 4 | Permissions with database-based storage 5 | ======================================= 6 | 7 | We use :class:`~aiohttp_session.SimpleCookieStorage` and an in-memory SQLite DB to 8 | make it easy to try out the demo. When developing an application, you should use 9 | :class:`~aiohttp_session.cookie_storage.EncryptedCookieStorage` or 10 | :class:`~aiohttp_session.redis_storage.RedisStorage` and a production-ready database. 11 | If you want the full source code in advance or for comparison, check out 12 | the `demo source`_. 13 | 14 | .. _demo source: 15 | https://github.com/aio-libs/aiohttp_security/tree/master/demo 16 | 17 | .. _passlib: 18 | https://passlib.readthedocs.io 19 | 20 | Database 21 | -------- 22 | 23 | When the application runs, we initialise the DB with sample data using SQLAlchemy 24 | ORM: 25 | 26 | .. literalinclude:: ../demo/database_auth/main.py 27 | :pyobject: init_db 28 | 29 | 30 | This will consist of 2 tables/models created in ``db.py``: 31 | 32 | Users: 33 | 34 | .. literalinclude:: ../demo/database_auth/db.py 35 | :pyobject: User 36 | 37 | And their permissions: 38 | 39 | .. literalinclude:: ../demo/database_auth/db.py 40 | :pyobject: Permission 41 | 42 | 43 | Writing policies 44 | ---------------- 45 | 46 | You need to implement two entities: 47 | :class:`IdentityPolicy` and 48 | :class:`AuthorizationPolicy`. 49 | First one should have these methods: 50 | :class:`~aiohttp_security.AbstractIdentityPolicy.identify`, 51 | :class:`~aiohttp_security.AbstractIdentityPolicy.remember` and 52 | :class:`~aiohttp_security.AbstractIdentityPolicy.forget`. 53 | For the second one: 54 | :class:`~aiohttp_security.AbstractAuthorizationPolicy.authorized_userid` and 55 | :class:`~aiohttp_security.AbstractAuthorizationPolicy.permits`. We will use the 56 | included :class:`~aiohttp_security.SessionIdentityPolicy` and write our own 57 | database-based authorization policy. 58 | 59 | In our example we will lookup a user login in the database and, if present, return 60 | the identity. 61 | 62 | .. literalinclude:: ../demo/database_auth/db_auth.py 63 | :pyobject: DBAuthorizationPolicy.authorized_userid 64 | 65 | 66 | For permission checking, we will fetch the user first, check if he is superuser 67 | (all permissions are allowed), otherwise check if the permission is explicitly set 68 | for that user. 69 | 70 | .. literalinclude:: ../demo/database_auth/db_auth.py 71 | :pyobject: DBAuthorizationPolicy.permits 72 | 73 | 74 | Setup 75 | ----- 76 | 77 | Once we have all the code in place we can install it for our application: 78 | 79 | .. literalinclude:: ../demo/database_auth/main.py 80 | :pyobject: init_app 81 | 82 | Now we have authorization and can decorate every other view with access rights 83 | based on permissions. There are two helpers included for this:: 84 | 85 | from aiohttp_security import check_authorized, check_permission 86 | 87 | For each view you need to protect - just apply the decorator on it. 88 | 89 | .. literalinclude:: ../demo/database_auth/handlers.py 90 | :pyobject: Web.protected_page 91 | 92 | or 93 | 94 | .. literalinclude:: ../demo/database_auth/handlers.py 95 | :pyobject: Web.logout 96 | 97 | If someone tries to access that protected page he will see:: 98 | 99 | 403: Forbidden 100 | 101 | 102 | The best part of it - you can implement any logic you want following the API conventions. 103 | 104 | Launch application 105 | ------------------ 106 | 107 | For working with passwords there is a good library passlib_. Once you've 108 | created some users you want to check their credentials on login. A similar 109 | function may do what you are trying to accomplish:: 110 | 111 | from passlib.hash import sha256_crypt 112 | 113 | .. literalinclude:: ../demo/database_auth/db_auth.py 114 | :pyobject: check_credentials 115 | 116 | 117 | Final step is to launch your application:: 118 | 119 | python -m database_auth 120 | 121 | 122 | Try to login with admin/moderator/user accounts (with **password** password) 123 | and access **/public** or **/protected** endpoints. 124 | -------------------------------------------------------------------------------- /docs/glossary.rst: -------------------------------------------------------------------------------- 1 | .. _aiohttp-security-glossary: 2 | 3 | ========== 4 | Glossary 5 | ========== 6 | 7 | .. if you add new entries, keep the alphabetical sorting! 8 | 9 | .. glossary:: 10 | 11 | 12 | aiohttp 13 | 14 | :term:`asyncio` based library for making web servers. 15 | 16 | 17 | asyncio 18 | 19 | The library for writing single-threaded concurrent code using 20 | coroutines, multiplexing I/O access over sockets and other 21 | resources, running network clients and servers, and other 22 | related primitives. 23 | 24 | Reference implementation of :pep:`3156` 25 | 26 | https://pypi.python.org/pypi/asyncio/ 27 | 28 | authentication 29 | 30 | Actions related to retrieving, storing and removing user's 31 | :term:`identity`. 32 | 33 | Authenticated user has no access rights, the system even has no 34 | knowledge is there the user still registered in DB. 35 | 36 | If :class:`~aiohttp.web.Request` has an :term:`identity` it 37 | means the user has some ID that should be checked by 38 | :term:`authorization` policy. 39 | 40 | authorization 41 | 42 | Checking actual permissions for identified user along with 43 | getting :term:`userid`. 44 | 45 | identity 46 | 47 | Session-wide :class:`str` for identifying user. 48 | 49 | Stored in local storage (client-side cookie or server-side storage). 50 | 51 | Use :meth:`~aiohttp_session.remember` for saving *identity* (sign in) 52 | and :meth:`~aiohttp_session.forget` for dropping it (sign out). 53 | 54 | *identity* is used for getting :term:`userid` and :term:`permission`. 55 | 56 | permission 57 | 58 | Permission required for access to resource. 59 | 60 | Permissions are just strings, and they have no required 61 | composition: you can name permissions whatever you like. 62 | 63 | userid 64 | 65 | User's ID, most likely his *login* or *email* 66 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | aiohttp_security 2 | ================ 3 | 4 | The library provides security for :ref:`aiohttp.web`. 5 | 6 | The current version is |version| 7 | 8 | Contents 9 | -------- 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | usage 15 | reference 16 | example 17 | example_db_auth 18 | glossary 19 | 20 | License 21 | ------- 22 | 23 | ``aiohttp_security`` is offered under the Apache 2 license. 24 | 25 | Indices and tables 26 | ================== 27 | 28 | * :ref:`genindex` 29 | * :ref:`modindex` 30 | * :ref:`search` 31 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 2> nul 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\aiohttp_security.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\aiohttp_security.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | .. _aiohttp-security-reference: 2 | 3 | 4 | =========== 5 | Reference 6 | =========== 7 | 8 | .. module:: aiohttp_security 9 | .. currentmodule:: aiohttp_security 10 | .. highlight:: python 11 | 12 | 13 | Public API functions 14 | ==================== 15 | 16 | .. function:: setup(app, identity_policy, autz_policy) 17 | 18 | Setup :mod:`aiohttp` application with security policies. 19 | 20 | :param app: aiohttp :class:`aiohttp.web.Application` instance. 21 | 22 | :param identity_policy: indentification policy, an 23 | :class:`AbstractIdentityPolicy` instance. 24 | 25 | :param autz_policy: authorization policy, an 26 | :class:`AbstractAuthorizationPolicy` instance. 27 | 28 | 29 | .. coroutinefunction:: remember(request, response, identity, **kwargs) 30 | 31 | Remember *identity* in *response*, e.g. by storing a cookie or 32 | saving info into session. 33 | 34 | The action is performed by registered 35 | :meth:`AbstractIdentityPolicy.remember`. 36 | 37 | Usually the *identity* is stored in user cookies somehow for using by 38 | :func:`authorized_userid` and :func:`permits`. 39 | 40 | :param request: :class:`aiohttp.web.Request` object. 41 | 42 | :param response: :class:`aiohttp.web.StreamResponse` and 43 | descendants like :class:`aiohttp.web.Response`. 44 | 45 | :param str identity: :class:`aiohttp.web.Request` object. 46 | 47 | :param kwargs: additional arguments passed to 48 | :meth:`AbstractIdentityPolicy.remember`. 49 | 50 | They are policy-specific and may be used, e.g. for 51 | specifiying cookie lifetime. 52 | 53 | .. coroutinefunction:: forget(request, response) 54 | 55 | Forget previously remembered :term:`identity`. 56 | 57 | The action is performed by registered 58 | :meth:`AbstractIdentityPolicy.forget`. 59 | 60 | :param request: :class:`aiohttp.web.Request` object. 61 | 62 | :param response: :class:`aiohttp.web.StreamResponse` and 63 | descendants like :class:`aiohttp.web.Response`. 64 | 65 | 66 | .. coroutinefunction:: check_authorized(request) 67 | 68 | Checker that doesn't pass if user is not authorized by *request*. 69 | 70 | :param request: :class:`aiohttp.web.Request` object. 71 | 72 | :return str: authorized user ID if success 73 | 74 | :raise: :class:`aiohttp.web.HTTPUnauthorized` for anonymous users. 75 | 76 | Usage:: 77 | 78 | async def handler(request): 79 | await check_authorized(request) 80 | # this line is never executed for anonymous users 81 | 82 | 83 | .. coroutinefunction:: check_permission(request, permission) 84 | 85 | Checker that doesn't pass if user has no requested permission. 86 | 87 | :param request: :class:`aiohttp.web.Request` object. 88 | 89 | :raise: :class:`aiohttp.web.HTTPUnauthorized` for anonymous users. 90 | 91 | :raise: :class:`aiohttp.web.HTTPForbidden` if user is 92 | authorized but has no access rights. 93 | 94 | Usage:: 95 | 96 | async def handler(request): 97 | await check_permission(request, 'read') 98 | # this line is never executed if a user has no read permission 99 | 100 | 101 | .. coroutinefunction:: authorized_userid(request) 102 | 103 | Retrieve :term:`userid`. 104 | 105 | The user should be registered by :func:`remember` before the call. 106 | 107 | :param request: :class:`aiohttp.web.Request` object. 108 | 109 | :return: :class:`str` :term:`userid` or ``None`` for session 110 | without signed in user. 111 | 112 | 113 | .. coroutinefunction:: permits(request, permission, context=None) 114 | 115 | Check user's permission. 116 | 117 | Return ``True`` if user remembered in *request* has specified *permission*. 118 | 119 | Allowed permissions as well as *context* meaning are depends on 120 | :class:`AbstractAuthorizationPolicy` implementation. 121 | 122 | Actually it's a wrapper around 123 | :meth:`AbstractAuthorizationPolicy.permits` coroutine. 124 | 125 | The user should be registered by :func:`remember` before the call. 126 | 127 | :param request: :class:`aiohttp.web.Request` object. 128 | 129 | :param permission: Requested :term:`permission`. :class:`str` or 130 | :class:`enum.Enum` object. 131 | 132 | :param context: additional object may be passed into 133 | :meth:`AbstractAuthorizationPolicy.permission` 134 | coroutine. 135 | 136 | :return: ``True`` if registered user has requested *permission*, 137 | ``False`` otherwise. 138 | 139 | 140 | .. coroutinefunction:: is_anonymous(request) 141 | 142 | Checks if user is anonymous user. 143 | 144 | Return ``True`` if user is not remembered in request, otherwise 145 | returns ``False``. 146 | 147 | :param request: :class:`aiohttp.web.Request` object. 148 | 149 | 150 | Abstract policies 151 | ================= 152 | 153 | *aiohttp_security* is built on top of two *abstract policies* -- 154 | :class:`AbstractIdentityPolicy` and 155 | :class:`AbstractAuthorizationPolicy`. 156 | 157 | The first one responds on remembering, retrieving and forgetting 158 | :term:`identity` into some session storage, e.g. HTTP cookie or 159 | authorization token. 160 | 161 | The second is responsible to return persistent :term:`userid` for 162 | session-wide :term:`identity` and check user's permissions. 163 | 164 | Most likely sofware developer reuses one of pre-implemented *identity 165 | policies* from *aiohttp_security* but build *authorization policy* 166 | from scratch for every application/project. 167 | 168 | 169 | Identification policy 170 | --------------------- 171 | 172 | .. class:: AbstractIdentityPolicy 173 | 174 | .. coroutinemethod:: identify(request) 175 | 176 | Extract :term:`identity` from *request*. 177 | 178 | Abstract method, should be overriden by descendant. 179 | 180 | :param request: :class:`aiohttp.web.Request` object. 181 | 182 | :return: the claimed identity of the user associated request or 183 | ``None`` if no identity can be found associated with 184 | the request. 185 | 186 | .. coroutinemethod:: remember(request, response, identity, **kwargs) 187 | 188 | Remember *identity*. 189 | 190 | May use *request* for accessing required data and *response* for 191 | storing *identity* (e.g. updating HTTP response cookies). 192 | 193 | *kwargs* may be used by concrete implementation for passing 194 | additional data. 195 | 196 | Abstract method, should be overriden by descendant. 197 | 198 | :param request: :class:`aiohttp.web.Request` object. 199 | 200 | :param response: :class:`aiohttp.web.StreamResponse` object or 201 | derivative. 202 | 203 | :param identity: :term:`identity` to store. 204 | 205 | :param kwargs: optional additional arguments. An individual 206 | identity policy and its consumers can decide on 207 | the composition and meaning of the parameter. 208 | 209 | 210 | .. coroutinemethod:: forget(request, response) 211 | 212 | Forget previously stored :term:`identity`. 213 | 214 | May use *request* for accessing required data and *response* for 215 | dropping *identity* (e.g. updating HTTP response cookies). 216 | 217 | Abstract method, should be overriden by descendant. 218 | 219 | :param request: :class:`aiohttp.web.Request` object. 220 | 221 | :param response: :class:`aiohttp.web.StreamResponse` object or 222 | derivative. 223 | 224 | 225 | Authorization policy 226 | --------------------- 227 | 228 | .. class:: AbstractAuthorizationPolicy 229 | 230 | .. coroutinemethod:: authorized_userid(identity) 231 | 232 | Retrieve authorized user id. 233 | 234 | Abstract method, should be overriden by descendant. 235 | 236 | :param identity: an :term:`identity` used for authorization. 237 | 238 | :return: the :term:`userid` of the user identified by the 239 | *identity* or ``None`` if no user exists related to the 240 | identity. 241 | 242 | .. coroutinemethod:: permits(identity, permission, context=None) 243 | 244 | Check user permissions. 245 | 246 | Abstract method, should be overriden by descendant. 247 | 248 | :param identity: an :term:`identity` used for authorization. 249 | 250 | :param permission: requested permission. The type of parameter 251 | is not fixed and depends on implementation. 252 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. _aiohttp-security-usage: 2 | 3 | 4 | ======= 5 | Usage 6 | ======= 7 | 8 | .. currentmodule:: aiohttp_security 9 | .. highlight:: python 10 | 11 | 12 | First of all, what is *aiohttp_security* about? 13 | 14 | *aiohttp-security* is a set of public API functions as well as a 15 | reference standard for implementation details for securing access to 16 | assets served by a wsgi server. 17 | 18 | Assets are secured using authentication and authorization as explained 19 | below. *aiohttp-security* is part of the 20 | `aio-libs `_ project which takes advantage 21 | of asynchronous processing using Python's asyncio library. 22 | 23 | 24 | Public API 25 | ========== 26 | 27 | The API is agnostic to the low level implementation details such that 28 | all client code only needs to implement the endpoints as provided by 29 | the API (instead of calling policy code directly (see explanation 30 | below)). 31 | 32 | Via the API an application can: 33 | 34 | (i) remember a user in a local session (:func:`remember`), 35 | (ii) forget a user in a local session (:func:`forget`), 36 | (iii) retrieve the :term:`userid` (:func:`authorized_userid`) of a 37 | remembered user from an :term:`identity` (discussed below), and 38 | (iv) check the :term:`permission` of a remembered user (:func:`permits`). 39 | 40 | The library internals are built on top of two concepts: 41 | 42 | 1) :term:`authentication`, and 43 | 2) :term:`authorization`. 44 | 45 | There are abstract base classes for both types as well as several 46 | pre-built implementations that are shipped with the library. However, 47 | the end user is free to build their own implementations. 48 | 49 | The library comes with two pre-built identity policies; one that uses 50 | cookies, and one that uses sessions [#f1]_. It is envisioned that in 51 | most use cases developers will use one of the provided identity 52 | policies (Cookie or Session) and implement their own authorization 53 | policy. 54 | 55 | The workflow is as follows: 56 | 57 | 1) User is authenticated. This has to be implemented by the developer. 58 | 2) Once user is authenticated an identity string has to be created for 59 | that user. This has to be implemented by the developer. 60 | 3) The identity string is passed to the Identity Policy's remember 61 | method and the user is now remembered (Cookie or Session if using 62 | built-in). *Only once a user is remembered can the other API 63 | methods:* :func:`permits`, :func:`forget`, *and* 64 | :func:`authorized_userid` *be invoked* . 65 | 4) If the user tries to access a restricted asset the :func:`permits` 66 | method is called. Usually assets are protected using the 67 | :func:`check_permission` helper. This should return True if 68 | permission is granted. 69 | 70 | The :func:`permits` method is implemented by the developer as part of 71 | the :class:`AbstractAuthorizationPolicy` and passed to the 72 | application at runtime via setup. 73 | 74 | In addition a :func:`check_authorized` also 75 | exists that requires no permissions (i.e. doesn't call :func:`permits` 76 | method) but only requires that the user is remembered 77 | (i.e. authenticated/logged in). 78 | 79 | 80 | 81 | 82 | Authentication 83 | ============== 84 | 85 | Authentication is the process where a user's identity is verified. It 86 | confirms who the user is. This is traditionally done using a user name 87 | and password (note: this is not the only way). 88 | 89 | A authenticated user has no access rights, rather an authenticated 90 | user merely confirms that the user exists and that the user is who 91 | they say they are. 92 | 93 | In *aiohttp_security* the developer is responsible for their own 94 | authentication mechanism. *aiohttp_security* only requires that the 95 | authentication result in a identity string which corresponds to a 96 | user's id in the underlying system. 97 | 98 | .. note:: 99 | 100 | :term:`identity` is a string that is shared between the browser and 101 | the server. Therefore it is recommended that a random string 102 | such as a uuid or hash is used rather than things like a 103 | database primary key, user login/email, etc. 104 | 105 | Identity Policy 106 | =============== 107 | 108 | Once a user is authenticated the *aiohttp_security* API is invoked for 109 | storing, retrieving, and removing a user's :term:`identity`. This is 110 | accommplished via AbstractIdentityPolicy's :func:`remember`, 111 | :func:`identify`, and :func:`forget` methods. The Identity Policy is 112 | therefore the mechanism by which a authenticated user is persisted in 113 | the system. 114 | 115 | *aiohttp_security* has two built in identity policy's for this 116 | purpose. :class:`CookiesIdentityPolicy` that uses cookies and 117 | :class:`SessionIdentityPolicy` that uses sessions via 118 | ``aiohttp-session`` library. 119 | 120 | Authorization 121 | ============== 122 | 123 | Once a user is authenticated (see above) it means that the user has an 124 | :term:`identity`. This :term:`identity` can now be used for checking 125 | access rights or :term:`permission` using a :term:`authorization` 126 | policy. 127 | 128 | The authorization policy's :func:`permits()` method is used for this purpose. 129 | 130 | 131 | When :class:`aiohttp.web.Request` has an :term:`identity` it means the 132 | user has been authenticated and therefore has an :term:`identity` that 133 | can be checked by the :term:`authorization` policy. 134 | 135 | As noted above, :term:`identity` is a string that is shared between 136 | the browser and the server. Therefore it is recommended that a 137 | random string such as a uuid or hash is used rather than things like 138 | a database primary key, user login/email, etc. 139 | 140 | 141 | .. rubric:: Footnotes 142 | .. [#f1] jwt - json web tokens in the works 143 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | flake8==7.2.0 4 | flake8-bandit==4.1.1 5 | flake8-bugbear==24.12.12 6 | flake8-import-order==0.18.2 7 | flake8-requirements==2.2.1 8 | mypy==1.16.0 9 | types-passlib==1.7.7.20250602 10 | pep257==0.7.0 11 | sphinx==8.1.3 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | aiohttp==3.12.6 3 | aiohttp-session==2.12.1 4 | async-timeout==5.0.1 5 | coverage==7.8.2 6 | cryptography==45.0.3 7 | hiredis==3.2.1 8 | passlib==1.7.4 9 | pyjwt==2.10.1 10 | pytest==8.4.0 11 | pytest-aiohttp==1.1.0 12 | pytest-cov==6.1.1 13 | pytest-mock==3.14.1 14 | sqlalchemy==2.0.41 15 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests/ 3 | addopts = --cov=aiohttp_security --cov=tests 4 | asyncio_mode = auto 5 | xfail_strict = true 6 | filterwarnings= 7 | error 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from setuptools import setup 5 | 6 | 7 | with open(os.path.join(os.path.abspath(os.path.dirname( 8 | __file__)), 'aiohttp_security', '__init__.py'), 'r', encoding='latin1') as fp: 9 | try: 10 | version = re.findall(r"^__version__ = '([^']+)'$", fp.read(), re.M)[0] 11 | except IndexError: 12 | raise RuntimeError('Unable to determine version.') 13 | 14 | 15 | def read(f): 16 | return open(os.path.join(os.path.dirname(__file__), f)).read().strip() 17 | 18 | 19 | install_requires = ["aiohttp>=3.9"] 20 | tests_require = install_requires + ['pytest'] 21 | extras_require = {'session': 'aiohttp-session'} 22 | 23 | 24 | setup(name='aiohttp-security', 25 | version=version, 26 | description=("security for aiohttp.web"), 27 | long_description='\n\n'.join((read("README.rst"), read("CHANGES.rst"))), 28 | long_description_content_type="text/x-rst", 29 | classifiers=[ 30 | 'License :: OSI Approved :: Apache Software License', 31 | 'Intended Audience :: Developers', 32 | 'Programming Language :: Python', 33 | 'Programming Language :: Python :: 3', 34 | "Programming Language :: Python :: 3.9", 35 | "Programming Language :: Python :: 3.10", 36 | "Programming Language :: Python :: 3.11", 37 | "Programming Language :: Python :: 3.12", 38 | "Programming Language :: Python :: 3.13", 39 | 'Topic :: Internet :: WWW/HTTP', 40 | 'Framework :: AsyncIO', 41 | "Framework :: aiohttp", 42 | ], 43 | author='Andrew Svetlov', 44 | author_email='andrew.svetlov@gmail.com', 45 | url='https://github.com/aio-libs/aiohttp_security/', 46 | license='Apache 2', 47 | packages=("aiohttp_security",), 48 | python_requires=">=3.9", 49 | install_requires=install_requires, 50 | tests_require=tests_require, 51 | include_package_data=True, 52 | extras_require=extras_require) 53 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohttp-security/1d401e323e187fea5d761d92ac177faaaefe29a6/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # nothing to do 2 | -------------------------------------------------------------------------------- /tests/test_cookies_identity.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | from aiohttp_security import AbstractAuthorizationPolicy, forget, remember 4 | from aiohttp_security import setup as _setup 5 | from aiohttp_security.api import IDENTITY_KEY 6 | from aiohttp_security.cookies_identity import CookiesIdentityPolicy 7 | 8 | 9 | class Autz(AbstractAuthorizationPolicy): 10 | 11 | async def permits(self, identity, permission, context=None): 12 | pass 13 | 14 | async def authorized_userid(self, identity): 15 | pass 16 | 17 | 18 | async def test_remember(aiohttp_client): 19 | 20 | async def handler(request): 21 | response = web.Response() 22 | await remember(request, response, 'Andrew') 23 | return response 24 | 25 | app = web.Application() 26 | _setup(app, CookiesIdentityPolicy(), Autz()) 27 | app.router.add_route('GET', '/', handler) 28 | client = await aiohttp_client(app) 29 | resp = await client.get('/') 30 | assert 200 == resp.status 31 | assert 'Andrew' == resp.cookies['AIOHTTP_SECURITY'].value 32 | 33 | 34 | async def test_identify(aiohttp_client): 35 | 36 | async def create(request): 37 | response = web.Response() 38 | await remember(request, response, 'Andrew') 39 | return response 40 | 41 | async def check(request): 42 | policy = request.app[IDENTITY_KEY] 43 | user_id = await policy.identify(request) 44 | assert 'Andrew' == user_id 45 | return web.Response() 46 | 47 | app = web.Application() 48 | _setup(app, CookiesIdentityPolicy(), Autz()) 49 | app.router.add_route('GET', '/', check) 50 | app.router.add_route('POST', '/', create) 51 | client = await aiohttp_client(app) 52 | resp = await client.post('/') 53 | assert 200 == resp.status 54 | await resp.release() 55 | resp = await client.get('/') 56 | assert 200 == resp.status 57 | 58 | 59 | async def test_forget(aiohttp_client): 60 | 61 | async def index(request): 62 | return web.Response() 63 | 64 | async def login(request): 65 | response = web.HTTPFound(location='/') 66 | await remember(request, response, 'Andrew') 67 | raise response 68 | 69 | async def logout(request): 70 | response = web.HTTPFound(location='/') 71 | await forget(request, response) 72 | raise response 73 | 74 | app = web.Application() 75 | _setup(app, CookiesIdentityPolicy(), Autz()) 76 | app.router.add_route('GET', '/', index) 77 | app.router.add_route('POST', '/login', login) 78 | app.router.add_route('POST', '/logout', logout) 79 | client = await aiohttp_client(app) 80 | resp = await client.post('/login') 81 | assert 200 == resp.status 82 | assert str(resp.url).endswith('/') 83 | cookies = client.session.cookie_jar.filter_cookies( 84 | client.make_url('/')) 85 | assert 'Andrew' == cookies['AIOHTTP_SECURITY'].value 86 | 87 | resp = await client.post('/logout') 88 | assert 200 == resp.status 89 | assert str(resp.url).endswith('/') 90 | cookies = client.session.cookie_jar.filter_cookies( 91 | client.make_url('/')) 92 | assert 'AIOHTTP_SECURITY' not in cookies 93 | -------------------------------------------------------------------------------- /tests/test_dict_autz.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from aiohttp import web 4 | 5 | from aiohttp_security import ( 6 | AbstractAuthorizationPolicy, authorized_userid, check_authorized, check_permission, forget, 7 | is_anonymous, permits, remember) 8 | from aiohttp_security import setup as _setup 9 | from aiohttp_security.cookies_identity import CookiesIdentityPolicy 10 | 11 | 12 | class Autz(AbstractAuthorizationPolicy): 13 | 14 | async def permits(self, identity, permission, context=None): 15 | if identity == 'UserID': 16 | return permission in {'read', 'write'} 17 | else: 18 | return False 19 | 20 | async def authorized_userid(self, identity): 21 | if identity == 'UserID': 22 | return 'Andrew' 23 | else: 24 | return None 25 | 26 | 27 | async def test_authorized_userid(aiohttp_client): 28 | 29 | async def login(request): 30 | response = web.HTTPFound(location='/') 31 | await remember(request, response, 'UserID') 32 | raise response 33 | 34 | async def check(request): 35 | userid = await authorized_userid(request) 36 | assert 'Andrew' == userid 37 | return web.Response(text=userid) 38 | 39 | app = web.Application() 40 | _setup(app, CookiesIdentityPolicy(), Autz()) 41 | app.router.add_route('GET', '/', check) 42 | app.router.add_route('POST', '/login', login) 43 | client = await aiohttp_client(app) 44 | 45 | resp = await client.post('/login') 46 | assert 200 == resp.status 47 | txt = await resp.text() 48 | assert 'Andrew' == txt 49 | 50 | 51 | async def test_authorized_userid_not_authorized(aiohttp_client): 52 | 53 | async def check(request): 54 | userid = await authorized_userid(request) 55 | assert userid is None 56 | return web.Response() 57 | 58 | app = web.Application() 59 | _setup(app, CookiesIdentityPolicy(), Autz()) 60 | app.router.add_route('GET', '/', check) 61 | client = await aiohttp_client(app) 62 | 63 | resp = await client.get('/') 64 | assert 200 == resp.status 65 | 66 | 67 | async def test_permits_enum_permission(aiohttp_client): 68 | class Permission(enum.Enum): 69 | READ = '101' 70 | WRITE = '102' 71 | UNKNOWN = '103' 72 | 73 | class Autz(AbstractAuthorizationPolicy): 74 | 75 | async def permits(self, identity, permission, context=None): 76 | if identity == 'UserID': 77 | return permission in {Permission.READ, Permission.WRITE} 78 | else: 79 | return False 80 | 81 | async def authorized_userid(self, identity): 82 | if identity == 'UserID': 83 | return 'Andrew' 84 | else: 85 | return None 86 | 87 | async def login(request): 88 | response = web.HTTPFound(location='/') 89 | await remember(request, response, 'UserID') 90 | raise response 91 | 92 | async def check(request): 93 | ret = await permits(request, Permission.READ) 94 | assert ret 95 | ret = await permits(request, Permission.WRITE) 96 | assert ret 97 | ret = await permits(request, Permission.UNKNOWN) 98 | assert not ret 99 | return web.Response() 100 | 101 | app = web.Application() 102 | _setup(app, CookiesIdentityPolicy(), Autz()) 103 | app.router.add_route('GET', '/', check) 104 | app.router.add_route('POST', '/login', login) 105 | client = await aiohttp_client(app) 106 | resp = await client.post('/login') 107 | assert 200 == resp.status 108 | 109 | 110 | async def test_permits_unauthorized(aiohttp_client): 111 | 112 | async def check(request): 113 | ret = await permits(request, 'read') 114 | assert not ret 115 | ret = await permits(request, 'write') 116 | assert not ret 117 | ret = await permits(request, 'unknown') 118 | assert not ret 119 | return web.Response() 120 | 121 | app = web.Application() 122 | _setup(app, CookiesIdentityPolicy(), Autz()) 123 | app.router.add_route('GET', '/', check) 124 | client = await aiohttp_client(app) 125 | resp = await client.get('/') 126 | assert 200 == resp.status 127 | 128 | 129 | async def test_is_anonymous(aiohttp_client): 130 | 131 | async def index(request): 132 | is_anon = await is_anonymous(request) 133 | if is_anon: 134 | raise web.HTTPUnauthorized() 135 | return web.Response() 136 | 137 | async def login(request): 138 | response = web.HTTPFound(location='/') 139 | await remember(request, response, 'UserID') 140 | raise response 141 | 142 | async def logout(request): 143 | response = web.HTTPFound(location='/') 144 | await forget(request, response) 145 | raise response 146 | 147 | app = web.Application() 148 | _setup(app, CookiesIdentityPolicy(), Autz()) 149 | app.router.add_route('GET', '/', index) 150 | app.router.add_route('POST', '/login', login) 151 | app.router.add_route('POST', '/logout', logout) 152 | client = await aiohttp_client(app) 153 | resp = await client.get('/') 154 | assert web.HTTPUnauthorized.status_code == resp.status 155 | 156 | await client.post('/login') 157 | resp = await client.get('/') 158 | assert web.HTTPOk.status_code == resp.status 159 | 160 | await client.post('/logout') 161 | resp = await client.get('/') 162 | assert web.HTTPUnauthorized.status_code == resp.status 163 | 164 | 165 | async def test_check_authorized(aiohttp_client): 166 | async def index(request): 167 | await check_authorized(request) 168 | return web.Response() 169 | 170 | async def login(request): 171 | response = web.HTTPFound(location='/') 172 | await remember(request, response, 'UserID') 173 | raise response 174 | 175 | async def logout(request): 176 | response = web.HTTPFound(location='/') 177 | await forget(request, response) 178 | raise response 179 | 180 | app = web.Application() 181 | _setup(app, CookiesIdentityPolicy(), Autz()) 182 | app.router.add_route('GET', '/', index) 183 | app.router.add_route('POST', '/login', login) 184 | app.router.add_route('POST', '/logout', logout) 185 | client = await aiohttp_client(app) 186 | resp = await client.get('/') 187 | assert web.HTTPUnauthorized.status_code == resp.status 188 | 189 | await client.post('/login') 190 | resp = await client.get('/') 191 | assert web.HTTPOk.status_code == resp.status 192 | 193 | await client.post('/logout') 194 | resp = await client.get('/') 195 | assert web.HTTPUnauthorized.status_code == resp.status 196 | 197 | 198 | async def test_check_permission(aiohttp_client): 199 | 200 | async def index_read(request): 201 | await check_permission(request, 'read') 202 | return web.Response() 203 | 204 | async def index_write(request): 205 | await check_permission(request, 'write') 206 | return web.Response() 207 | 208 | async def index_forbid(request): 209 | await check_permission(request, 'forbid') 210 | return web.Response() 211 | 212 | async def login(request): 213 | response = web.HTTPFound(location='/') 214 | await remember(request, response, 'UserID') 215 | raise response 216 | 217 | async def logout(request): 218 | response = web.HTTPFound(location='/') 219 | await forget(request, response) 220 | raise response 221 | 222 | app = web.Application() 223 | _setup(app, CookiesIdentityPolicy(), Autz()) 224 | app.router.add_route('GET', '/permission/read', index_read) 225 | app.router.add_route('GET', '/permission/write', index_write) 226 | app.router.add_route('GET', '/permission/forbid', index_forbid) 227 | app.router.add_route('POST', '/login', login) 228 | app.router.add_route('POST', '/logout', logout) 229 | client = await aiohttp_client(app) 230 | 231 | resp = await client.get('/permission/read') 232 | assert web.HTTPUnauthorized.status_code == resp.status 233 | resp = await client.get('/permission/write') 234 | assert web.HTTPUnauthorized.status_code == resp.status 235 | resp = await client.get('/permission/forbid') 236 | assert web.HTTPUnauthorized.status_code == resp.status 237 | 238 | await client.post('/login') 239 | resp = await client.get('/permission/read') 240 | assert web.HTTPOk.status_code == resp.status 241 | resp = await client.get('/permission/write') 242 | assert web.HTTPOk.status_code == resp.status 243 | resp = await client.get('/permission/forbid') 244 | assert web.HTTPForbidden.status_code == resp.status 245 | 246 | await client.post('/logout') 247 | resp = await client.get('/permission/read') 248 | assert web.HTTPUnauthorized.status_code == resp.status 249 | resp = await client.get('/permission/write') 250 | assert web.HTTPUnauthorized.status_code == resp.status 251 | resp = await client.get('/permission/forbid') 252 | assert web.HTTPUnauthorized.status_code == resp.status 253 | -------------------------------------------------------------------------------- /tests/test_jwt_identity.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | import pytest 3 | from aiohttp import web 4 | 5 | from aiohttp_security import AbstractAuthorizationPolicy 6 | from aiohttp_security import setup as _setup 7 | from aiohttp_security.api import IDENTITY_KEY 8 | from aiohttp_security.jwt_identity import JWTIdentityPolicy 9 | 10 | 11 | @pytest.fixture 12 | def make_token(): 13 | def factory(payload, secret): 14 | return jwt.encode( 15 | payload, 16 | secret, 17 | algorithm='HS256', 18 | ) 19 | 20 | return factory 21 | 22 | 23 | class Autz(AbstractAuthorizationPolicy): 24 | 25 | async def permits(self, identity, permission, context=None): 26 | pass 27 | 28 | async def authorized_userid(self, identity): 29 | pass 30 | 31 | 32 | async def test_no_pyjwt_installed(mocker): 33 | mocker.patch("aiohttp_security.jwt_identity.HAS_JWT", False) 34 | with pytest.raises(RuntimeError): 35 | JWTIdentityPolicy('secret') 36 | 37 | 38 | async def test_identify(make_token, aiohttp_client): 39 | kwt_secret_key = "Key" # noqa: S105 40 | 41 | token = make_token({'login': 'Andrew'}, kwt_secret_key) 42 | 43 | async def check(request): 44 | policy = request.app[IDENTITY_KEY] 45 | identity = await policy.identify(request) 46 | assert "Andrew" == identity 47 | return web.Response() 48 | 49 | app = web.Application() 50 | _setup(app, JWTIdentityPolicy(kwt_secret_key), Autz()) 51 | app.router.add_route('GET', '/', check) 52 | 53 | client = await aiohttp_client(app) 54 | headers = {"Authorization": "Bearer {}".format(token)} 55 | resp = await client.get('/', headers=headers) 56 | assert 200 == resp.status 57 | 58 | 59 | async def test_identify_broken_scheme(make_token, aiohttp_client): 60 | kwt_secret_key = "Key" # noqa: S105 61 | 62 | token = make_token({'login': 'Andrew'}, kwt_secret_key) 63 | 64 | async def check(request): 65 | policy = request.app[IDENTITY_KEY] 66 | 67 | try: 68 | await policy.identify(request) 69 | except ValueError as exc: 70 | raise web.HTTPBadRequest(reason=str(exc)) 71 | 72 | return web.Response() 73 | 74 | app = web.Application() 75 | _setup(app, JWTIdentityPolicy(kwt_secret_key), Autz()) 76 | app.router.add_route('GET', '/', check) 77 | 78 | client = await aiohttp_client(app) 79 | headers = {"Authorization": "Token {}".format(token)} 80 | resp = await client.get('/', headers=headers) 81 | assert 400 == resp.status 82 | assert 'Invalid authorization scheme' in resp.reason 83 | 84 | 85 | async def test_identify_expired_signature(make_token, aiohttp_client): 86 | kwt_secret_key = "Key" # noqa: S105 87 | 88 | token = make_token({"login": "Andrew", "exp": 0}, kwt_secret_key) 89 | 90 | async def check(request): 91 | policy = request.app[IDENTITY_KEY] 92 | try: 93 | await policy.identify(request) 94 | except jwt.exceptions.PyJWTError as exc: 95 | raise web.HTTPBadRequest(reason=str(exc)) 96 | 97 | assert False 98 | 99 | app = web.Application() 100 | _setup(app, JWTIdentityPolicy(kwt_secret_key), Autz()) 101 | app.router.add_route("GET", "/", check) 102 | 103 | client = await aiohttp_client(app) 104 | headers = {"Authorization": "Bearer {}".format(token)} 105 | resp = await client.get("/", headers=headers) 106 | assert 400 == resp.status 107 | assert "Signature has expired" in resp.reason 108 | -------------------------------------------------------------------------------- /tests/test_no_auth.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | from aiohttp_security import authorized_userid, permits 4 | 5 | 6 | async def test_authorized_userid(aiohttp_client): 7 | 8 | async def check(request): 9 | userid = await authorized_userid(request) 10 | assert userid is None 11 | return web.Response() 12 | 13 | app = web.Application() 14 | app.router.add_route('GET', '/', check) 15 | client = await aiohttp_client(app) 16 | resp = await client.get('/') 17 | assert 200 == resp.status 18 | 19 | 20 | async def test_permits(aiohttp_client): 21 | 22 | async def check(request): 23 | ret = await permits(request, 'read') 24 | assert ret 25 | ret = await permits(request, 'write') 26 | assert ret 27 | ret = await permits(request, 'unknown') 28 | assert ret 29 | return web.Response() 30 | 31 | app = web.Application() 32 | app.router.add_route('GET', '/', check) 33 | client = await aiohttp_client(app) 34 | resp = await client.get('/') 35 | assert 200 == resp.status 36 | -------------------------------------------------------------------------------- /tests/test_no_identity.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | from aiohttp_security import forget, remember 4 | 5 | 6 | async def test_remember(aiohttp_client): 7 | 8 | async def do_remember(request): 9 | response = web.Response() 10 | await remember(request, response, 'Andrew') 11 | 12 | app = web.Application() 13 | app.router.add_route('POST', '/', do_remember) 14 | client = await aiohttp_client(app) 15 | resp = await client.post('/') 16 | assert 500 == resp.status 17 | exp = "Security subsystem is not initialized, call aiohttp_security.setup(...) first" 18 | assert exp == resp.reason 19 | 20 | 21 | async def test_forget(aiohttp_client): 22 | 23 | async def do_forget(request): 24 | response = web.Response() 25 | await forget(request, response) 26 | 27 | app = web.Application() 28 | app.router.add_route('POST', '/', do_forget) 29 | client = await aiohttp_client(app) 30 | resp = await client.post('/') 31 | assert 500 == resp.status 32 | exp = "Security subsystem is not initialized, call aiohttp_security.setup(...) first" 33 | assert exp == resp.reason 34 | -------------------------------------------------------------------------------- /tests/test_session_identity.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aiohttp import web 3 | from aiohttp_session import SimpleCookieStorage, get_session 4 | from aiohttp_session import setup as setup_session 5 | 6 | from aiohttp_security import AbstractAuthorizationPolicy, forget, remember 7 | from aiohttp_security import setup as setup_security 8 | from aiohttp_security.api import IDENTITY_KEY 9 | from aiohttp_security.session_identity import SessionIdentityPolicy 10 | 11 | 12 | class Autz(AbstractAuthorizationPolicy): 13 | 14 | async def permits(self, identity, permission, context=None): 15 | pass 16 | 17 | async def authorized_userid(self, identity): 18 | pass 19 | 20 | 21 | @pytest.fixture 22 | def make_app(): 23 | app = web.Application() 24 | setup_session(app, SimpleCookieStorage()) 25 | setup_security(app, SessionIdentityPolicy(), Autz()) 26 | return app 27 | 28 | 29 | async def test_remember(make_app, aiohttp_client): 30 | 31 | async def handler(request): 32 | response = web.Response() 33 | await remember(request, response, 'Andrew') 34 | return response 35 | 36 | async def check(request): 37 | session = await get_session(request) 38 | assert session['AIOHTTP_SECURITY'] == 'Andrew' 39 | return web.Response() 40 | 41 | app = make_app() 42 | app.router.add_route('GET', '/', handler) 43 | app.router.add_route('GET', '/check', check) 44 | client = await aiohttp_client(app) 45 | resp = await client.get('/') 46 | assert 200 == resp.status 47 | 48 | resp = await client.get('/check') 49 | assert 200 == resp.status 50 | 51 | 52 | async def test_identify(make_app, aiohttp_client): 53 | 54 | async def create(request): 55 | response = web.Response() 56 | await remember(request, response, 'Andrew') 57 | return response 58 | 59 | async def check(request): 60 | policy = request.app[IDENTITY_KEY] 61 | user_id = await policy.identify(request) 62 | assert 'Andrew' == user_id 63 | return web.Response() 64 | 65 | app = make_app() 66 | app.router.add_route('GET', '/', check) 67 | app.router.add_route('POST', '/', create) 68 | client = await aiohttp_client(app) 69 | resp = await client.post('/') 70 | assert 200 == resp.status 71 | 72 | resp = await client.get('/') 73 | assert 200 == resp.status 74 | 75 | 76 | async def test_forget(make_app, aiohttp_client): 77 | 78 | async def index(request): 79 | session = await get_session(request) 80 | return web.Response(text=session.get('AIOHTTP_SECURITY', '')) 81 | 82 | async def login(request): 83 | response = web.HTTPFound(location='/') 84 | await remember(request, response, 'Andrew') 85 | raise response 86 | 87 | async def logout(request): 88 | response = web.HTTPFound('/') 89 | await forget(request, response) 90 | raise response 91 | 92 | app = make_app() 93 | app.router.add_route('GET', '/', index) 94 | app.router.add_route('POST', '/login', login) 95 | app.router.add_route('POST', '/logout', logout) 96 | 97 | client = await aiohttp_client(app) 98 | 99 | resp = await client.post('/login') 100 | assert 200 == resp.status 101 | assert str(resp.url).endswith('/') 102 | txt = await resp.text() 103 | assert 'Andrew' == txt 104 | 105 | resp = await client.post('/logout') 106 | assert 200 == resp.status 107 | assert str(resp.url).endswith('/') 108 | txt = await resp.text() 109 | assert '' == txt 110 | --------------------------------------------------------------------------------