├── .coveragerc ├── .github ├── dependabot.yml └── workflows │ └── ci-tests.yml ├── .gitignore ├── .gitlab-ci.yml ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── _static │ └── .gitkeep ├── api │ ├── authpolicy.rst │ ├── interfaces.rst │ └── sources.rst ├── conf.py ├── faq.rst ├── index.rst ├── license.rst └── narr │ └── policy.rst ├── pyproject.toml ├── rtd.txt ├── setup.cfg ├── setup.py ├── src └── pyramid_authsanity │ ├── __init__.py │ ├── interfaces.py │ ├── policy.py │ ├── sources.py │ └── util.py ├── tests ├── test_authpolicy.py ├── test_includeme.py ├── test_sources.py └── test_util.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | parallel = true 3 | source = 4 | pyramid_authsanity 5 | tests 6 | omit = 7 | 8 | [paths] 9 | source = 10 | src/pyramid_authsanity 11 | */site-packages/pyramid_authsanity 12 | 13 | [report] 14 | show_missing = true 15 | precision = 2 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | 3 | version: 2 4 | updates: 5 | 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | # Check for updates to GitHub Actions every weekday 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci-tests.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: 4 | # Only on pushes to master or one of the release branches we build on push 5 | push: 6 | branches: 7 | - master 8 | tags: 9 | - "*" 10 | # Build pull requests 11 | pull_request: 12 | 13 | jobs: 14 | test: 15 | strategy: 16 | matrix: 17 | py: 18 | - "3.7" 19 | - "3.8" 20 | - "3.9" 21 | - "3.10" 22 | - "3.11" 23 | - "pypy-3.8" 24 | os: 25 | - "ubuntu-latest" 26 | architecture: 27 | - x64 28 | 29 | include: 30 | # Only run coverage on ubuntu-latest, except on pypy3 31 | - os: "ubuntu-latest" 32 | pytest-args: "--cov" 33 | - os: "ubuntu-latest" 34 | py: "pypy-3.8" 35 | pytest-args: "" 36 | 37 | name: "Python: ${{ matrix.py }}-${{ matrix.architecture }} on ${{ matrix.os }}" 38 | runs-on: ${{ matrix.os }} 39 | steps: 40 | - uses: actions/checkout@v4 41 | - name: Setup python 42 | uses: actions/setup-python@v4 43 | with: 44 | python-version: ${{ matrix.py }} 45 | architecture: ${{ matrix.architecture }} 46 | - run: pip install tox 47 | - name: Running tox 48 | run: tox -e py -- ${{ matrix.pytest-args }} 49 | coverage: 50 | runs-on: ubuntu-latest 51 | name: Validate coverage 52 | steps: 53 | - uses: actions/checkout@v4 54 | - name: Setup python 55 | uses: actions/setup-python@v4 56 | with: 57 | python-version: 3.9 58 | architecture: x64 59 | - run: pip install tox 60 | - run: tox -e py39-cover,coverage 61 | docs: 62 | runs-on: ubuntu-latest 63 | name: Build the documentation 64 | steps: 65 | - uses: actions/checkout@v4 66 | - name: Setup python 67 | uses: actions/setup-python@v4 68 | with: 69 | python-version: 3.9 70 | architecture: x64 71 | - run: pip install tox 72 | - run: tox -e docs 73 | lint: 74 | runs-on: ubuntu-latest 75 | name: Lint the package 76 | steps: 77 | - uses: actions/checkout@v4 78 | - name: Setup python 79 | uses: actions/setup-python@v4 80 | with: 81 | python-version: 3.9 82 | architecture: x64 83 | - run: pip install tox 84 | - run: tox -e lint 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.py[co] 3 | __pycache__ 4 | 5 | .coverage 6 | .coverage.* 7 | coverage.xml 8 | coverage-*.xml 9 | pytest-*.xml 10 | 11 | .tox/ 12 | .eggs/ 13 | .cache/ 14 | 15 | /env/ 16 | /build/ 17 | /dist/ 18 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | .test: &test 2 | stage: test 3 | script: 4 | - export TOXENV='' 5 | tags: 6 | - docker 7 | 8 | test:pypy3: 9 | <<: *test 10 | image: pypy:3 11 | script: 12 | - pip install tox 13 | - TOXENV=pypy3 tox 14 | 15 | test:3.6: 16 | <<: *test 17 | image: python:3.6 18 | script: 19 | - pip install tox 20 | - TOXENV=py36 tox 21 | 22 | test:3.7: 23 | <<: *test 24 | image: python:3.7 25 | script: 26 | - pip install tox 27 | - TOXENV=py37 tox 28 | 29 | test:3.8: 30 | <<: *test 31 | image: python:3.8 32 | script: 33 | - pip install tox 34 | - TOXENV=py38 tox 35 | 36 | test:3.9: 37 | <<: *test 38 | image: python:3.9 39 | script: 40 | - pip install tox 41 | - TOXENV=py39 tox 42 | 43 | test_cov: 44 | <<: *test 45 | image: python:3.9 46 | script: 47 | - pip install tox 48 | - TOXENV=py39-cover tox 49 | 50 | test:pep8: 51 | <<: *test 52 | image: python:3.6 53 | script: 54 | - pip install tox 55 | - TOXENV=lint tox 56 | 57 | pages: 58 | image: python:3.9 59 | script: 60 | - apt-get install make 61 | - pip install tox 62 | - TOXENV=docs tox 63 | - mv .tox/docs/html public 64 | artifacts: 65 | paths: 66 | - public 67 | tags: 68 | - docker 69 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | 2.0.0 (2021-03-07) 2 | ================== 3 | 4 | - Remove support for Python 2, 3.4, and 3.5 5 | 6 | - Updated to use new Pyramid 2.0 import locations, please use 1.1.0 if you want 7 | compatibility with lower versions of Pyramid. 8 | 9 | 1.1.0 (2017-11-29) 10 | ================== 11 | 12 | - Add new Authorization header based authentication source 13 | 14 | This provides out of the box support for "Bearer" like tokens. 15 | 16 | 1.0.0 (2017-05-19) 17 | ================== 18 | 19 | - Remove Python 2.6 support 20 | 21 | - Fix a bug whereby the policy was storing a dict instead of a list in the 22 | source, which of course broke things subtly when actually using the policy. 23 | 24 | - Send empty cookie when forgetting the authentication for the cookie source 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2017 Bert JW Regeer; 2 | 3 | Permission to use, copy, modify, and distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | 15 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src 2 | graft tests 3 | graft docs 4 | graft .github 5 | 6 | include README.rst 7 | include CHANGES.rst 8 | include LICENSE 9 | 10 | include .coveragerc 11 | include tox.ini .travis.yml rtd.txt .gitlab-ci.yml 12 | include pyproject.toml 13 | 14 | recursive-exclude * __pycache__ *.py[cod] 15 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | pyramid_authsanity 3 | ================== 4 | 5 | An auth policy for the `Pyramid Web Framework 6 | `_ with sane defaults that works with `Michael 7 | Merickel's `_ absolutely fantastic 8 | `pyramid_services `_. 9 | Provides an easy to use authorization policy that incorporates web security 10 | best practices. 11 | 12 | Installation 13 | ============ 14 | 15 | Install from `PyPI `_ using 16 | ``pip`` or ``easy_install`` inside a virtual environment. 17 | 18 | :: 19 | 20 | $ $VENV/bin/pip install pyramid_authsanity 21 | 22 | Or install directly from source. 23 | 24 | :: 25 | 26 | $ git clone https://github.com/usingnamespace/pyramid_authsanity.git 27 | $ cd pyramid_authsanity 28 | $ $VENV/bin/pip install -e . 29 | 30 | Setup 31 | ===== 32 | 33 | Activate ``pyramid_authsanity`` by including it into your pyramid application. 34 | 35 | :: 36 | 37 | config.include('pyramid_authsanity') 38 | 39 | -------------------------------------------------------------------------------- /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/pyramid_authsanity.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pyramid_authsanity.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/pyramid_authsanity" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pyramid_authsanity" 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/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usingnamespace/pyramid_authsanity/b4f41eb70a783f40c39d8547b663338cc0aab420/docs/_static/.gitkeep -------------------------------------------------------------------------------- /docs/api/authpolicy.rst: -------------------------------------------------------------------------------- 1 | :mod:`pyramid_authsanity` 2 | ========================= 3 | 4 | .. automodule:: pyramid_authsanity 5 | 6 | Authentication Service Policy 7 | ----------------------------- 8 | 9 | .. autoclass:: AuthServicePolicy 10 | :members: 11 | 12 | -------------------------------------------------------------------------------- /docs/api/interfaces.rst: -------------------------------------------------------------------------------- 1 | :mod:`pyramid_authsanity.interfaces` 2 | ==================================== 3 | 4 | .. automodule:: pyramid_authsanity.interfaces 5 | 6 | SourceService 7 | ------------- 8 | 9 | .. autointerface:: IAuthSourceService 10 | :members: 11 | 12 | AuthService 13 | ----------- 14 | 15 | .. autointerface:: IAuthService 16 | :members: 17 | -------------------------------------------------------------------------------- /docs/api/sources.rst: -------------------------------------------------------------------------------- 1 | :mod:`pyramid_authsanity.sources` 2 | ================================= 3 | 4 | .. automodule:: pyramid_authsanity.sources 5 | 6 | Session Authentication Source 7 | ----------------------------- 8 | 9 | .. autofunction:: SessionAuthSourceInitializer 10 | 11 | Cookie Authentication Source 12 | ---------------------------- 13 | 14 | .. autofunction:: CookieAuthSourceInitializer 15 | 16 | Authorization Header Authentication Source 17 | ------------------------------------------ 18 | 19 | .. autofunction:: HeaderAuthSourceInitializer 20 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | import sys 3 | import os 4 | import shlex 5 | 6 | extensions = [ 7 | "sphinx.ext.autodoc", 8 | "sphinx.ext.intersphinx", 9 | "sphinx.ext.viewcode", 10 | "repoze.sphinx.autointerface", 11 | ] 12 | 13 | # Add any paths that contain templates here, relative to this directory. 14 | templates_path = ["_templates"] 15 | 16 | # The suffix(es) of source filenames. 17 | # You can specify multiple suffix as a list of string: 18 | # source_suffix = ['.rst', '.md'] 19 | source_suffix = ".rst" 20 | 21 | # The encoding of source files. 22 | # source_encoding = 'utf-8-sig' 23 | 24 | # The master toctree document. 25 | master_doc = "index" 26 | 27 | # General information about the project. 28 | project = "pyramid_authsanity" 29 | copyright = "2015, Bert JW Regeer" 30 | author = "Bert JW Regeer" 31 | 32 | version = release = pkg_resources.get_distribution("pyramid_authsanity").version 33 | 34 | # The language for content autogenerated by Sphinx. Refer to documentation 35 | # for a list of supported languages. 36 | # 37 | # This is also used if you do content translation via gettext catalogs. 38 | # Usually you set "language" from the command line for these cases. 39 | language = "en" 40 | 41 | # List of patterns, relative to source directory, that match files and 42 | # directories to ignore when looking for source files. 43 | exclude_patterns = ["_build"] 44 | 45 | # The name of the Pygments (syntax highlighting) style to use. 46 | pygments_style = "sphinx" 47 | 48 | # If true, `todo` and `todoList` produce output, else they produce nothing. 49 | todo_include_todos = False 50 | 51 | modindex_common_prefix = ["pyramid_authsanity."] 52 | 53 | # -- Options for HTML output ---------------------------------------------- 54 | 55 | html_theme = "alabaster" 56 | 57 | # Add any paths that contain custom static files (such as style sheets) here, 58 | # relative to this directory. They are copied after the builtin static files, 59 | # so a file named "default.css" will overwrite the builtin "default.css". 60 | html_static_path = ["_static"] 61 | 62 | # Output file base name for HTML help builder. 63 | htmlhelp_basename = "pyramid_authsanitydoc" 64 | 65 | # -- Options for LaTeX output --------------------------------------------- 66 | 67 | latex_elements = { 68 | # The paper size ('letterpaper' or 'a4paper'). 69 | #'papersize': 'letterpaper', 70 | # The font size ('10pt', '11pt' or '12pt'). 71 | #'pointsize': '10pt', 72 | # Additional stuff for the LaTeX preamble. 73 | #'preamble': '', 74 | # Latex figure (float) alignment 75 | #'figure_align': 'htbp', 76 | } 77 | 78 | # Grouping the document tree into LaTeX files. List of tuples 79 | # (source start file, target name, title, 80 | # author, documentclass [howto, manual, or own class]). 81 | latex_documents = [ 82 | ( 83 | master_doc, 84 | "pyramid_authsanity.tex", 85 | "pyramid\\_authsanity Documentation", 86 | "Bert JW Regeer", 87 | "manual", 88 | ), 89 | ] 90 | 91 | # The name of an image file (relative to this directory) to place at the top of 92 | # the title page. 93 | # latex_logo = None 94 | 95 | # For "manual" documents, if this is true, then toplevel headings are parts, 96 | # not chapters. 97 | # latex_use_parts = False 98 | 99 | # If true, show page references after internal links. 100 | # latex_show_pagerefs = False 101 | 102 | # If true, show URL addresses after external links. 103 | # latex_show_urls = False 104 | 105 | # Documents to append as an appendix to all manuals. 106 | # latex_appendices = [] 107 | 108 | # If false, no module index is generated. 109 | # latex_domain_indices = True 110 | 111 | 112 | # -- Options for manual page output --------------------------------------- 113 | 114 | # One entry per manual page. List of tuples 115 | # (source start file, name, description, authors, manual section). 116 | man_pages = [ 117 | (master_doc, "pyramid_authsanity", "pyramid_authsanity Documentation", [author], 1) 118 | ] 119 | 120 | # If true, show URL addresses after external links. 121 | # man_show_urls = False 122 | 123 | 124 | # -- Options for Texinfo output ------------------------------------------- 125 | 126 | # Grouping the document tree into Texinfo files. List of tuples 127 | # (source start file, target name, title, author, 128 | # dir menu entry, description, category) 129 | texinfo_documents = [ 130 | ( 131 | master_doc, 132 | "pyramid_authsanity", 133 | "pyramid_authsanity Documentation", 134 | author, 135 | "pyramid_authsanity", 136 | "One line description of project.", 137 | "Miscellaneous", 138 | ), 139 | ] 140 | 141 | # Documents to append as an appendix to all manuals. 142 | # texinfo_appendices = [] 143 | 144 | # If false, no module index is generated. 145 | # texinfo_domain_indices = True 146 | 147 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 148 | # texinfo_show_urls = 'footnote' 149 | 150 | # If true, do not generate a @detailmenu in the "Top" node's menu. 151 | # texinfo_no_detailmenu = False 152 | 153 | 154 | # Example configuration for intersphinx: refer to the Python standard library. 155 | intersphinx_mapping = {"https://docs.python.org/": None} 156 | -------------------------------------------------------------------------------- /docs/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently Asked Questions 2 | ========================== 3 | 4 | Why tickets? 5 | ~~~~~~~~~~~~ 6 | 7 | If you have a web application that uses a simple signed cookie that contains 8 | information about the signed in user, the login will not expire until the 9 | cookie's expiration. This can leave gaps in security. 10 | 11 | Take the scenario of an employee that uses their own device for business, they 12 | log in in the morning before heading into the office and the cookie is set to 13 | authenticate them for 12 hours. They go buy some coffee and put their phone 14 | down. Walking out they leave the phone on a table. Once they find out they 15 | notify the company about the lost phone, however since the authentication 16 | cookie is their username, there is no way to terminate the existing session, 17 | and were an attacker able to use their phone they would be able to continue 18 | using the web application for the next 12 hours. 19 | 20 | Tickets are stored server side, and for each device/login there will be a 21 | unique ticket. These can be individually removed, and as soon as it is removed 22 | the authentication is no longer valid. 23 | 24 | Facebook/Google for example also allow the user to view their sessions, and 25 | terminate one, or all of them. This ticket based system allows for the same 26 | user interaction, thereby allowing more control over who is logged in or why. 27 | 28 | If a user changes their password, tickets give the ability to log out all 29 | pre-existing sessions so that the user is required to login again on any and 30 | all devices. 31 | 32 | What is session fixation? 33 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 34 | 35 | Session fixation is an attack that permits an attacker to hijack a valid 36 | session. Generally this is done by going to the website and retrieving an 37 | session, that session is then given to the victim. As soon as the victim logs 38 | in, the attacker who still has the same session token is able to see what is 39 | being stored in the session which may potentially leak data. For authentication 40 | policies that store the authentication in the session this would give the 41 | attacker full control over the victim's account. 42 | 43 | You stop session fixation by dropping the session when going across an 44 | authentication boundary (login/logout). This will recreate the session from 45 | scratch, which leaves the attacker with a session that is worthless. 46 | 47 | Vary headers 48 | ~~~~~~~~~~~~ 49 | 50 | When an HTTP request is made, the content is usually cached for as long as 51 | possible to avoid having to do more trips to the backend server (for reverse 52 | proxies) and more requests to the server for browsers. However proxies and 53 | browsers can't know that the page for example contains information that is 54 | dependent on a particular HTTP header, that is where the ``vary`` HTTP header 55 | comes in. 56 | 57 | Using ``vary`` you can tell the proxies or web browser that this page is to be 58 | cached, but it is dependent on a particular header. For example ``vary: 59 | cookie`` means the cache is allowed to return the page without requesting 60 | information from the backend server so long the cookie the client sends is the 61 | exact same as at the time of the previous response generated. 62 | 63 | For more take a look at `RFC7231 section 7.1.4 64 | `__ which explains what this 65 | header does and means. 66 | 67 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | pyramid_authsanity 2 | ================== 3 | 4 | pyramid_authsanity is an authentication policy for the `Pyramid Web Framework 5 | `__ that strives to make it 6 | easier to write a secure authentication policy that follows web best practices. 7 | 8 | - Uses tickets to allow sessions to be prematurely ended. Don't depend on the 9 | expiration of a cookie for example, instead have the ability to terminate 10 | sessions server side. 11 | - Stops session fixation by automatically clearing the session upon 12 | login/logout. Sessions are also cleared if the new session is for a different 13 | userid than before. 14 | - Automatically adds the Vary HTTP header if the authentication policy is used. 15 | 16 | pyramid_authsanity uses `Michael Merickel's `__ 17 | absolutely fantastic `pyramid_services 18 | `__ to allow an application 19 | developer to easily plug in their own sources, and interact with their user 20 | database. 21 | 22 | API Documentation 23 | ================= 24 | 25 | Reference material for every public API exposed by pyramid_authsanity: 26 | 27 | .. toctree:: 28 | :maxdepth: 1 29 | :glob: 30 | 31 | api/* 32 | 33 | Narrative Documentation 34 | ======================= 35 | 36 | Narrative documentation that describes how to use this library, with some 37 | examples. 38 | 39 | .. toctree:: 40 | :maxdepth: 1 41 | 42 | narr/policy 43 | 44 | Other Matters 45 | ============= 46 | 47 | .. toctree:: 48 | :maxdepth: 2 49 | 50 | faq 51 | license 52 | 53 | .. toctree:: 54 | :maxdepth: 2 55 | 56 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | License 2 | ======= 3 | 4 | .. include:: ../LICENSE 5 | -------------------------------------------------------------------------------- /docs/narr/policy.rst: -------------------------------------------------------------------------------- 1 | The authentication policy 2 | ========================= 3 | 4 | This authentication policy has two moving pieces, they work together to provide 5 | an easy to use authentication policy that provides more security by allowing 6 | the server to terminate an active authentication session. 7 | 8 | Source Service 9 | ~~~~~~~~~~~~~~ 10 | 11 | The first piece is called the authentication source service, this stores the 12 | principal and a ticket. There are two provided source services: 13 | 14 | cookie 15 | ------ 16 | 17 | This is the default source and stores the information in a JSON encoded cookie 18 | that is signed using HMAC. This secures the information so long as the secret 19 | key for the HMAC is not made public. 20 | 21 | session 22 | ------- 23 | 24 | This source stores the information required for the authentication in the 25 | Pyramid session, this requires that a session is available in the application 26 | as `request.session`. Since there is no requirement for a Pyramid application 27 | to have a registered session, pyramid_authsanity decided to not make this the 28 | default. 29 | 30 | Authentication Service 31 | ~~~~~~~~~~~~~~~~~~~~~~ 32 | 33 | The authentication service is defined by the user, the primary goal is to 34 | verify that the principal and ticket are both still valid. 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 41"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | target-version = ['py35', 'py36', 'py37', 'py38'] 7 | exclude = ''' 8 | /( 9 | \.git 10 | | .tox 11 | )/ 12 | ''' 13 | 14 | # This next section only exists for people that have their editors 15 | # automatically call isort, black already sorts entries on its own when run. 16 | [tool.isort] 17 | profile = "black" 18 | multi_line_output = 3 19 | src_paths = ["src", "tests"] 20 | skip_glob = ["docs/*"] 21 | include_trailing_comma = true 22 | force_grid_wrap = false 23 | combine_as_imports = true 24 | line_length = 88 25 | force_sort_within_sections = true 26 | default_section = "THIRDPARTY" 27 | known_first_party = "pyramid_authsanity" 28 | -------------------------------------------------------------------------------- /rtd.txt: -------------------------------------------------------------------------------- 1 | -e .[docs] 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | dev = develop easy_install pyramid_authsanity[testing] 3 | 4 | [tool:pytest] 5 | python_files = test_*.py 6 | testpaths = 7 | src/pyramid_authsanity 8 | tests 9 | 10 | [metadata] 11 | license_file = LICENSE 12 | 13 | [flake8] 14 | exclude = pyramid_authsanity/tests/ 15 | show-source = True 16 | max-line-length = 89 17 | 18 | [check-manifest] 19 | ignore = 20 | .gitignore 21 | PKG-INFO 22 | *.egg-info 23 | *.egg-info/* 24 | ignore-default-rules = true 25 | 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | try: 7 | README = open(os.path.join(here, "README.rst")).read() 8 | CHANGES = open(os.path.join(here, "CHANGES.rst")).read() 9 | except IOError: 10 | README = CHANGES = "" 11 | 12 | requires = [ 13 | "pyramid>=2.0", 14 | "zope.interface", 15 | "pyramid_services>=2.0", # LookupError instead of ValueError 16 | ] 17 | 18 | tests_require = requires + [ 19 | "pytest", 20 | "coverage", 21 | "pytest-cov", 22 | ] 23 | 24 | docs_require = requires + [ 25 | "sphinx", 26 | "repoze.sphinx.autointerface", 27 | ] 28 | 29 | setup( 30 | name="pyramid_authsanity", 31 | version="2.0.0", 32 | description="An auth policy for the Pyramid Web Framework with sane defaults.", 33 | long_description=README + "\n\n" + CHANGES, 34 | classifiers=[ 35 | "Development Status :: 5 - Production/Stable", 36 | "Framework :: Pyramid", 37 | "Intended Audience :: Developers", 38 | "License :: OSI Approved :: ISC License (ISCL)", 39 | "Programming Language :: Python", 40 | "Programming Language :: Python :: 3.7", 41 | "Programming Language :: Python :: 3.8", 42 | "Programming Language :: Python :: 3.9", 43 | "Programming Language :: Python :: 3.10", 44 | "Programming Language :: Python :: 3.11", 45 | "Programming Language :: Python :: Implementation :: CPython", 46 | "Programming Language :: Python :: Implementation :: PyPy", 47 | ], 48 | keywords="pyramid authorization policy", 49 | python_requires=">=3.7", 50 | author="Bert JW Regeer", 51 | author_email="bertjw@regeer.org", 52 | url="https://github.com/usingnamespace/pyramid_authsanity", 53 | packages=find_packages("src", exclude=["tests"]), 54 | package_dir={"": "src"}, 55 | include_package_data=True, 56 | zip_safe=False, 57 | install_requires=requires, 58 | extras_require={ 59 | "testing": tests_require, 60 | "docs": docs_require, 61 | }, 62 | entry_points={}, 63 | ) 64 | -------------------------------------------------------------------------------- /src/pyramid_authsanity/__init__.py: -------------------------------------------------------------------------------- 1 | from pyramid.settings import asbool, aslist 2 | 3 | from .interfaces import IAuthSourceService 4 | from .policy import AuthServicePolicy 5 | from .sources import ( 6 | CookieAuthSourceInitializer, 7 | HeaderAuthSourceInitializer, 8 | SessionAuthSourceInitializer, 9 | ) 10 | from .util import int_or_none, kw_from_settings 11 | 12 | default_settings = ( 13 | ("source", str, ""), 14 | ("debug", asbool, False), 15 | ("cookie.cookie_name", str, "auth"), 16 | ("cookie.max_age", int_or_none, None), 17 | ("cookie.httponly", asbool, True), 18 | ("cookie.path", str, "/"), 19 | ("cookie.domains", aslist, []), 20 | ("cookie.debug", asbool, False), 21 | ("session.value_key", str, "sanity."), 22 | ) 23 | 24 | 25 | def init_cookie_source(config, settings): 26 | if "authsanity.secret" not in settings: 27 | raise RuntimeError("authsanity.secret is required for cookie based storage") 28 | 29 | kw = kw_from_settings(settings, "authsanity.cookie.") 30 | 31 | config.register_service_factory( 32 | CookieAuthSourceInitializer(settings["authsanity.secret"], **kw), 33 | iface=IAuthSourceService, 34 | ) 35 | 36 | 37 | def init_session_source(config, settings): 38 | kw = kw_from_settings(settings, "authsanity.session.") 39 | 40 | config.register_service_factory( 41 | SessionAuthSourceInitializer(**kw), iface=IAuthSourceService 42 | ) 43 | 44 | 45 | def init_authorization_header_source(config, settings): 46 | if "authsanity.secret" not in settings: 47 | raise RuntimeError( 48 | "authsanity.secret is required for Authorization header source" 49 | ) 50 | 51 | kw = kw_from_settings(settings, "authsanity.header.") 52 | 53 | config.register_service_factory( 54 | HeaderAuthSourceInitializer(settings["authsanity.secret"], **kw), 55 | iface=IAuthSourceService, 56 | ) 57 | 58 | 59 | default_sources = { 60 | "cookie": init_cookie_source, 61 | "session": init_session_source, 62 | "header": init_authorization_header_source, 63 | } 64 | 65 | 66 | # Stolen from pyramid_debugtoolbar 67 | def parse_settings(settings): 68 | parsed = {} 69 | 70 | def populate(name, convert, default): 71 | name = "%s%s" % ("authsanity.", name) 72 | value = convert(settings.get(name, default)) 73 | parsed[name] = value 74 | 75 | for name, convert, default in default_settings: 76 | populate(name, convert, default) 77 | return parsed 78 | 79 | 80 | def includeme(config): 81 | # Go parse the settings 82 | settings = parse_settings(config.registry.settings) 83 | 84 | # Update the config 85 | config.registry.settings.update(settings) 86 | 87 | # include pyramid_services 88 | config.include("pyramid_services") 89 | 90 | if settings["authsanity.source"] in default_sources: 91 | default_sources[settings["authsanity.source"]](config, config.registry.settings) 92 | 93 | config.set_authentication_policy( 94 | AuthServicePolicy(debug=settings["authsanity.debug"]) 95 | ) 96 | -------------------------------------------------------------------------------- /src/pyramid_authsanity/interfaces.py: -------------------------------------------------------------------------------- 1 | from zope.interface import Attribute, Interface 2 | 3 | 4 | class IAuthSourceService(Interface): 5 | """Represents an authentication source.""" 6 | 7 | vary = Attribute("List of HTTP headers to Vary the response by.") 8 | 9 | def get_value(): 10 | """Returns the opaque value that was stored.""" 11 | 12 | def headers_remember(value): 13 | """Returns any and all headers for remembering the value, as a list. 14 | Value is a standard Python type that shall be serializable using 15 | JSON.""" 16 | 17 | def headers_forget(): 18 | """Returns any and all headers for forgetting the current requests 19 | value.""" 20 | 21 | 22 | class IAuthService(Interface): 23 | """Represents an authentication service. This service verifies that the 24 | users authentication ticket is valid and returns groups the user is a 25 | member of.""" 26 | 27 | def userid(): 28 | """Return the current user id, None, or raise an error. Raising an 29 | error is used when no attempt to verify a ticket has been made yet and 30 | signifies that the authentication policy should attempt to call 31 | ``verify_ticket``""" 32 | 33 | def groups(): 34 | """Returns the groups for the current user, as a list. Including the 35 | current userid in this list is not required, as it will be implicitly 36 | added by the authentication policy.""" 37 | 38 | def verify_ticket(principal, ticket): 39 | """Verify that the principal matches the ticket given.""" 40 | 41 | def add_ticket(principal, ticket): 42 | """Add a new ticket for the principal. If there is a failure, due to a 43 | missing/non-existent principal, or failure to add ticket for principal, 44 | should raise an error""" 45 | 46 | def remove_ticket(ticket): 47 | """Remove a ticket for the current user. Upon success return True""" 48 | -------------------------------------------------------------------------------- /src/pyramid_authsanity/policy.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | 4 | from pyramid.authorization import Authenticated, Everyone 5 | from pyramid.interfaces import IAuthenticationPolicy, IDebugLogger 6 | from zope.interface import implementer 7 | 8 | from .util import _find_services, _session_registered, add_vary_callback 9 | 10 | 11 | def _clean_principal(princid): 12 | """Utility function that cleans up the passed in principal 13 | This can easily also be extended for example to make sure that certain 14 | usernames are automatically off-limits. 15 | """ 16 | if princid in (Authenticated, Everyone): 17 | princid = None 18 | return princid 19 | 20 | 21 | _marker = object() 22 | 23 | 24 | @implementer(IAuthenticationPolicy) 25 | class AuthServicePolicy(object): 26 | def _log(self, msg, methodname, request): 27 | logger = request.registry.queryUtility(IDebugLogger) 28 | if logger: 29 | cls = self.__class__ 30 | classname = cls.__module__ + "." + cls.__name__ 31 | methodname = classname + "." + methodname 32 | logger.debug(methodname + ": " + msg) 33 | 34 | _find_services = staticmethod(_find_services) # Testing 35 | _session_registered = staticmethod(_session_registered) # Testing 36 | _have_session = _marker 37 | 38 | def __init__(self, debug=False): 39 | self.debug = debug 40 | 41 | def unauthenticated_userid(self, request): 42 | """We do not allow the unauthenticated userid to be used.""" 43 | 44 | def authenticated_userid(self, request): 45 | """Returns the authenticated userid for this request.""" 46 | debug = self.debug 47 | 48 | (sourcesvc, authsvc) = self._find_services(request) 49 | request.add_response_callback(add_vary_callback(sourcesvc.vary)) 50 | 51 | try: 52 | userid = authsvc.userid() 53 | except Exception: 54 | debug and self._log( 55 | "authentication has not yet been completed", 56 | "authenticated_userid", 57 | request, 58 | ) 59 | (principal, ticket) = sourcesvc.get_value() 60 | 61 | debug and self._log( 62 | "source service provided information: (principal: %r, ticket: %r)" 63 | % (principal, ticket), 64 | "authenticated_userid", 65 | request, 66 | ) 67 | 68 | # Verify the principal and the ticket, even if None 69 | authsvc.verify_ticket(principal, ticket) 70 | 71 | try: 72 | # This should now return None or the userid 73 | userid = authsvc.userid() 74 | except Exception: 75 | userid = None 76 | 77 | debug and self._log( 78 | "authenticated_userid returning: %r" % (userid,), 79 | "authenticated_userid", 80 | request, 81 | ) 82 | 83 | return userid 84 | 85 | def effective_principals(self, request): 86 | """A list of effective principals derived from request.""" 87 | debug = self.debug 88 | effective_principals = [Everyone] 89 | 90 | userid = self.authenticated_userid(request) 91 | (_, authsvc) = self._find_services(request) 92 | 93 | if userid is None: 94 | debug and self._log( 95 | "authenticated_userid returned %r; returning %r" 96 | % (userid, effective_principals), 97 | "effective_principals", 98 | request, 99 | ) 100 | return effective_principals 101 | 102 | if _clean_principal(userid) is None: 103 | debug and self._log( 104 | ( 105 | "authenticated_userid returned disallowed %r; returning %r " 106 | "as if it was None" % (userid, effective_principals) 107 | ), 108 | "effective_principals", 109 | request, 110 | ) 111 | return effective_principals 112 | 113 | effective_principals.append(Authenticated) 114 | effective_principals.append(userid) 115 | effective_principals.extend(authsvc.groups()) 116 | 117 | debug and self._log( 118 | "returning effective principals: %r" % (effective_principals,), 119 | "effective_principals", 120 | request, 121 | ) 122 | return effective_principals 123 | 124 | def remember(self, request, principal, **kw): 125 | """Returns a list of headers that are to be set from the source service.""" 126 | debug = self.debug 127 | 128 | if self._have_session is _marker: 129 | self._have_session = self._session_registered(request) 130 | 131 | prev_userid = self.authenticated_userid(request) 132 | 133 | (sourcesvc, authsvc) = self._find_services(request) 134 | 135 | request.add_response_callback(add_vary_callback(sourcesvc.vary)) 136 | 137 | value = {} 138 | value["principal"] = principal 139 | value["ticket"] = ticket = ( 140 | base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode("ascii") 141 | ) 142 | 143 | debug and self._log( 144 | "Remember principal: %r, ticket: %r" % (principal, ticket), 145 | "remember", 146 | request, 147 | ) 148 | 149 | authsvc.add_ticket(principal, ticket) 150 | 151 | # Clear the previous session 152 | if self._have_session: 153 | if prev_userid != principal: 154 | request.session.invalidate() 155 | else: 156 | # We are logging in the same user that is already logged in, we 157 | # still want to generate a new session, but we can keep the 158 | # existing data 159 | data = dict(request.session.items()) 160 | request.session.invalidate() 161 | request.session.update(data) 162 | request.session.new_csrf_token() 163 | 164 | return sourcesvc.headers_remember([principal, ticket]) 165 | 166 | def forget(self, request): 167 | """A list of headers which will delete appropriate cookies.""" 168 | debug = self.debug 169 | 170 | if self._have_session is _marker: 171 | self._have_session = self._session_registered(request) 172 | 173 | (sourcesvc, authsvc) = self._find_services(request) 174 | 175 | request.add_response_callback(add_vary_callback(sourcesvc.vary)) 176 | 177 | (_, ticket) = sourcesvc.get_value() 178 | 179 | debug and self._log("Forgetting ticket: %r" % (ticket,), "forget", request) 180 | authsvc.remove_ticket(ticket) 181 | 182 | # Clear the session by invalidating it 183 | if self._have_session: 184 | request.session.invalidate() 185 | 186 | return sourcesvc.headers_forget() 187 | -------------------------------------------------------------------------------- /src/pyramid_authsanity/sources.py: -------------------------------------------------------------------------------- 1 | from webob.cookies import JSONSerializer, SignedCookieProfile, SignedSerializer 2 | from zope.interface import implementer 3 | 4 | from .interfaces import IAuthSourceService 5 | 6 | 7 | def SessionAuthSourceInitializer(value_key="sanity."): 8 | """An authentication source that uses the current session""" 9 | 10 | value_key = value_key + "value" 11 | 12 | @implementer(IAuthSourceService) 13 | class SessionAuthSource(object): 14 | vary = [] 15 | 16 | def __init__(self, context, request): 17 | self.request = request 18 | self.session = request.session 19 | self.cur_val = None 20 | 21 | def get_value(self): 22 | if self.cur_val is None: 23 | self.cur_val = self.session.get(value_key, [None, None]) 24 | 25 | return self.cur_val 26 | 27 | def headers_remember(self, value): 28 | if self.cur_val is None: 29 | self.cur_val = self.session.get(value_key, [None, None]) 30 | 31 | self.session[value_key] = value 32 | return [] 33 | 34 | def headers_forget(self): 35 | if self.cur_val is None: 36 | self.cur_val = self.session.get(value_key, [None, None]) 37 | 38 | if value_key in self.session: 39 | del self.session[value_key] 40 | return [] 41 | 42 | return SessionAuthSource 43 | 44 | 45 | def CookieAuthSourceInitializer( 46 | secret, 47 | cookie_name="auth", 48 | secure=False, 49 | max_age=None, 50 | httponly=False, 51 | path="/", 52 | domains=None, 53 | debug=False, 54 | hashalg="sha512", 55 | ): 56 | """An authentication source that uses a unique cookie.""" 57 | 58 | @implementer(IAuthSourceService) 59 | class CookieAuthSource(object): 60 | vary = ["Cookie"] 61 | 62 | def __init__(self, context, request): 63 | self.domains = domains 64 | 65 | if self.domains is None: 66 | self.domains = [] 67 | self.domains.append(request.domain) 68 | 69 | self.cookie = SignedCookieProfile( 70 | secret, 71 | "authsanity", 72 | cookie_name, 73 | secure=secure, 74 | max_age=max_age, 75 | httponly=httponly, 76 | path=path, 77 | domains=domains, 78 | hashalg=hashalg, 79 | ) 80 | # Bind the cookie to the current request 81 | self.cookie = self.cookie.bind(request) 82 | 83 | def get_value(self): 84 | val = self.cookie.get_value() 85 | 86 | if val is None: 87 | return [None, None] 88 | 89 | return val 90 | 91 | def headers_remember(self, value): 92 | return self.cookie.get_headers(value, domains=self.domains) 93 | 94 | def headers_forget(self): 95 | return self.cookie.get_headers(None, max_age=0) 96 | 97 | return CookieAuthSource 98 | 99 | 100 | def HeaderAuthSourceInitializer(secret, salt="sanity.header."): 101 | """An authentication source that uses the Authorization header.""" 102 | 103 | @implementer(IAuthSourceService) 104 | class HeaderAuthSource(object): 105 | vary = ["Authorization"] 106 | 107 | def __init__(self, context, request): 108 | self.request = request 109 | self.cur_val = None 110 | 111 | serializer = JSONSerializer() 112 | self.serializer = SignedSerializer( 113 | secret, 114 | salt, 115 | serializer=serializer, 116 | ) 117 | 118 | def _get_authorization(self): 119 | try: 120 | type, token = self.request.authorization 121 | 122 | return self.serializer.loads(token) 123 | except Exception: 124 | return None 125 | 126 | def _create_authorization(self, value): 127 | try: 128 | return self.serializer.dumps(value) 129 | except Exception: 130 | return "" 131 | 132 | def get_value(self): 133 | if self.cur_val is None: 134 | self.cur_val = self._get_authorization() or [None, None] 135 | 136 | return self.cur_val 137 | 138 | def headers_remember(self, value): 139 | if self.cur_val is None: 140 | self.cur_val = None 141 | 142 | token = self._create_authorization(value) 143 | auth_info = str(b"Bearer " + token, "latin-1", "strict") 144 | return [("Authorization", auth_info)] 145 | 146 | def headers_forget(self): 147 | if self.cur_val is None: 148 | self.cur_val = None 149 | 150 | return [] 151 | 152 | return HeaderAuthSource 153 | -------------------------------------------------------------------------------- /src/pyramid_authsanity/util.py: -------------------------------------------------------------------------------- 1 | from pyramid.interfaces import ISessionFactory 2 | 3 | from .interfaces import IAuthService, IAuthSourceService 4 | 5 | 6 | def int_or_none(x): 7 | return int(x) if x is not None else x 8 | 9 | 10 | def kw_from_settings(settings, from_prefix="authsanity."): 11 | return { 12 | k.replace(from_prefix, ""): v 13 | for k, v in settings.items() 14 | if k.startswith(from_prefix) 15 | } 16 | 17 | 18 | def add_vary_callback(vary_by): 19 | def vary_add(request, response): 20 | vary = set(response.vary if response.vary is not None else []) 21 | vary |= set(vary_by) 22 | response.vary = list(vary) 23 | 24 | return vary_add 25 | 26 | 27 | def _find_services(request): 28 | sourcesvc = request.find_service(IAuthSourceService) 29 | authsvc = request.find_service(IAuthService) 30 | 31 | return (sourcesvc, authsvc) 32 | 33 | 34 | def _session_registered(request): 35 | registry = request.registry 36 | factory = registry.queryUtility(ISessionFactory) 37 | 38 | return False if factory is None else True 39 | -------------------------------------------------------------------------------- /tests/test_authpolicy.py: -------------------------------------------------------------------------------- 1 | from pyramid.authorization import ACLAuthorizationPolicy 2 | from pyramid.interfaces import IAuthenticationPolicy 3 | import pyramid.testing 4 | import pytest 5 | from zope.interface import implementer 6 | from zope.interface.verify import verifyClass, verifyObject 7 | 8 | from pyramid_authsanity.interfaces import IAuthService, IAuthSourceService 9 | 10 | 11 | def test_clean_principal_invalid(): 12 | from pyramid.authorization import Everyone 13 | 14 | from pyramid_authsanity.policy import _clean_principal 15 | 16 | ret = _clean_principal(Everyone) 17 | 18 | assert ret is None 19 | 20 | 21 | def test_clean_principal_valid(): 22 | from pyramid_authsanity.policy import _clean_principal 23 | 24 | ret = _clean_principal("root") 25 | 26 | assert ret == "root" 27 | 28 | 29 | class TestAuthServicePolicyInterface(object): 30 | def test_verify(self): 31 | from pyramid_authsanity.policy import AuthServicePolicy 32 | 33 | assert verifyClass(IAuthenticationPolicy, AuthServicePolicy) 34 | assert verifyObject(IAuthenticationPolicy, AuthServicePolicy()) 35 | 36 | 37 | class TestAuthServicePolicy(object): 38 | @pytest.fixture(autouse=True) 39 | def pyramid_config(self, request): 40 | from pyramid.interfaces import IDebugLogger 41 | 42 | self.config = pyramid.testing.setUp() 43 | self.config.set_authorization_policy(ACLAuthorizationPolicy()) 44 | self.logger = DummyLogger() 45 | self.config.registry.registerUtility(self.logger, IDebugLogger) 46 | 47 | def finish(): 48 | del self.config 49 | pyramid.testing.tearDown() 50 | 51 | request.addfinalizer(finish) 52 | 53 | def _makeOne(self, debug=False, source=None, auth=None): 54 | from pyramid_authsanity import AuthServicePolicy 55 | 56 | def find_services(request): 57 | return (source, auth) 58 | 59 | def session_registered(request): 60 | return False 61 | 62 | policy = AuthServicePolicy(debug=debug) 63 | policy._find_services = find_services 64 | policy._session_registered = session_registered 65 | return policy 66 | 67 | def _makeOneRequest(self): 68 | request = DummyRequest() 69 | request.registry = self.config.registry 70 | return request 71 | 72 | def test_find_services(self): 73 | policy = self._makeOne() 74 | request = self._makeOneRequest() 75 | 76 | (source, auth) = policy._find_services(request) 77 | 78 | assert source is None 79 | assert auth is None 80 | 81 | def test_fake_source_ticket(self): 82 | context = None 83 | request = self._makeOneRequest() 84 | source = fake_source_init(None)(context, request) 85 | auth = fake_auth_init()(context, request) 86 | 87 | assert verifyObject(IAuthService, auth) 88 | assert verifyObject(IAuthSourceService, source) 89 | 90 | def test_valid_source_ticket(self): 91 | context = None 92 | request = self._makeOneRequest() 93 | source = fake_source_init(["test", "valid"])(context, request) 94 | auth = fake_auth_init(fake_userid="test", valid_tickets=["valid"])( 95 | context, request 96 | ) 97 | 98 | policy = self._makeOne(debug=True, source=source, auth=auth) 99 | 100 | authuserid = policy.authenticated_userid(request) 101 | assert authuserid == "test" 102 | 103 | def test_invalid_source_ticket(self): 104 | context = None 105 | request = self._makeOneRequest() 106 | source = fake_source_init(["test", "invalid"])(context, request) 107 | auth = fake_auth_init(fake_userid="test", valid_tickets=["valid"])( 108 | context, request 109 | ) 110 | 111 | policy = self._makeOne(source=source, auth=auth) 112 | 113 | authuserid = policy.authenticated_userid(request) 114 | assert authuserid is None 115 | 116 | def test_bad_auth_service(self): 117 | context = None 118 | request = self._makeOneRequest() 119 | source = fake_source_init([None, None])(context, request) 120 | 121 | class BadAuth(object): 122 | def userid(self): 123 | raise ValueError("No ticket verified") 124 | 125 | def verify_ticket(self, principal, ticket): 126 | pass 127 | 128 | auth = BadAuth() 129 | 130 | policy = self._makeOne(source=source, auth=auth) 131 | 132 | authuserid = policy.authenticated_userid(request) 133 | assert authuserid is None 134 | 135 | def test_no_user_effective_principals(self): 136 | from pyramid.authorization import Everyone 137 | 138 | context = None 139 | request = self._makeOneRequest() 140 | source = fake_source_init([None, None])(context, request) 141 | auth = fake_auth_init()(context, request) 142 | 143 | policy = self._makeOne(source=source, auth=auth) 144 | 145 | groups = policy.effective_principals(request) 146 | 147 | assert [Everyone] == groups 148 | 149 | def test_user_bad_principal_effective_principals(self): 150 | from pyramid.authorization import Everyone 151 | 152 | context = None 153 | request = self._makeOneRequest() 154 | source = fake_source_init([Everyone, "valid"])(context, request) 155 | auth = fake_auth_init(fake_userid=Everyone, valid_tickets=["valid"])( 156 | context, request 157 | ) 158 | 159 | policy = self._makeOne(source=source, auth=auth) 160 | 161 | groups = policy.effective_principals(request) 162 | 163 | assert [Everyone] == groups 164 | 165 | def test_effective_principals(self): 166 | from pyramid.authorization import Authenticated, Everyone 167 | 168 | context = None 169 | request = self._makeOneRequest() 170 | source = fake_source_init(["test", "valid"])(context, request) 171 | auth = fake_auth_init(fake_userid="test", valid_tickets=["valid"])( 172 | context, request 173 | ) 174 | 175 | policy = self._makeOne(source=source, auth=auth) 176 | 177 | groups = policy.effective_principals(request) 178 | 179 | assert [Everyone, Authenticated, "test"] == groups 180 | 181 | def test_remember(self): 182 | context = None 183 | request = self._makeOneRequest() 184 | source = fake_source_init([None, None])(context, request) 185 | auth = fake_auth_init(fake_userid="test")(context, request) 186 | 187 | policy = self._makeOne(source=source, auth=auth) 188 | 189 | headers = policy.remember(request, "test") 190 | 191 | assert len(headers) == 0 192 | assert len(auth.valid_tickets) >= 1 193 | assert isinstance(source.value, list) 194 | assert len(source.value) == 2 195 | 196 | def test_forget(self): 197 | context = None 198 | request = self._makeOneRequest() 199 | source = fake_source_init(["test", "valid"])(context, request) 200 | auth = fake_auth_init(fake_userid="test", valid_tickets=["valid"])( 201 | context, request 202 | ) 203 | 204 | policy = self._makeOne(source=source, auth=auth) 205 | 206 | assert "valid" in auth.valid_tickets 207 | 208 | headers = policy.forget(request) 209 | 210 | assert len(headers) == 0 211 | assert "valid" not in auth.valid_tickets 212 | 213 | 214 | class TestAuthServicePolicyIntegration(object): 215 | @pytest.fixture(autouse=True) 216 | def pyramid_config(self, request): 217 | from pyramid.interfaces import IDebugLogger, ISessionFactory 218 | 219 | self.config = pyramid.testing.setUp() 220 | self.config.registry.registerUtility(lambda: None, ISessionFactory) 221 | self.config.include("pyramid_services") 222 | self.config.set_authorization_policy(ACLAuthorizationPolicy()) 223 | self.logger = DummyLogger() 224 | self.config.registry.registerUtility(self.logger, IDebugLogger) 225 | 226 | def finish(): 227 | del self.config 228 | pyramid.testing.tearDown() 229 | 230 | request.addfinalizer(finish) 231 | 232 | def _makeOne(self, debug=False, source=None, auth=None): 233 | from pyramid_authsanity import AuthServicePolicy 234 | 235 | if source: 236 | self.config.register_service_factory(source, iface=IAuthSourceService) 237 | 238 | if auth: 239 | self.config.register_service_factory(auth, iface=IAuthService) 240 | 241 | return AuthServicePolicy(debug=debug) 242 | 243 | def _makeOneRequest(self): 244 | from pyramid.request import apply_request_extensions 245 | 246 | request = DummyRequest() 247 | request.registry = self.config.registry 248 | apply_request_extensions(request) 249 | 250 | return request 251 | 252 | def test_include_me(self): 253 | from pyramid_authsanity.policy import AuthServicePolicy 254 | 255 | self.config.include("pyramid_authsanity") 256 | self.config.commit() 257 | introspector = self.config.registry.introspector 258 | auth_policy = introspector.get("authentication policy", None) 259 | 260 | assert isinstance(auth_policy["policy"], AuthServicePolicy) 261 | 262 | def test_logging(self): 263 | policy = self._makeOne(debug=True) 264 | request = DummyRequest(registry=self.config.registry) 265 | policy._log("this message", "test_logging", request) 266 | 267 | assert len(self.logger.logentries) >= 1 268 | 269 | def test_find_services(self): 270 | from pyramid_authsanity.interfaces import IAuthService, IAuthSourceService 271 | 272 | self.config.register_service_factory( 273 | lambda x, y: "Source", iface=IAuthSourceService 274 | ) 275 | self.config.register_service_factory(lambda x, y: "Auth", iface=IAuthService) 276 | 277 | policy = self._makeOne() 278 | request = self._makeOneRequest() 279 | 280 | (sourcesvc, authsvc) = policy._find_services(request) 281 | 282 | assert sourcesvc == "Source" 283 | assert authsvc == "Auth" 284 | 285 | def test_valid_source_ticket(self): 286 | request = self._makeOneRequest() 287 | source = fake_source_init(["test", "valid"]) 288 | auth = fake_auth_init(fake_userid="test", valid_tickets=["valid"]) 289 | 290 | policy = self._makeOne(debug=True, source=source, auth=auth) 291 | 292 | authuserid = policy.authenticated_userid(request) 293 | assert authuserid == "test" 294 | 295 | def test_invalid_source_ticket(self): 296 | request = self._makeOneRequest() 297 | source = fake_source_init(["test", "invalid"]) 298 | auth = fake_auth_init(fake_userid="test", valid_tickets=["valid"]) 299 | 300 | policy = self._makeOne(source=source, auth=auth) 301 | 302 | authuserid = policy.authenticated_userid(request) 303 | assert authuserid is None 304 | 305 | def test_bad_auth_service(self): 306 | request = self._makeOneRequest() 307 | source = fake_source_init([None, None]) 308 | 309 | class BadAuth(object): 310 | def __init__(self, context, request): 311 | pass 312 | 313 | def userid(self): 314 | raise ValueError("No ticket verification") 315 | 316 | def verify_ticket(self, principal, ticket): 317 | pass 318 | 319 | policy = self._makeOne(source=source, auth=BadAuth) 320 | 321 | authuserid = policy.authenticated_userid(request) 322 | assert authuserid is None 323 | 324 | def test_no_user_effective_principals(self): 325 | from pyramid.authorization import Everyone 326 | 327 | request = self._makeOneRequest() 328 | source = fake_source_init([None, None]) 329 | auth = fake_auth_init() 330 | 331 | policy = self._makeOne(source=source, auth=auth) 332 | 333 | groups = policy.effective_principals(request) 334 | 335 | assert [Everyone] == groups 336 | 337 | def test_user_bad_principal_effective_principals(self): 338 | from pyramid.authorization import Everyone 339 | 340 | request = self._makeOneRequest() 341 | source = fake_source_init([Everyone, "valid"]) 342 | auth = fake_auth_init(fake_userid=Everyone, valid_tickets=["valid"]) 343 | 344 | policy = self._makeOne(source=source, auth=auth) 345 | 346 | groups = policy.effective_principals(request) 347 | 348 | assert [Everyone] == groups 349 | 350 | def test_effective_principals(self): 351 | from pyramid.authorization import Authenticated, Everyone 352 | 353 | request = self._makeOneRequest() 354 | source = fake_source_init(["test", "valid"]) 355 | auth = fake_auth_init(fake_userid="test", valid_tickets=["valid"]) 356 | 357 | policy = self._makeOne(source=source, auth=auth) 358 | 359 | groups = policy.effective_principals(request) 360 | 361 | assert [Everyone, Authenticated, "test"] == groups 362 | 363 | def test_remember(self): 364 | request = self._makeOneRequest() 365 | source = fake_source_init([None, None]) 366 | auth = fake_auth_init(fake_userid="test") 367 | 368 | policy = self._makeOne(source=source, auth=auth) 369 | 370 | headers = policy.remember(request, "test") 371 | 372 | authreq = request.find_service(IAuthService) 373 | 374 | assert len(headers) == 0 375 | assert len(authreq.valid_tickets) >= 1 376 | 377 | def test_remember_value_json_serializable(self): 378 | request = self._makeOneRequest() 379 | source = fake_source_init([None, None]) 380 | auth = fake_auth_init(fake_userid="test") 381 | 382 | policy = self._makeOne(source=source, auth=auth) 383 | 384 | headers = policy.remember(request, "test") 385 | 386 | authreq = request.find_service(IAuthService) 387 | sourcereq = request.find_service(IAuthSourceService) 388 | 389 | assert len(headers) == 0 390 | assert len(authreq.valid_tickets) >= 1 391 | import json 392 | 393 | assert json.dumps(sourcereq.value) 394 | 395 | def test_remember_same_user(self): 396 | request = self._makeOneRequest() 397 | source = fake_source_init(["test", "valid_ticket"]) 398 | auth = fake_auth_init(fake_userid="test", valid_tickets=["valid_ticket"]) 399 | 400 | policy = self._makeOne(source=source, auth=auth) 401 | 402 | headers = policy.remember(request, "test") 403 | 404 | authreq = request.find_service(IAuthService) 405 | 406 | assert len(headers) == 0 407 | assert len(authreq.valid_tickets) >= 1 408 | 409 | def test_forget(self): 410 | request = self._makeOneRequest() 411 | source = fake_source_init(["test", "valid"]) 412 | auth = fake_auth_init(fake_userid="test", valid_tickets=["valid"]) 413 | 414 | policy = self._makeOne(source=source, auth=auth) 415 | 416 | authreq = request.find_service(IAuthService) 417 | 418 | assert "valid" in authreq.valid_tickets 419 | 420 | headers = policy.forget(request) 421 | 422 | assert len(headers) == 0 423 | assert "valid" not in authreq.valid_tickets 424 | 425 | 426 | class DummyLogger(object): 427 | def debug(self, log): 428 | self.logentries.append(log) 429 | 430 | def __init__(self): 431 | self.logentries = [] 432 | 433 | 434 | class DummyRequest(object): 435 | domain = "example.net" 436 | 437 | def __init__(self, environ=None, session=None, registry=None, cookie=None): 438 | class Session(dict): 439 | def invalidate(self): 440 | self.clear() 441 | 442 | def new_csrf_token(self): 443 | pass 444 | 445 | self.environ = environ or {} 446 | self.session = Session() 447 | self.session.update(session or {}) 448 | self.registry = registry 449 | self.callbacks = [] 450 | self.cookies = cookie or [] 451 | self.context = None 452 | 453 | def add_response_callback(self, callback): 454 | self.callbacks.append(callback) 455 | 456 | 457 | def fake_source_init(fake_value): 458 | @implementer(IAuthSourceService) 459 | class fake_source(object): 460 | vary = ["Cookie"] 461 | 462 | def __init__(self, context, request): 463 | self.value = fake_value 464 | 465 | def get_value(self): 466 | return self.value if self.value else [None, None] 467 | 468 | def headers_remember(self, value): 469 | self.value = value 470 | return [] 471 | 472 | def headers_forget(self): 473 | self.value = [None, None] 474 | return [] 475 | 476 | return fake_source 477 | 478 | 479 | def fake_auth_init(fake_userid=None, fake_groups=list(), valid_tickets=list()): 480 | @implementer(IAuthService) 481 | class fake_auth(object): 482 | def __init__(self, context, request): 483 | self.authcomplete = False 484 | self.ticketvalid = False 485 | self._userid = fake_userid 486 | self._groups = fake_groups 487 | self.valid_tickets = valid_tickets 488 | 489 | def userid(self): 490 | if not self.authcomplete: 491 | raise ValueError("No ticket verified.") 492 | 493 | if not self.ticketvalid: 494 | return None 495 | 496 | return self._userid 497 | 498 | def groups(self): 499 | return self._groups 500 | 501 | def verify_ticket(self, principal, ticket): 502 | self.authcomplete = True 503 | if principal == self._userid and ticket in self.valid_tickets: 504 | self.ticketvalid = True 505 | 506 | def add_ticket(self, principal, ticket): 507 | if ticket not in self.valid_tickets: 508 | self.valid_tickets.append(ticket) 509 | 510 | def remove_ticket(self, ticket): 511 | if ticket in self.valid_tickets: 512 | self.valid_tickets.remove(ticket) 513 | 514 | return fake_auth 515 | -------------------------------------------------------------------------------- /tests/test_includeme.py: -------------------------------------------------------------------------------- 1 | from pyramid.authorization import ACLAuthorizationPolicy 2 | import pyramid.testing 3 | from pyramid_services import find_service_factory 4 | import pytest 5 | from zope.interface.verify import verifyClass 6 | 7 | from pyramid_authsanity.interfaces import IAuthSourceService 8 | 9 | 10 | class TestAuthServicePolicyIntegration(object): 11 | @pytest.fixture(autouse=True) 12 | def pyramid_config(self, request): 13 | self.config = pyramid.testing.setUp() 14 | self.config.set_authorization_policy(ACLAuthorizationPolicy()) 15 | 16 | def finish(): 17 | del self.config 18 | pyramid.testing.tearDown() 19 | 20 | request.addfinalizer(finish) 21 | 22 | def _makeOne(self, settings): 23 | self.config.registry.settings.update(settings) 24 | self.config.include("pyramid_authsanity") 25 | 26 | def test_include_me(self): 27 | from pyramid_authsanity.policy import AuthServicePolicy 28 | 29 | self._makeOne({}) 30 | self.config.commit() 31 | introspector = self.config.registry.introspector 32 | auth_policy = introspector.get("authentication policy", None) 33 | 34 | assert isinstance(auth_policy["policy"], AuthServicePolicy) 35 | 36 | with pytest.raises(LookupError): 37 | find_service_factory(self.config, IAuthSourceService) 38 | 39 | def test_include_me_cookie_no_secret(self): 40 | settings = {"authsanity.source": "cookie"} 41 | 42 | with pytest.raises(RuntimeError): 43 | self._makeOne(settings) 44 | 45 | def test_include_me_cookie_with_secret(self): 46 | from pyramid_authsanity.policy import AuthServicePolicy 47 | 48 | settings = {"authsanity.source": "cookie", "authsanity.secret": "sekrit"} 49 | 50 | self._makeOne(settings) 51 | self.config.commit() 52 | introspector = self.config.registry.introspector 53 | auth_policy = introspector.get("authentication policy", None) 54 | 55 | assert isinstance(auth_policy["policy"], AuthServicePolicy) 56 | assert verifyClass( 57 | IAuthSourceService, find_service_factory(self.config, IAuthSourceService) 58 | ) 59 | 60 | def test_include_me_session(self): 61 | from pyramid_authsanity.policy import AuthServicePolicy 62 | 63 | settings = {"authsanity.source": "session"} 64 | 65 | self._makeOne(settings) 66 | self.config.commit() 67 | introspector = self.config.registry.introspector 68 | auth_policy = introspector.get("authentication policy", None) 69 | 70 | assert isinstance(auth_policy["policy"], AuthServicePolicy) 71 | assert verifyClass( 72 | IAuthSourceService, find_service_factory(self.config, IAuthSourceService) 73 | ) 74 | 75 | def test_include_me_header_with_secret(self): 76 | from pyramid_authsanity.policy import AuthServicePolicy 77 | 78 | settings = {"authsanity.source": "header", "authsanity.secret": "sekrit"} 79 | 80 | self._makeOne(settings) 81 | self.config.commit() 82 | introspector = self.config.registry.introspector 83 | auth_policy = introspector.get("authentication policy", None) 84 | 85 | assert isinstance(auth_policy["policy"], AuthServicePolicy) 86 | assert verifyClass( 87 | IAuthSourceService, find_service_factory(self.config, IAuthSourceService) 88 | ) 89 | 90 | def test_include_me_header_no_secret(self): 91 | settings = {"authsanity.source": "header"} 92 | 93 | with pytest.raises(RuntimeError): 94 | self._makeOne(settings) 95 | -------------------------------------------------------------------------------- /tests/test_sources.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | import sys 3 | 4 | import pytest 5 | from zope.interface.verify import verifyObject 6 | 7 | from pyramid_authsanity import sources 8 | from pyramid_authsanity.interfaces import IAuthSourceService 9 | 10 | 11 | class _TestAuthSource(object): 12 | def test_verify_object(self): 13 | assert verifyObject(IAuthSourceService, self._makeOne()) 14 | 15 | def test_get_value(self): 16 | source = self._makeOne() 17 | val = source.get_value() 18 | 19 | assert val == [None, None] 20 | 21 | def test_headers_remember(self): 22 | source = self._makeOne() 23 | headers = source.headers_remember("test") 24 | 25 | assert isinstance(headers, Iterable) 26 | 27 | def test_headers_forget(self): 28 | source = self._makeOne() 29 | headers = source.headers_forget() 30 | 31 | assert isinstance(headers, Iterable) 32 | 33 | 34 | class TestSessionAuthSource(_TestAuthSource): 35 | def _makeOne(self, request=None): 36 | obj = sources.SessionAuthSourceInitializer() 37 | 38 | if request is None: 39 | request = DummyRequest() 40 | 41 | return obj(None, request) 42 | 43 | def test_remember(self): 44 | request = DummyRequest() 45 | source = self._makeOne(request=request) 46 | source.headers_remember("test") 47 | 48 | assert request.session["sanity.value"] == "test" 49 | 50 | def test_forget(self): 51 | request = DummyRequest() 52 | request.session["sanity.value"] = "test" 53 | source = self._makeOne(request=request) 54 | source.headers_forget() 55 | 56 | assert "sanity.value" not in request.session 57 | 58 | def test_get_value_from_session(self): 59 | request = DummyRequest() 60 | request.session["sanity.value"] = "test" 61 | source = self._makeOne(request=request) 62 | val = source.get_value() 63 | 64 | assert val == "test" 65 | 66 | def test_get_previous_value_after_remember(self): 67 | request = DummyRequest() 68 | source = self._makeOne(request=request) 69 | val = source.get_value() 70 | 71 | assert val == [None, None] 72 | 73 | source.headers_remember("test") 74 | 75 | val2 = source.get_value() 76 | 77 | assert val2 == [None, None] 78 | assert request.session["sanity.value"] == "test" 79 | 80 | 81 | class TestCookieAuthSource(_TestAuthSource): 82 | def _makeOne(self, request=None): 83 | obj = sources.CookieAuthSourceInitializer("seekrit") 84 | 85 | if request is None: 86 | request = DummyRequest() 87 | 88 | return obj(None, request) 89 | 90 | def test_get_header_remember(self): 91 | source = self._makeOne() 92 | headers = source.headers_remember("test") 93 | 94 | assert isinstance(headers, Iterable) 95 | 96 | for h in headers: 97 | assert "auth" in h[1] 98 | 99 | @pytest.mark.skipif( 100 | sys.version_info < (3, 0), 101 | reason="json.dumps() doesn't like binary data on Python 3.x", 102 | ) 103 | def test_get_header_remember_binary(self): 104 | source = self._makeOne() 105 | with pytest.raises(TypeError): 106 | source.headers_remember(b"test") 107 | 108 | def test_get_header_forget(self): 109 | source = self._makeOne() 110 | headers = source.headers_forget() 111 | 112 | assert isinstance(headers, Iterable) 113 | 114 | # Should set an empty cookie 115 | 116 | for h in headers: 117 | assert h[1].startswith("auth=;") 118 | 119 | def test_get_value_cookie(self): 120 | request = DummyRequest() 121 | request.cookies["auth"] = ( 122 | "JgEICiZyfFFc3Qcx5O84h4u8NSZIi51xVMYs_HyP94BO1aXGZpME_LJ1UZgfdAMJD" 123 | "oaGaLCt_y-x6FSBh3ZKDyJ0ZXN0Ig" 124 | ) 125 | source = self._makeOne(request=request) 126 | val = source.get_value() 127 | 128 | assert val == "test" 129 | 130 | def test_get_value_bad_cookie(self): 131 | request = DummyRequest() 132 | request.cookies["auth"] = ( 133 | "jxxxxxxxfFFc3Qcx5O84h4u8NSZIi51xVMYs_HyP94BO1aXGZpME_LJ1UZgfdAMJD" 134 | "oaGaLCt_y-x6FSBh3ZKDyJ0ZXN0Ig" 135 | ) 136 | source = self._makeOne(request=request) 137 | val = source.get_value() 138 | 139 | assert val == [None, None] 140 | 141 | def test_get_value_empty_cookie(self): 142 | request = DummyRequest() 143 | request.cookies["auth"] = "" 144 | source = self._makeOne(request=request) 145 | val = source.get_value() 146 | 147 | assert val == [None, None] 148 | 149 | def test_round_trip_cookie(self): 150 | source1 = self._makeOne() 151 | headers1 = source1.headers_remember(["user1", "ticket1"]) 152 | 153 | assert isinstance(headers1, Iterable) 154 | assert len(headers1) == 1 155 | 156 | set_cookie = headers1[0][1] 157 | authpart = set_cookie.split(" ")[0] 158 | (name, cookie) = authpart.split("=") 159 | 160 | assert name == "auth" 161 | 162 | request = DummyRequest() 163 | request.cookies[name] = cookie[:-1] 164 | 165 | source2 = self._makeOne(request=request) 166 | val = source2.get_value() 167 | 168 | assert val == ["user1", "ticket1"] 169 | 170 | 171 | class TestHeaderAuthSource(_TestAuthSource): 172 | def _makeOne(self, request=None): 173 | obj = sources.HeaderAuthSourceInitializer("seekrit") 174 | 175 | if request is None: 176 | request = DummyRequest() 177 | 178 | return obj(None, request) 179 | 180 | def test_get_header_remember(self): 181 | source = self._makeOne() 182 | headers = source.headers_remember("test") 183 | 184 | assert isinstance(headers, Iterable) 185 | assert len(headers) >= 1 186 | 187 | for h in headers: 188 | assert "Authorization" in h[0] 189 | 190 | @pytest.mark.skipif( 191 | sys.version_info < (3, 0), 192 | reason="json.dumps() doesn't like binary data on Python 3.x", 193 | ) 194 | def test_get_header_remember_binary(self): 195 | source = self._makeOne() 196 | with pytest.raises(TypeError): 197 | source.headers_remember(b"test") 198 | 199 | def test_get_header_forget(self): 200 | source = self._makeOne() 201 | headers = source.headers_forget() 202 | 203 | assert isinstance(headers, Iterable) 204 | assert len(headers) == 0 205 | 206 | def test_get_value_bad_authorization(self): 207 | request = DummyRequest() 208 | request.authorization = ("Bearer", "thisisinvalid") 209 | source = self._makeOne(request=request) 210 | val = source.get_value() 211 | 212 | assert val == [None, None] 213 | 214 | def test_get_value_authorization(self): 215 | request = DummyRequest() 216 | request.authorization = ( 217 | "Bearer", 218 | ( 219 | "zASow9lpNp6cr7FirG4kV6vQym8i75kLPZ7orcPMaemV4iaf92P-DTR0om_h0trI" 220 | "mTEOXyv514obhbcB-3fvKyJ0ZXN0Ig" 221 | ), 222 | ) 223 | source = self._makeOne(request=request) 224 | val = source.get_value() 225 | 226 | assert val == "test" 227 | 228 | 229 | class DummyRequest(object): 230 | def __init__(self): 231 | self.session = dict() 232 | self.domain = "example.net" 233 | self.cookies = dict() 234 | self.authorization = None 235 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | from pytest import raises 2 | 3 | from pyramid_authsanity.util import add_vary_callback 4 | 5 | 6 | class TestAddVaryCallback(object): 7 | def _makeOne(self, *varies): 8 | return add_vary_callback(varies) 9 | 10 | def test_add_single_vary(self): 11 | cb = self._makeOne("cookie") 12 | response = DummyResponse() 13 | cb(None, response) 14 | 15 | assert len(response.vary) == 1 16 | assert "cookie" in response.vary 17 | 18 | def test_add_multiple_vary(self): 19 | cb = self._makeOne("cookie", "authorization") 20 | response = DummyResponse() 21 | cb(None, response) 22 | 23 | assert len(response.vary) == 2 24 | assert "cookie" in response.vary 25 | assert "authorization" in response.vary 26 | 27 | def test_add_multiple_existing(self): 28 | cb = self._makeOne("cookie") 29 | response = DummyResponse() 30 | response.vary = ["cookie"] 31 | cb(None, response) 32 | 33 | assert len(response.vary) == 1 34 | assert "cookie" in response.vary 35 | 36 | 37 | def test_int_or_none_none(): 38 | from pyramid_authsanity.util import int_or_none 39 | 40 | assert int_or_none(None) is None 41 | 42 | 43 | def test_int_or_none_int(): 44 | from pyramid_authsanity.util import int_or_none 45 | 46 | assert int_or_none(1) == 1 47 | 48 | 49 | def test_int_or_none_fail(): 50 | from pyramid_authsanity.util import int_or_none 51 | 52 | with raises(ValueError): 53 | int_or_none("test") 54 | 55 | 56 | def test_kw_from_settings(): 57 | from pyramid_authsanity.util import kw_from_settings 58 | 59 | settings = { 60 | "authsanity.test": None, 61 | "authsanity.other": "other", 62 | "notsanity.test": True, 63 | } 64 | 65 | kw = kw_from_settings(settings) 66 | 67 | assert kw == {"test": None, "other": "other"} 68 | 69 | 70 | def test_kw_from_settings_custom(): 71 | from pyramid_authsanity.util import kw_from_settings 72 | 73 | settings = { 74 | "authsanity.cookie.test": True, 75 | "authsanity.session.test": False, 76 | } 77 | kw = kw_from_settings(settings, "authsanity.cookie.") 78 | assert kw == {"test": True} 79 | 80 | 81 | class DummyResponse(object): 82 | vary = None 83 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | lint, 4 | py36,py37,py38,py39,pypy3 5 | py39-cover,coverage, 6 | docs 7 | 8 | [testenv] 9 | commands = 10 | python --version 11 | pytest {posargs:} 12 | extras = 13 | testing 14 | setenv = 15 | COVERAGE_FILE=.coverage.{envname} 16 | 17 | [testenv:py39-cover] 18 | commands = 19 | python --version 20 | pytest --cov {posargs:} 21 | 22 | [testenv:coverage] 23 | skip_install = True 24 | commands = 25 | coverage combine 26 | coverage xml 27 | coverage report --fail-under=100 28 | deps = 29 | coverage 30 | setenv = 31 | COVERAGE_FILE=.coverage 32 | depends = py39-cover 33 | 34 | [testenv:docs] 35 | allowlist_externals = 36 | make 37 | commands = 38 | pip install pyramid_authsanity[docs] 39 | make -C docs html BUILDDIR={envdir} "SPHINXOPTS=-W -E -D suppress_warnings=ref.term" 40 | extras = 41 | docs 42 | 43 | [testenv:lint] 44 | skip_install = True 45 | commands = 46 | isort --check-only --df src/pyramid_authsanity tests 47 | black --check --diff . 48 | check-manifest 49 | # flake8 src/pyramid_authsanity/ tests 50 | # build sdist/wheel 51 | python -m pep517.build . 52 | twine check dist/* 53 | deps = 54 | black 55 | check-manifest 56 | flake8 57 | flake8-bugbear 58 | isort 59 | pep517 60 | readme_renderer 61 | twine 62 | 63 | [testenv:format] 64 | skip_install = true 65 | commands = 66 | isort src/pyramid_authsanity tests 67 | black . 68 | deps = 69 | black 70 | isort 71 | 72 | [testenv:build] 73 | skip_install = true 74 | commands = 75 | # clean up build/ and dist/ folders 76 | python -c 'import shutil; shutil.rmtree("build", ignore_errors=True)' 77 | # Make sure we aren't forgetting anything 78 | check-manifest 79 | # build sdist/wheel 80 | python -m pep517.build . 81 | # Verify all is well 82 | twine check dist/* 83 | 84 | deps = 85 | readme_renderer 86 | check-manifest 87 | pep517 88 | twine 89 | --------------------------------------------------------------------------------